Neutron, our AI engine, scored 97.1% on UC Berkeley's CyberGym benchmark. Learn more

Deep Dive

Deep Dive

Pillow FlashPix Heap Overflow RCE (CVE-2026-112398)

Decoding a crafted FlashPix image in Pillow 12.3.0 or older with olefile installed lets an attacker run commands; Pillow 13.0.0 fixes CVE-2026-112398.

TL;DR

CVE-2026-112398 is a Critical (CVSS v4.0 9.2) heap buffer overflow in Pillow's FlashPix JPEG decoder that can run attacker-chosen commands when an application decodes an untrusted image, in every release up to 12.3.0 with olefile installed.

  • Pillow's FlashPix plugin decodes JPEG tiles inside an OLE2 container, Microsoft's Compound File Binary Format. It sizes the decode buffer from the color mode claimed by the FlashPix metadata, then lets libjpeg emit rows based on the embedded JPEG's own component count. A file that claims 2 bytes per pixel while its embedded JPEG emits 4 bytes per pixel overflows the decode buffer on every row.
  • Any service that opens and decodes untrusted uploads is exposed if it runs Pillow up to 12.3.0 with the optional olefile package installed: the file is detected by its content magic, not its extension, and the overflow fires inside a plain Image.open().load() call with no user interaction.
  • The vendor assigns CWE-122 (heap-based buffer overflow) and CWE-787 (out-of-bounds write), and its advisory includes a proof of concept that runs a command on stock Pillow 12.2.0. CISA's Authorized Data Publisher (ADP) enrichment marks exploitation evidence as "poc". It is not in the KEV catalog and no attacks in the wild are known as of October 24, 2026.
  • Upgrade to Pillow 13.0.0, released October 16, 2026. If you cannot, uninstall olefile, which disables FlashPix and Microsoft Image Composer support, or reject OLE2 files before they reach Pillow.

Am I affected?

You are affected if all of these hold:

  • Your application, or anything it imports, opens untrusted files with Image.open() and then forces a decode with .load(), .thumbnail(), .tobytes() or any other call that reads pixel data.
  • The installed Pillow is 12.3.0 or older. Every earlier release is affected; the CVE record lists the range as prior to 13.0.0.
  • The optional olefile package is installed in that environment. FlashPix detection imports it, and Pillow's current packaging declares it as an optional extra for the fpx and mic features.

You are not affected if Pillow 13.0.0 or newer does the image processing, or if olefile is absent, in which case the FlashPix plugin is never loaded.

PyPI logged 141,847,434 pillow downloads in the last week (checked October 24, 2026). Pillow often arrives as a transitive dependency, so audit every environment, not only the projects that declare it directly.

Branch Affected versions Fixed version
13.x none 13.0.0 and later
12.x and all earlier <= 12.3.0 13.0.0

Check what your environments run, including container images and build environments:

pip show pillow | grep -E "^(Name|Version)"
python -c "import PIL; print(PIL.__version__)"
python -c "import olefile; print('olefile', olefile.__version__)" 2>/dev/null || echo "olefile absent"
# pip requirements files
grep -i "^pillow" requirements*.txt

# Pipenv and pyproject.toml
grep -i "pillow" Pipfile.lock pyproject.toml 2>/dev/null

# Poetry
grep -A2 "name = \"pillow\"" poetry.lock

# Container images
docker run --rm <image> python -c "import PIL; print(PIL.__version__)"

How do I fix it?

Upgrade to the patched release:

pip install --upgrade "pillow>=13.0.0"

Pillow 13.0.0 dropped support for Python 3.10, so environments pinned to 3.10 need a Python upgrade or the workaround below.

If you cannot upgrade yet, remove the enabler. FlashPix support needs olefile, so uninstalling it turns the vulnerable path off:

pip uninstall -y olefile

That removes FlashPix and Microsoft Image Composer (MIC) support, which the advisory names as the olefile-backed formats. The limit is plain: a service that legitimately processes FlashPix or MIC files loses that capability, and any other code in the environment that imports olefile breaks too.

