Skip to content

open() and seek() let ValueError-IndexError-struct.error escape uncaught, bypassing the documented UnidentifiedImageError contract #9814

Description

@pontusj101

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions