Pillow: Heap buffer overflow in FlashPix (FPX) JPEG decoding leads to code execution
CVE-2026-112398 · GHSA-5x8w-8jj5-q52h · published 23 Oct 2026
How serious is it
- Severity: critical, CVSS score 9.2 out of 10.
- No exploit prediction (EPSS) score yet.
- Not on the CISA list of exploited vulnerabilities.
- No public exploit in Exploit-DB.
Is your app affected by CVE-2026-112398?
Paste this prompt into Claude Code, Cursor or any AI coding tool: it runs a free Shippable scan of your project and fixes what it finds. Built with Lovable, Bolt or v0? Scan your live app. Or upload your package file.
Who is affected
pillow(PyPI) <= 12.3.0
How to check
- Run
pip show pillowin your project to see the installed version, including copies pulled in by other packages. - Or paste your lockfile into the checker: it lists every package with a known vulnerability, this one included.
- Or run
npx shippable-dev scan localhost:3000in your project folder. It reads your lockfile and flags this vulnerability along with every other known one.
How to fix
Copies a prompt for Claude Code, Cursor or Codex: it checks your lockfiles for this and upgrades it.
- Upgrade
pillowto 13.0.0 or later, commit the lockfile and redeploy. - If a vulnerable version was live, check your logs and rotate the secrets that server could read.
The advisory
### Credits
**Researcher**: Alex Bouffard (mixbo@pebwalker.com)
### Summary
A heap buffer overflow in Pillow's FlashPix (FPX) JPEG-tile decoder lets an attacker achieve arbitrary code execution by getting an application to open a single crafted file with `Image.open()`. The file is recognized by content (the OLE2 magic), not by extension, so any service that opens or thumbnails untrusted uploads with the optional `olefile` dependency installed is reachable with no user interaction. In the PoC below, opening one crafted file executes an attacker-chosen command on stock Pillow 12.2.0 (x86_64 Ubuntu 24.04, glibc 2.39).
### Details
`FpxImagePlugin` decodes FlashPix (FPX) files, which are OLE2/CFBF compound documents.
`Image.open()` auto-detects them from the OLE2 magic `D0CF11E0...` whenever the optional `olefile` package is installed (Pillow's documented dependency for FPX/MIC support), regardless of the file's extension.
For a JPEG-compressed subimage (`compression == 2`), the plugin builds a `"jpeg"` decode tile and derives the `(rawmode, jpegmode)` pair from attacker-controlled FPX color IDs through the `MODES` table ([`src/PIL/FpxImagePlugin.py` L25-L32](https://github.com/python-pillow/Pillow/blob/12.2.0/src/PIL/FpxImagePlugin.py#L25-L32)):
```python
MODES = {
...
(0x00018000, 0x00017FFE): ("RGBA", "LA"), # rawmode "LA" (2 bytes/pixel)
...
(0x00020000, 0x00020001, 0x00020002): ("RGB", "YCC;P"), # rawmode "YCC;P"
}
```
The Imaging output buffer for the tile is sized for the rawmode (here `"LA"` = 2 bytes/pixel) ([`src/decode.c` L223-L226](https://github.com/python-pillow/Pillow/blob/12.2.0/src/decode.c#L223-L226)):
```c
state->bytes = (state->bits * state->xsize + 7) / 8; // "LA" gives 2 * xsize bytes/row
...
state->buffer = (UINT8 *)calloc(1, state->bytes);
```
In `JpegDecode.c` the rawmode string is mapped to a libjpeg `out_color_space`. Recognized modes (`CMYK`, `YCbCr`, `YCbCrK`, and so on) get a concrete colorspace, but `"LA"` and `"YCC;P"` are not recognized, so control reaches the fallback branch ([`src/libImaging/JpegDecode.c` L219-L221](https://github.com/python-pillow/Pillow/blob/12.2.0/src/libImaging/JpegDecode.c#L219-L221)):
```c
/* Disable decoder conversions. */
context->cinfo.jpeg_color_space = JCS_UNKNOWN;
context->cinfo.out_color_space = JCS_UNKNOWN;
```
Under `JCS_UNKNOWN`, libjpeg performs no color conversion and emits `cinfo.num_components` bytes per pixel, taken from the embedded JPEG's SOF marker, which the attacker sets to 4. So `jpeg_read_scanlines` writes `4 * width` bytes per row into a buffer allocated for only `2 * width` bytes ([`src/libImaging/JpegDecode.c` L252](https://github.com/python-pillow/Pillow/blob/12.2.0/src/libImaging/JpegDecode.c#L252)):
```c
ok = jpeg_read_scanlines(&context->cinfo, &state->buffer, 1); // writes 4*W into a 2*W buffer
```
This is a heap out-of-bounds write with attacker-controlled length (the image width, up to tens of thousands of pixels, so about `2 * width` bytes of overflow per row) and attacker-controlled content (the decoded pixel data). The PoC weaponizes it: the overflow overwrites an adjacent Python `bytes` object's `ob_type` with a pointer to a fake `PyTypeObject` / `PyBufferProcs` vtable that the same overflow plants inside the decode buffer. When Pillow releases the input buffer, `PyBuffer_Release` walks `ob_type -> tp_as_buffer -> bf_releasebuffer` and executes `call *rax`; the PoC points that at `system@plt` (fixed, the interpreter binary is non-PIE) with `rdi` set to the command string, yielding command execution.
### PoC
To facilitate an exact reproduction on an x86_64 machine, this PoC uses the version-pinned, ASLR-disabled Docker image from [Annex A](#annex-a-docker-setup).
The source code for the payload builder `make_payload.py` is in [Annex B](#annex-b-payload-builder-source).
`vulnerable.py` is the program under test, a one-liner that opens the file it is given:
```python
#!/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()
```
The payload is a standalone `.fpx` file the attacker crafts on their own host; the target only ever opens the resulting file, so building it requires no access to or code execution on the target. We run `make_payload.py` inside the same container here purely for convenience, so its baked-in addresses (`--offset`, `system@plt`) match this pinned image. The payload redirects the hijacked indirect call to `system@plt` (`0x4206c0`, fixed because the interpreter binary is non-PIE) with a baked-in command:
```console
$ 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`:
```console
$ python3 vulnerable.py shell.fpx
uid=0(root) gid=0(root) groups=0(root)
corrupted size vs. prev_size
```
`Image.open()` of the crafted file runs the baked command via `system("id")`: command execution as the user running the image processing (root in the container).
The trailing `corrupted size vs. prev_size` is glibc aborting during interpreter teardown over the corrupted heap, after `id` has
already run.
The builder writes the command inline at the hijacked `rdi`, so `--cmd` is limited to 7 bytes (for example `id`, `id;w`). `--offset` and `system@plt` are constants of the pinned, ASLR-disabled image and hold across runs of it.
### Impact
This is a heap-based buffer overflow (CWE-122) / out-of-bounds write (CWE-787) that leads to code execution. Any application or service that opens attacker-influenced files with Pillow and has the optional `olefile` dependency installed is affected: image upload and thumbnailing services, avatar and media processing, document and image conversion pipelines, and similar. Because detection is by OLE2 m…
Check your whole app
Dependencies are only half of it. Free and read-only: before you push, run npx shippable-dev scan localhost:3000 in your project folder, no account needed. For your live app, you prove it's yours with a one-line tag first.
References
- github.com/python-pillow/Pillow/security/advisories/GHSA-5x8w-8jj5-q52h
- nvd.nist.gov/vuln/detail/CVE-2026-112398
- github.com/python-pillow/Pillow/commit/242a059aedc1935097d9aa1dc3f099750467ba75
- github.com/python-pillow/Pillow/releases/tag/13.0.0
- github.com/advisories/GHSA-5x8w-8jj5-q52h
- github.com/python-pillow/Pillow
Merged from GitHub Advisory Database, OSV. Last updated 26 Oct 2026. All vulnerabilities · RSS · JSON