# Pillow: Heap buffer overflow in ImagingJpegDecode

Pillow's draft() shrinks the JPEG decode buffer for scaled output, but libjpeg-turbo ignores scaling on lossless SOF3 streams, overflowing the heap with attacker-chosen pixel bytes.

- Payout: Research
- Severity: critical
- Program: Pillow
- Found by: DavidKorczynski
- Disclosed: 2026-10-20
- CVE: CVE-2026-112403
- Write-up quality: 9/10
- Original: https://github.com/advisories/GHSA-8g5g-8vmr-9782
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-8g5g-8vmr-9782
- Tags: area:oss domain:oss impact:dos impact:rce kind:research platform:ghsa sev:critical tech:python technique:parser-differential technique:source-review vuln:dos vuln:memory-corruption

**Target:** Pillow JpegImageFile.draft() / ImagingJpegDecode with libjpeg-turbo 3.x
**Root cause:** draft() computes a scaled row buffer size and sets cinfo.scale_num/scale_denom, but ImagingJpegDecode never verifies cinfo.output_width/height/components against state->xsize/ysize/bits. libjpeg-turbo 3.x silently discards scaling for lossless (SOF3) streams, returning full-width rows into a buffer sized for ceil(W/s).
**Steps:**
1. Build Pillow at e41083f383c9cd3db95de52564cc0b6452313d4a against libjpeg-turbo 3.1.4 with ASan.
2. Generate a standards-compliant 8-bit grayscale SOF3 lossless JPEG (1200x1100, predictor 1) whose rows carry an attacker-chosen marker pattern.
3. `im = Image.open("/tmp/sof3_gray_1200x1100.jpg")` — opens as JPEG mode L size (1200,1100).
4. `im.thumbnail((256, 256))` — implicit draft() picks s=2, allocating a 600-byte row buffer.
5. jpeg_read_scanlines writes a full 1200-byte row past the 600-byte calloc block; ASan reports heap-buffer-overflow WRITE of size 1200.
**How found:** Noticed that draft() shrinks _size and tile extents and sets cinfo.scale_num/scale_denom without any geometry check in ImagingJpegDecode, then observed that libjpeg-turbo 3.x hardwires lossless SOF3 streams to no scaling — so the requested scale_denom is silently discarded while the buffer was already sized for it. Pillow's Python SOF parser already accepts 0xFFC3 at 8-bit precision, so the file opens normally.
**Exploitation:** Attacker supplies a crafted lossless SOF3 JPEG to any application that calls Image.thumbnail() (or draft()) before load(). draft() selects s>=2 when W,H >= 1024 for a 256x256 thumbnail, shrinking the decode buffer to ceil(W/s) samples; libjpeg-turbo 3.x ignores the requested scale_denom for lossless streams and writes full-width rows, overflowing the heap with byte-exact attacker-chosen pixel data. Buffer size class is attacker-selected via W; overflow repeats for ceil(H/s) rows over the same region. Decode then errors out, but the writes already occurred.
**Insight:** When a decoder wrapper pre-sizes a buffer from a requested scale factor, check whether the underlying library actually honors that factor for every codec it accepts — lossless/arithmetic JPEG modes in libjpeg-turbo silently ignore scaling, so any draft()/thumbnail() path is a candidate for a size-mismatch overflow.

## Proof of concept (copied exactly from the original report)

**Trigger calling thumbnail on crafted JPEG**

```
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)
```

**Docker reproducer build and run command**

```
docker build -t pillow-sof3-oob . && docker run --rm pillow-sof3-oob
```