A gateway can instead reject uploads that begin with the OLE2 magic D0CF11E0A1B11AE1, the same magic the plugin itself matches, which stops FlashPix at the door regardless of file extension.

What is the root cause?

The FlashPix plugin sizes its JPEG decode buffer from FlashPix metadata while libjpeg writes rows sized from the embedded JPEG header, and nothing checked the two against each other.

The FlashPix plugin maps color identifiers from the container to Pillow modes, in src/PIL/FpxImagePlugin.py at 12.2.0, lines 24 to 33:

MODES = {
    # opacity
    (0x00007FFE,): ("A", "L"),
    # monochrome
    (0x00010000,): ("L", "L"),
    (0x00018000, 0x00017FFE): ("RGBA", "LA"),
    ...
    (0x00030000, 0x00030001, 0x00030002): ("RGB", "RGB"),
}

The attacker picks which table row applies by writing those color identifiers into the file. The row ("RGBA", "LA") selects rawmode LA, 2 bytes per pixel. The decoder allocates the scanline buffer from that rawmode, in src/decode.c at 12.2.0, lines 223 to 226:

state->bytes = (state->bits * state->xsize + 7) / 8;   // "LA" gives 2 * xsize bytes per row
...
state->buffer = (UINT8 *)calloc(1, state->bytes);

Then the JPEG decoder maps the rawmode string to a libjpeg output colorspace, in src/libImaging/JpegDecode.c at 12.2.0. Recognized rawmodes such as CMYK, YCbCr and YCbCrK get a concrete colorspace. LA and YCC;P are not recognized, so control reaches the fallback at lines 219 to 221:

/* 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 num_components bytes per pixel, a value the attacker writes into the embedded JPEG's SOF marker. Set it to 4 against a buffer sized for 2, and every jpeg_read_scanlines call at line 252 writes a 4-component row into a 2-byte-per-pixel buffer:

ok = jpeg_read_scanlines(&context->cinfo, &state->buffer, 1);   // writes 4 * width into a 2 * width buffer

The overflow length is attacker-controlled through the image width. The overflowing bytes are the decoded pixels, which the attacker shapes through the JPEG stream rather than writing directly. The magic check that makes this reachable, at the top of the same plugin file:

def _accept(prefix: bytes) -> bool:
    return prefix.startswith(olefile.MAGIC)

That is the OLE2 magic, matched on content for every file Pillow is asked to open.

How is it exploited?

An attacker crafts a FlashPix file whose metadata claims 2 bytes per pixel but whose embedded JPEG declares 4 components. Decoding it overflows the heap buffer, and the advisory's PoC turns that into a type-pointer hijack that calls system().

The attack path, as demonstrated by the advisory's proof of concept:

  1. The attacker builds a standalone FlashPix file on their own machine, using the advisory's payload builder. Building it needs no access to the target.
  2. The file claims the ("RGBA", "LA") mode, so Pillow sizes the tile buffer at 2 bytes per pixel, and embeds a JPEG whose SOF declares 4 components, so libjpeg emits 4 bytes per pixel.
  3. The victim service receives the file through any upload or fetch path and forces the vulnerable decode. Image.open(path).load() is the PoC's entire operation:
#!/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()
  1. The OLE2 magic in the file header selects the FlashPix plugin, regardless of the file's name. The overflow runs on the first load().
  2. The overflow overwrites a bytes object adjacent to the decode buffer, planting a fake type object and vtable inside the same controlled heap memory. When Pillow releases the input buffer, PyBuffer_Release walks the hijacked type pointer and calls through it. The PoC points that call at system@plt with the command string in the first argument register, and system("id") executes as the user running the image processing.

The published PoC runs against a pinned Docker image with ASLR disabled so its baked-in addresses hold; id runs and then the interpreter aborts on the corrupted heap with corrupted size vs. prev_size, after the command has already executed. Against a normal server, the same overflow needs to deal with address randomization, so the proof of concept demonstrates the bug and the weaponization path rather than a one-shot universal exploit.

How does the FlashPix overflow trigger?

In one pass, the OLE2 magic selects the FlashPix plugin, the LA rawmode sizes the line buffer at 2 bytes per pixel, JCS_UNKNOWN makes libjpeg emit all 4 JPEG components, and the oversized row lands on the next heap object.

flowchart LR A["Crafted FPX file
uploaded to service"] --> B["Image.open() matches
OLE2 magic D0CF11E0"] B --> C["FlashPix color IDs select
rawmode LA, 2 bytes per pixel"] C --> D["Decode buffer allocated
at 2 * width bytes"] D --> E["rawmode LA unrecognized,
libjpeg set to JCS_UNKNOWN"] E --> F["Embedded JPEG declares 4
components, rows written
at 4 bytes per pixel"] F --> G["Heap out-of-bounds write
with attacker content"] G --> H["Adjacent object type
pointer overwritten"] H --> I["Release hook calls
attacker target, code executes"]

What can an attacker do?

Decoding the crafted file runs attacker-chosen commands as the user running the image processing, which means full compromise of that process and the data and privileges it holds. The advisory states the impact as arbitrary command execution in that process's context. The conditions:

  • Network reachability of an endpoint that opens attacker-influenced files and forces a decode (.load(), .thumbnail(), .tobytes()), with the optional olefile dependency installed. Upload and thumbnailing services, avatar and media processing, document and image conversion pipelines are the advisory's examples.
  • No user interaction and no extension trick: detection is by content, so a file named avatar.png with OLE2 bytes takes the FlashPix path.
  • The PoC's one-shot execution is demonstrated on a pinned, ASLR-disabled image, so a real target with address randomization takes extra exploitation work, while the underlying out-of-bounds write happens on every affected version.

Why does the fix work?

The fix adds a bounds check in ImagingJpegDecode before any scanline is read, rejecting streams whose output width or row size exceeds the decode buffer. The patch commit, merged as #10131, adds this check:

+            /* The scanline buffer was sized from the tile extents and the
+               unpacker, not from the JPEG header, so make sure the lines
+               libjpeg is about to produce actually fit in it. */
+            if (context->cinfo.output_width > (JDIMENSION)state->xsize ||
+                (size_t)context->cinfo.output_components * context->cinfo.output_width >
+                    (size_t)state->bytes) {
+                jpeg_destroy_decompress(&context->cinfo);
+                state->errcode = IMAGING_CODEC_BROKEN;
+                return -1;
+            }

