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:
- The image is a SOF3 (lossless) JPEG at 8-bit precision
- The application uses Pillow < 13.0.0 (tested on 12.3.0, the last vulnerable release)
- Pillow is linked against libjpeg-turbo 3.0+ (the official PyPI wheels bundle 3.1.x)
draft()picks a scale factor >= 2, which happens when the image is >= 1024px andthumbnail()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 | tjhandle handle = tj3Init(TJINIT_COMPRESS); |
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 |
|
Gunicorn with 2 workers, Pillow 12.3.0, stock libjpeg-turbo from the PyPI wheel.
The crash
One upload:
1 | $ python3 poc.py |
Twenty uploads:
1 | $ python3 verify.py --rounds 20 |
The gunicorn error log:
1 | [2026-10-23 09:45:33 +0000] [1] [WARNING] Worker (pid:106) 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 = 150bytes - libjpeg-turbo outputs:
1200 * 1 = 1200bytes per scanline - Overflow per scanline: 1050 bytes
- Total overflow:
1050 * 1100 = 1,155,000bytes 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 | git clone https://github.com/al4n4n/CVE-2026-112403.git |
References
- GHSA-8g5g-8vmr-9782 (GitHub Security Advisory)
- CVE-2026-112403
- Fix commit 242a059
- Pillow 13.0.0 release notes
- oss-security announcement
- Debian security tracker