# Pillow: Heap buffer overflow in FlashPix (FPX) JPEG decoding leads to code execution

Pillow's FPX plugin maps attacker FPX color IDs to rawmode 'LA', but JCS_UNKNOWN libjpeg emits 4 bytes/px into a 2-byte/px heap buffer, enabling RCE via Image.open().

- Payout: Research
- Severity: critical
- Program: Pillow
- Found by: pebwalker
- Disclosed: 2026-10-23
- CVE: CVE-2026-112398
- Write-up quality: 9/10
- Original: https://github.com/advisories/GHSA-5x8w-8jj5-q52h
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-5x8w-8jj5-q52h
- Tags: area:oss domain:oss impact:rce kind:research platform:ghsa sev:critical tech:python technique:chained technique:source-review vuln:memory-corruption vuln:rce

**Target:** Pillow FpxImagePlugin / libImaging JpegDecode.c
**Root cause:** FPX color IDs select rawmode 'LA' (2 bytes/px) sizing the decode buffer, but JpegDecode.c falls back to JCS_UNKNOWN for unrecognized modes, so libjpeg emits num_components (4) bytes/px from the SOF marker, overflowing the buffer.
**Steps:**
1. Craft an OLE2/CFBF .fpx with an Image Contents stream setting color IDs 0x00018000/0x00017FFE (rawmode 'LA') and compression 2 (JPEG).
2. Embed a 4-component (CMYK) JPEG subimage with width up to 65000 so SOF num_components=4.
3. Victim calls `Image.open(path).load()`; plugin builds 'jpeg' tile with rawmode 'LA', buffer sized 2*width.
4. JpegDecode.c hits fallback `context->cinfo.out_color_space  = JCS_UNKNOWN;`, `jpeg_read_scanlines(&context->cinfo, &state->buffer, 1)` writes 4*width bytes into 2*width buffer.
5. Overflow overwrites adjacent Python bytes object's ob_type with pointer to fake PyTypeObject/PyBufferProcs planted in the decode buffer.
6. On PyBuffer_Release, `call *rax` via bf_releasebuffer hits system@plt (0x4206c0, non-PIE) with rdi = command string.
**How found:** Code review of FpxImagePlugin MODES table and JpegDecode.c: noticed 'LA'/'YCC;P' rawmodes are unrecognized by the colorspace mapping, falling to JCS_UNKNOWN where libjpeg emits SOF num_components bytes/px, mismatching the rawmode-sized buffer.
**Exploitation:** Attacker builds a standalone .fpx offline (no target access). Opening it triggers the overflow during normal load; the overflow plants a fake type/vtable and overwrites an adjacent bytes object's ob_type, so PyBuffer_Release's indirect call executes system() with an inline command (<=7 bytes), yielding command execution as the processing user (root in PoC).
**Escalation:** Heap overflow turned into control-flow hijack of a Python object vtable, redirecting an indirect call to system@plt for arbitrary command execution.
**Insight:** When a decoder sizes its output buffer from one mode table but libjpeg's JCS_UNKNOWN path emits SOF num_components bytes/px, any unrecognized rawmode string is a length mismatch; check fallback branches where color conversion is disabled.

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

**Vulnerable one-liner opening crafted file**

```
#!/usr/bin/env python3
# The entire vulnerable operation an application performs on an untrusted file.
import sys
from PIL import Image

Image.open(sys.argv[1]).load()
```

**Command to build and run exploit**

```
$ python3 make_payload.py --target 0x4206c0 --cmd id --out shell.fpx
[control] shell.fpx: S=0xdc8800 target=0x4206c0 cmd='id' craft_iters=0 bad=0
[control] opening it: vtable hijack -> call *0x4206c0 with rdi -> 'id'

Then open it with `vulnerable.py`:

$ python3 vulnerable.py shell.fpx
uid=0(root) gid=0(root) groups=0(root)
corrupted size vs. prev_size
```