The check rejects a JPEG whose width exceeds the tile or whose components exceed the buffer's per-pixel size, destroying the decompressor and reporting "broken data stream" instead of decoding. This closes the mismatch class where the buffer size and the JPEG header finally meet: not in the FlashPix table, which still maps LA modes, but in the decoder, which now refuses to write rows that do not fit.

It is a complete fix for oversized-scanline JPEG inputs; it does not remove the JCS_UNKNOWN path itself, which remains correct as long as rows fit the buffer.

How do I check if I were compromised?

No indicators of compromise, domains or attacker infrastructure are published for this issue. The overflow leaves three kinds of evidence to look for in your own environment:

  • Crash logs from image-processing workers with heap corruption messages, such as the corrupted size vs. prev_size abort the PoC leaves behind, or SIGSEGV exits from processes that open uploaded images.
  • Uploaded files whose first 8 bytes are the OLE2 magic D0 CF 11 E0 A1 B1 1A E1 but whose name or declared type is not FlashPix, which is the delivery signature the plugin's own magic check describes.
  • Processing jobs that opened an image and then executed unexpected commands or spawned unexpected processes, since execution happens in the worker's own context.

Timeline

  • 2026-10-06: Andrew Murray (radarhere) commits the scanline bounds check, merged as #10131.
  • 2026-10-16: Pillow releases 13.0.0 with the fix.
  • 2026-10-17: Pillow publishes advisory GHSA-5x8w-8jj5-q52h, rating it Critical, CVSS v4.0 9.2, with a full proof of concept, crediting Alex Bouffard (pebwalker).
  • 2026-10-19: The CVE record for CVE-2026-112398 is published by the GitHub CNA.
  • 2026-10-22: CISA's Authorized Data Publisher (ADP) enrichment block records exploitation evidence as "poc".
  • 2026-10-23: The GitHub Advisory Database publishes and reviews the advisory.
  • 2026-10-24: This post.

