Package: Pillow (PyPI)
Tested against: 12.3.0 (current PyPI release)
Affected: Image.open()'s format-probe loop (src/PIL/Image.py,
_open_core()) and multi-frame .seek() (e.g. GifImagePlugin.py)
Summary
Image.open()'s docstring is explicit:
:exception PIL.UnidentifiedImageError: If the image cannot be opened
and identified.
Its internal implementation tries each registered format plugin's _open() in turn, and normalizes failures into that documented exception -- but only for four exception types:
try:
...
im = factory(fp, filename)
_decompression_bomb_check(im.size)
return im
except (SyntaxError, IndexError, TypeError, struct.error) as e:
if WARN_POSSIBLE_FORMATS:
warning_messages.append(i + " opening failed. " + str(e))
except BaseException:
if exclusive_fp:
fp.close()
raise
ValueError is not in that tuple. Any plugin whose _open() raises a bare ValueError on malformed input escapes this loop entirely via the except BaseException: raise branch, bypassing both the "try the next format" fallback and the final raise UnidentifiedImageError(...) a caller relying on the documented contract expects.
Finding 1: PNG decompression-bomb guard raises ValueError, not UnidentifiedImageError
PngImagePlugin._safe_zlib_decompress() deliberately guards against
decompression bombs in text chunks:
def _safe_zlib_decompress(s: bytes) -> bytes:
dobj = zlib.decompressobj()
plaintext = dobj.decompress(s, MAX_TEXT_CHUNK)
if dobj.unconsumed_tail:
msg = "Decompressed data too large for PngImagePlugin.MAX_TEXT_CHUNK"
raise ValueError(msg)
return plaintext
Called from three chunk handlers (iCCP, zTXt, iTXt), each of which explicitly catches ValueError -- but re-raises it when ImageFile.LOAD_TRUNCATED_IMAGES is False (the default):
try:
v = _safe_zlib_decompress(v)
except ValueError:
if ImageFile.LOAD_TRUNCATED_IMAGES:
return s
else:
raise
With default settings, this ValueError propagates all the way out of Image.open() as a raw builtin ValueError -- not PIL.UnidentifiedImageError -- despite the file being exactly the kind of "cannot be opened and identified" case the documented contract is meant to cover.
Reproduction: a 1x1 PNG (10,303 bytes) with a single iTXt chunk that decompresses to 10MB:
import io, struct, zlib
from PIL import Image
def chunk(tag, data):
return struct.pack(">I", len(data)) + tag + data + struct.pack(">I", zlib.crc32(tag+data) & 0xFFFFFFFF)
sig = b"\x89PNG\r\n\x1a\n"
ihdr = struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0)
keyword = b"Comment"
compressed = zlib.compress(b"A" * (10 * 1024 * 1024), 9)
itxt = keyword + b"\x00\x01\x00\x00\x00" + compressed
idat = zlib.compress(b"\x00\xff\xff\xff")
data = sig + chunk(b"IHDR", ihdr) + chunk(b"iTXt", itxt) + chunk(b"IDAT", idat) + chunk(b"IEND", b"")
Image.open(io.BytesIO(data))
# raises: ValueError: Decompressed data too large for PngImagePlugin.MAX_TEXT_CHUNK
# NOT PIL.UnidentifiedImageError
Finding 2: multi-frame .seek() has no safety net at all
Separately: once a multi-frame image is successfully opened, .seek(frame) for frame > 0 has no exception-normalizing wrapper -- not even the four types Image.open() itself catches. Two concrete GIF cases, both firing on .seek(1) after Image.open() succeeds cleanly at frame 0:
2a. Truncated local color table -> uncaught IndexError. A 40-byte GIF where frame 1 declares a local color table (6 bytes expected for bits=0) but only 2 bytes are present before EOF:
GifImagePlugin._is_palette_needed() indexes p[i+1]/p[i+2] without a length check.
2b. Truncated frame descriptor -> uncaught struct.error. A 33-byte GIF where frame 1's local-image descriptor (9 bytes: x0,y0,x1,y1,flags) is cut to 4 bytes before EOF: i16(s, 4) (struct.unpack_from) raises because the buffer is shorter than the offset it's asked to read from.
2c. Same gap in APNG, a different exception type and call site. A 2-frame APNG (built with Pillow's own encoder, then its fdAT chunk truncated mid-data): .seek(1) itself returns normally here, but the very next .load() -- the obvious next call, needed to actually decode frame 1's pixels -- raises an uncaught OSError: image file is truncated. A third exception type, in a different plugin, hitting the same underlying gap: nothing past the initial Image.open() probe loop normalizes failures into a documented exception type.
All three reproduced live (attached script) -- Image.open() succeeds, .seek(1) raises the raw builtin exception with no normalization at all, an even weaker guarantee than the (already incomplete) one Image.open() itself provides.
Why this matters
A caller following Pillow's own documented pattern --
try: Image.open(...) except UnidentifiedImageError: ... -- to safely handle untrusted/malformed images will still crash on exactly the class of input that pattern is meant to guard against, for both PNG (Finding 1) and any multi-frame format accessed via .seek()` (Finding 2).
Worth being precise about how far this reaches: a single-request handler wrapped in a normal framework dispatch loop (Flask/Django/etc.) isn't affected, since the framework's own outer exception handling turns the crash into an ordinary 500 for that one request. The real-world failure mode is narrower and specific: a batch/pipeline loop over multiple images that uses only the documented except UnidentifiedImageError (no outer except Exception) can have one malformed file silently abort processing
of every file after it in that run, with no error indicating the batch was cut short. Confirmed this distinction directly by running both
shapes side by side.
Suggested direction (not attempted)
Adding ValueError to the normalized set in Image.open()'s probe loop would close Finding 1 (though it's worth checking whether any legitimate caller currently depends on a raw ValueError escaping there). For Finding 2, .seek() implementations across multi-frame plugins would need their own equivalent try/except normalizing to a documented exception type -- a larger, cross-plugin change I haven't attempted, since I don't know the full set of plugins and call sites affected.
How I found this
This came out of an automated study that has several LLM-based code-review passes read library source and report suspected defects, each only counted once it was reproduced end-to-end against the real source. The original findings described a vaguer "unguarded seek()-path" pattern across several PNG/GIF/TIFF locations; before writing this report I re-derived the precise mechanism myself by reading Image.open()'s actual exception-handling code, which is what let me state exactly which four exception types are normalized and why ValueError isn't among them. A sixth related finding (a TIFF __getitem__ lazy-unpack crash) is not included here -- I couldn't independently reconstruct a concrete repro for it within reasonable effort this round, so I'd rather omit it than include something I haven't verified myself. Happy to answer questions about the methodology, but the findings should be judged on whether the scripts reproduce on your end.
Attachment
pillow_uncaught_exception_poc.py -- self-contained, builds all three minimal test files in-memory (no external files needed), prints exactly which exception type escapes and confirms it's not UnidentifiedImageError.
Package: Pillow (PyPI)
Tested against: 12.3.0 (current PyPI release)
Affected:
Image.open()'s format-probe loop (src/PIL/Image.py,_open_core()) and multi-frame.seek()(e.g.GifImagePlugin.py)Summary
Image.open()'s docstring is explicit:Its internal implementation tries each registered format plugin's
_open()in turn, and normalizes failures into that documented exception -- but only for four exception types:ValueErroris not in that tuple. Any plugin whose_open()raises a bareValueErroron malformed input escapes this loop entirely via theexcept BaseException: raisebranch, bypassing both the "try the next format" fallback and the finalraise UnidentifiedImageError(...)a caller relying on the documented contract expects.Finding 1: PNG decompression-bomb guard raises ValueError, not UnidentifiedImageError
PngImagePlugin._safe_zlib_decompress()deliberately guards againstdecompression bombs in text chunks:
Called from three chunk handlers (iCCP, zTXt, iTXt), each of which explicitly catches
ValueError-- but re-raises it whenImageFile.LOAD_TRUNCATED_IMAGESisFalse(the default):With default settings, this
ValueErrorpropagates all the way out ofImage.open()as a raw builtinValueError-- notPIL.UnidentifiedImageError-- despite the file being exactly the kind of "cannot be opened and identified" case the documented contract is meant to cover.Reproduction: a 1x1 PNG (10,303 bytes) with a single iTXt chunk that decompresses to 10MB:
Finding 2: multi-frame .seek() has no safety net at all
Separately: once a multi-frame image is successfully opened,
.seek(frame)forframe > 0has no exception-normalizing wrapper -- not even the four typesImage.open()itself catches. Two concrete GIF cases, both firing on.seek(1)afterImage.open()succeeds cleanly at frame 0:2a. Truncated local color table -> uncaught IndexError. A 40-byte GIF where frame 1 declares a local color table (6 bytes expected for
bits=0) but only 2 bytes are present before EOF:GifImagePlugin._is_palette_needed()indexesp[i+1]/p[i+2]without a length check.2b. Truncated frame descriptor -> uncaught struct.error. A 33-byte GIF where frame 1's local-image descriptor (9 bytes: x0,y0,x1,y1,flags) is cut to 4 bytes before EOF:
i16(s, 4)(struct.unpack_from) raises because the buffer is shorter than the offset it's asked to read from.2c. Same gap in APNG, a different exception type and call site. A 2-frame APNG (built with Pillow's own encoder, then its
fdATchunk truncated mid-data):.seek(1)itself returns normally here, but the very next.load()-- the obvious next call, needed to actually decode frame 1's pixels -- raises an uncaughtOSError: image file is truncated. A third exception type, in a different plugin, hitting the same underlying gap: nothing past the initialImage.open()probe loop normalizes failures into a documented exception type.All three reproduced live (attached script) --
Image.open()succeeds,.seek(1)raises the raw builtin exception with no normalization at all, an even weaker guarantee than the (already incomplete) oneImage.open()itself provides.Why this matters
A caller following Pillow's own documented pattern --
try: Image.open(...) except UnidentifiedImageError: ...-- to safely handle untrusted/malformed images will still crash on exactly the class of input that pattern is meant to guard against, for both PNG (Finding 1) and any multi-frame format accessed via .seek()` (Finding 2).Worth being precise about how far this reaches: a single-request handler wrapped in a normal framework dispatch loop (Flask/Django/etc.) isn't affected, since the framework's own outer exception handling turns the crash into an ordinary 500 for that one request. The real-world failure mode is narrower and specific: a batch/pipeline loop over multiple images that uses only the documented
except UnidentifiedImageError(no outerexcept Exception) can have one malformed file silently abort processingof every file after it in that run, with no error indicating the batch was cut short. Confirmed this distinction directly by running both
shapes side by side.
Suggested direction (not attempted)
Adding
ValueErrorto the normalized set inImage.open()'s probe loop would close Finding 1 (though it's worth checking whether any legitimate caller currently depends on a rawValueErrorescaping there). For Finding 2,.seek()implementations across multi-frame plugins would need their own equivalent try/except normalizing to a documented exception type -- a larger, cross-plugin change I haven't attempted, since I don't know the full set of plugins and call sites affected.How I found this
This came out of an automated study that has several LLM-based code-review passes read library source and report suspected defects, each only counted once it was reproduced end-to-end against the real source. The original findings described a vaguer "unguarded seek()-path" pattern across several PNG/GIF/TIFF locations; before writing this report I re-derived the precise mechanism myself by reading
Image.open()'s actual exception-handling code, which is what let me state exactly which four exception types are normalized and whyValueErrorisn't among them. A sixth related finding (a TIFF__getitem__lazy-unpack crash) is not included here -- I couldn't independently reconstruct a concrete repro for it within reasonable effort this round, so I'd rather omit it than include something I haven't verified myself. Happy to answer questions about the methodology, but the findings should be judged on whether the scripts reproduce on your end.Attachment
pillow_uncaught_exception_poc.py-- self-contained, builds all three minimal test files in-memory (no external files needed), prints exactly which exception type escapes and confirms it's notUnidentifiedImageError.