Live › vulnerability › CVE-2026-112398
Go to dashboardPillow's FlashPix (FPX) JPEG decoder writes more bytes per row than its output buffer holds, a heap overflow. An attacker who gets a server to open one crafted file with Image.open() can run code in that process when the optional olefile package is installed.
Summary written from the sources below. Check the originals before acting.
First seen by InfraStack 2026-10-23 21:56 UTC, 0h before NVD. All times on this page are UTC.
| Product | Ecosystem | Affected | Fixed in |
|---|
| pillow | pypi | <= 12.3.0 | 13.0.0 |
pip show pillow (version 12.3.0 or earlier is affected) and pip show olefile (installed means FPX detection is active)
If the app opens untrusted uploads with Pillow, treat it as exposed
Upgrade Pillow to 13.0.0 or later
If an upgrade is not possible, uninstall olefile so FPX files are not auto-detected (the advisory's dependency condition), then verify with a test FPX file
Each row is the first time that source carried this signal. The analyst’s rows are its own findings.
Researcher: Alex Bouffard ([email protected])
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).
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):
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):
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):
/* 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):
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.
To facilitate an exact reproduction on an x86_64 machine, this PoC uses the version-pinned, ASLR-disabled Docker image from Annex A.
The source code for the payload builder make_payload.py is in Annex B.
vulnerable.py is the program under test, a one-liner that opens the file it is given:
#!/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:
$ 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
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.
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 magic rather than file extension and the overflow is triggered during a normal Image.open() / load, a server that automatically processes an uploaded file is exploitable with no user interaction. The result is arbitrary command execution in the context of the process performing the image processing, leading to full compromise of that process and the data and privileges it holds.
The PoC runs in a pinned Docker image so the environment is deterministic; the image installs a version-pinned Python (3.12.3) and the Pillow 12.2.0 wheel, and the builder is pure Pillow. The image's entrypoint disables ASLR (setarch -R) to reduce the system knowledge requirements. The single --security-opt seccomp=unconfined flag on docker run is required only because turning ASLR off uses the personality() syscall that Docker's default seccomp profile blocks. Run only on infrastructure you own.
1. Build the image. Put vulnerable.py (PoC) and make_payload.py
(Annex B) in a directory with this Dockerfile:
FROM ubuntu:24.04@sha256:786a8b558f7be160c6c8c4a54f9a57274f3b4fb1491cf65146521ae77ff1dc54
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
python3.12=3.12.3-1ubuntu0.13 python3.12-venv=3.12.3-1ubuntu0.13 \
&& rm -rf /var/lib/apt/lists/*
RUN python3.12 -m venv /opt/v \
&& /opt/v/bin/pip install --no-cache-dir "Pillow==12.2.0" "olefile==0.47"
ENV PATH=/opt/v/bin:$PATH
WORKDIR /poc
# ASLR is an OS mitigation unrelated to the bug; disable it in-image for a deterministic heap.
ENTRYPOINT ["setarch", "-R"]
CMD ["bash"]
$ docker build -t fjpeg1-poc .
...
Downloading pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl (7.1 MB)
Successfully installed Pillow-12.2.0 olefile-0.47
Successfully built 370344c832b6
Successfully tagged fjpeg1-poc:latest
2. Start the (ASLR-disabled) container shell with t
This page as .md · .json · in your agent: get_signal("CVE-2026-112398")
GitHub Advisory Database, CC-BY 4.0 · NVD data is provided by NIST and is public domain. Posts quoted belong to their authors.