FAQ

Does my service need olefile installed to be affected?

Yes. The FlashPix plugin imports olefile, and Pillow's packaging declares it as an optional dependency for the fpx and mic extras. Without it, the plugin never loads and the OLE2 file is rejected before decoding. Environments that never installed it are not on the vulnerable path.

Does renaming or blocking .fpx uploads stop it?

No. Image.open() matches the FlashPix plugin by the OLE2 magic bytes at the start of the file, so the extension is irrelevant to the attack and to the defense. Rejecting files by the magic prefix D0CF11E0A1B11AE1 at your upload boundary does stop it, because that is the same signature the plugin requires.

Is the published PoC a full remote exploit?

It is a working demonstration of command execution, but it targets a pinned Docker image with ASLR disabled so the payload's baked-in addresses are fixed. On a server with address randomization, the same overflow needs additional work to place or leak addresses. The overflow itself, and the heap control it gives an attacker, behave identically on any affected version.

Are normal JPEG files dangerous too?

A normal JPEG does not take this path. The bug needs the FlashPix container to select the LA rawmode and the JCS_UNKNOWN fallback, which ordinary JPEG opens never do. The patch also now rejects any JPEG whose rows exceed the buffer, so the general class is closed at the decoder level.

What else did Pillow 13.0.0 fix?

The same release also fixed CVE-2026-112900, an argument injection into the Ghostscript call for EPS input that affects every Pillow since 2.6.0 and that the project rated Critical, CVSS v4.0 9.2. Upgrading to 13.0.0 covers both issues at once.

Review your image-processing services

Start a security assessment with Ostorlab, then use the dependency checks above to verify the Pillow version and olefile presence in each image-processing environment. Upgrade affected deployments to Pillow 13.0.0 or later.

References

  • Pillow advisory GHSA-5x8w-8jj5-q52h with the proof of concept and Docker lab, published October 17, 2026: https://github.com/python-pillow/Pillow/security/advisories/GHSA-5x8w-8jj5-q52h (checked October 24, 2026)
  • CVE-2026-112398 record with the CVSS v4.0 9.2 vector and the CISA ADP "poc" enrichment: https://cveawg.mitre.org/api/cve/CVE-2026-112398 (checked October 24, 2026)
  • GitHub Advisory Database entry: https://github.com/advisories/GHSA-5x8w-8jj5-q52h (checked October 24, 2026)
  • Fix commit 242a059a, "Reject JPEG streams whose scanlines are wider or have more components", dated October 6, 2026: https://github.com/python-pillow/Pillow/commit/242a059aedc1935097d9aa1dc3f099750467ba75 (checked October 24, 2026)
  • Pillow 13.0.0 release, published October 16, 2026: https://github.com/python-pillow/Pillow/releases/tag/13.0.0 (checked October 24, 2026)
  • Vulnerable FpxImagePlugin source at 12.2.0: https://raw.githubusercontent.com/python-pillow/Pillow/12.2.0/src/PIL/FpxImagePlugin.py (checked October 24, 2026)
  • Vulnerable JpegDecode.c source at 12.2.0: https://raw.githubusercontent.com/python-pillow/Pillow/12.2.0/src/libImaging/JpegDecode.c (checked October 24, 2026)
  • Pillow's pyproject.toml declaring olefile as the fpx and mic optional extra: https://raw.githubusercontent.com/python-pillow/Pillow/12.2.0/pyproject.toml (checked October 24, 2026)
  • PyPI download statistics for pillow, 141,847,434 downloads in the last week: https://pypistats.org/packages/pillow (checked October 24, 2026)
  • Companion advisory for the Ghostscript argument injection fixed in the same release: https://github.com/python-pillow/Pillow/security/advisories/GHSA-w889-685w-c8fh (checked October 24, 2026)