Pillow Talk
Open the wrong picture and Pillow opens a shell.
Introduction
I spent the last DEF CON Qualifier CTF trying to solve stork, a challenge built around a custom Lua VM. The organizers had written their own scripting engine, and the goal was to break out of it through a memory bug.
I did not solve it before the competition ended, probably because I didn't have much practice finding and exploiting memory corruption bugs through a scripting language. Fuzzing a C parser directly is familiar territory. Reaching a memory bug from inside a managed runtime and turning it into arbitrary execution is not something I had done often.
The obvious place to build that skill is browser exploitation, but the v8 write-ups have always felt out of reach to me. So I picked a smaller version of the same problem: Python libraries that wrap C parsers.
I audited a handful of the popular ones and found memory corruption bugs in most. This article is about those I found in Pillow.
Pillow is the de facto imaging library for Python. If a Python program opens, resizes, or converts an image, chances are Pillow is the library doing it: thumbnail pipelines, avatar uploads, document converters, ML data loaders. Under the Python API sits a large amount of C, some of it Pillow's own, most of it the usual decoders for JPEG, PNG, TIFF, and the rest.
A Python memory-corruption primer
A heap overflow in a C extension is a different animal from one in a standalone C program. The interpreter's objects share the same heap as the buffers native code allocates, so an overflow will usually target a live Python object.
Every CPython object begins with the same header: a reference count and a pointer to the object's type.
To illustrate that, bytes is about the simplest object to look at. On a 64-bit build, this line:
x = b"AAAAAAAA"
puts a PyBytesObject on the heap: the shared header, then a length, a cached hash, and the eight bytes laid out inline right after.
The first two fields, ob_refcnt at offset 0 and ob_type at offset 8, are the header every object start with; everything after them is particular to bytes.
ob_typeis the most important field. It points to aPyTypeObject, which is essentially a table of function pointers: one slot per operation the interpreter might perform on the object. Deallocating it, calling it, hashing it, fetching an attribute, releasing a buffer it lent out, each is a slot. Almost everything done to an object is an indirect call through one of these slots, reached by followingob_type.
That is what makes this class of bug exploitable. To act on an object, the interpreter reads its ob_type, picks a slot, and calls that function pointer with the object itself as the first argument:
The pointers are trusted by the interpreter, and usually easy to reach from an overflow. Overwriting ob_type lets you point to an arbitrary type. So I aim it at memory I already control, the overflowing buffer itself, and shape those bytes into a counterfeit PyTypeObject. The next time the interpreter touches the object it reads my fake type, pulls a slot from it, and calls the address I placed there:
Two values have to be real addresses for this to fire, each with its own requirement:
- The buffer's address. The overwritten
ob_typehas to hold the real address of my bytes on the heap, so we need to know where is the buffer we are overflowing. This usually requires a leak. Without it I can corruptob_typebut I cannot aim it. - What to call. We need to know the address we want to call when we take control of the execution. Most of the time, this means finding the address of
system. Against a binary built without PIE,systemis reachable through a fixed PLT entry baked into the executable, so this one costs nothing. Against a position-independent build it is a second leak, of the text or libc base. But it's 2026, most python binaries deployed in a production environment must be PIE-enabled right? Right? ...Actually no. All the prebuilt docker images I analyzed during this research were not shipping with a position independant build of python.
The object pointer is the slot's first argument, rdi on x86-64, and that pointer is usually the start of the buffer (lucky!), so whatever is placed at the front is the argument. A command string there, like id\0, turns the hijacked call into system("id\0").
The bugs ahead loosely follow this map, reaching an adjacent object's ob_type, redirecting it into attacker-controlled bytes shaped like a type, and landing a call on a chosen address with a chosen argument. What differs between them is how each one reaches the overflow.
Start to finish, that is the whole move in one place: the buffer fills with a fake type, the write runs off its end into ob_type, and the pointer that used to find the real type swings to the fake one.
How Image.open() picks a decoder
Most people picture Image.open(file) as "decode the PNG, or the JPEG, or whatever it is". The real behavior is more permissive. Pillow does not trust the extension or the caller. It reads the first bytes of the file and offers them to every registered format plugin, in order, until one claims it.
A plugin claims a file through a small _accept callback. Dozens are registered by default, including formats you would expect and several you would not. What matters for an attacker is that the reachable set of decoders is the set Pillow ships, not the set the application thinks it enabled. A file uploaded as avatar.png is decoded by whichever plugin recognizes its actual content.
One of those plugins is FpxImagePlugin, the FlashPix decoder.
FlashPix, briefly
A FlashPix file is not an image file in the way a JPEG is. It is an OLE2 compound document: the container Microsoft used for legacy .doc and .xls files, a small filesystem packed into one file as a directory tree of named streams. Every OLE2 file starts with the same eight magic bytes, D0 CF 11 E0 A1 B1 1A E1, and the FlashPix plugin claims anything that begins with them:
# src/PIL/FpxImagePlugin.py
def _accept(prefix):
return prefix[:8] == olefile.MAGIC
Two details decide reachability. First, the match is on the magic bytes, so any upload starting with the OLE2 magic is routed into the FlashPix code regardless of its name. Second, the plugin needs the optional olefile package to read the container; without it, FlashPix is disabled. That sounds like a real barrier, but plenty of the imaging and document-processing ecosystem already pulls olefile in for other reasons. Where it is installed, the path is reachable.
Inside the document, a FlashPix file describes one or more subimages, and a subimage can store its pixels as JPEG. The JPEG path is where the bug is.
The bug: a buffer sized for the wrong mode
When the FlashPix plugin meets a JPEG-compressed subimage, it builds a decode tile and tells Pillow's JPEG decoder two things: the rawmode, which is the pixel layout it expects back, and the jpegmode, which is what it thinks is in the stream. Both come from a lookup table keyed by FlashPix color identifiers, and those identifiers come straight out of the file:
# src/PIL/FpxImagePlugin.py
MODES = {
...
(0x00018000, 0x00017FFE): ("RGBA", "LA"), # rawmode "LA" (2 bytes/pixel)
...
(0x00020000, 0x00020001, 0x00020002): ("RGB", "YCC;P"), # rawmode "YCC;P"
}
Choose the color identifier that maps to "LA" and the decoder is told to expect two bytes per pixel. Pillow sizes the output buffer to match, one row wide, from the bit depth the rawmode implies:
// src/decode.c
state->bytes = (state->bits * state->xsize + 7) / 8; // "LA" is 16 bits -> 2 * xsize bytes/row
state->buffer = (UINT8 *)calloc(1, state->bytes);
For an image of width W in "LA" mode, that row buffer is 2 * W bytes.
Then, the JPEG decoder translates that rawmode string into a libjpeg output color space. It walks the modes it understands, RGB, CMYK, YCbCr, and a few more, and falls through to an else for anything it does not. "LA" is not on the list:
// src/libImaging/JpegDecode.c
if (context->rawmode == IMAGING_RAWMODE_L) {
context->cinfo.out_color_space = JCS_GRAYSCALE;
} else if (context->rawmode == IMAGING_RAWMODE_RGB) {
context->cinfo.out_color_space = JCS_RGB;
}
...
} else {
/* Disable decoder conversions. */
context->cinfo.jpeg_color_space = JCS_UNKNOWN;
context->cinfo.out_color_space = JCS_UNKNOWN;
}
JCS_UNKNOWN tells libjpeg to skip color conversion and emit the raw components as the JPEG stores them. The bytes per pixel then come straight from the embedded JPEG's frame header, which the attacker writes. It declares four components.
So the second layer fills the buffer at four bytes per pixel:
// src/libImaging/JpegDecode.c
ok = jpeg_read_scanlines(&context->cinfo, &state->buffer, 1); // writes 4*W into a 2*W buffer
jpeg_read_scanlines writes 4 * W bytes into a buffer sized for 2 * W. The result is a heap overflow with attacker-controlled length (W is a field in the file) and attacker-controlled content (the pixels of a JPEG the attacker wrote).
A heap buffer overflow is a write that runs past the end of a heap allocation into whatever the allocator placed after it.
Building a file that triggers it
A bug you can only trigger by calling Pillow's internal decoder by hand is not worth much. It has to fire from one ordinary Image.open(), which means hand-building a valid FlashPix file that drags Pillow all the way to that scanline write.
I had to forge the container properly: a valid header, a sector allocation table, and a directory tree with the entries the plugin looks for. The pieces that matter for the bug are three streams:
- An Image Contents property set that declares one subimage, gives it the width
WI want, and tags it with the FlashPix color identifier that maps to"LA". - A subimage header that marks the subimage as JPEG-compressed, so the plugin takes the JPEG path.
- A data stream holding the crafted JPEG itself, a four-component frame of width
W.
The color identifier is a few bytes in the property blob:
# the color-spec blob in the Image Contents property set
blob = o32(1) + o32(2) + o32(0x00018000) + o32(0x00017FFE) # -> MODES key -> rawmode "LA"
With the container built correctly, opening the file is enough. Pillow recognizes the OLE2 magic, hands it to FpxImagePlugin, the plugin reads the property set, picks "LA", builds a two-byte-per-pixel buffer, and feeds the four-component JPEG to libjpeg. Everything after this point is deciding what to write into the overflow.
Confirming the overflow
Before building anything elaborate, it is worth running a few heuristics to make sure the primitive is exploitable. The cheapest possible test floods the overflow with a single repeated byte and opens the file.
There is one small wrinkle to account for. Pillow's CMYK JPEG path stores its data inverted, so a source byte of 0xBD comes back as 0x42. Flood the source with 0xBD, and every byte that lands past the buffer is 0x42. Among the things sitting just past the buffer is a live Python object, and 0x42 bytes turn its CPython type pointer into 0x4242424242424242. The next thing Pillow does after decoding the tile dereferences that pointer:
$ python3 vulnerable.py crash.fpx
Segmentation fault# kernel log
segfault at 4242424242424242 ...
That settles it, the pointer being dereferenced is mine. Now, let's turn 0x4242424242424242 into something useful.
From overflow to a controlled call
The object sitting past the buffer is the bytes object holding the file's data, the buffer Pillow is decoding. On a deterministic heap it lands at a fixed distance from the decode buffer, and its CPython type pointer, ob_type, sits at offset 130024 for the width I picked.
The overflow reaches a useful operation quickly because of how CPython releases a borrowed buffer. When Pillow is done with the file data it calls PyBuffer_Release, which reaches through the object's type to find the teardown callback:
// CPython, simplified
void PyBuffer_Release(Py_buffer *view) {
PyObject *obj = view->obj;
PyBufferProcs *pb = Py_TYPE(obj)->tp_as_buffer; // [ob_type + 0xa0]
if (pb != NULL && pb->bf_releasebuffer != NULL) // [pb + 8]
pb->bf_releasebuffer(obj, view); // indirect call
...
}
Read as a chain of dereferences off the pointer I overwrote, that is:
rax = obj->ob_type ; the pointer we control
rax = [rax + 0xa0] ; tp_as_buffer
rax = [rax + 8] ; bf_releasebuffer
call rax ; with rdi = obj, rsi = view
This is a clean type-confusion primitive. I already control a large region of memory, the decode buffer, which is full of my decoded pixels.
Now bear with me: I need to lay the buffer out as a fake PyTypeObject whose tp_as_buffer points at a fake PyBufferProcs whose bf_releasebuffer is the address I want to call! Then, pointing ob_type at the fake type will make PyBuffer_Release calls that address with the object in rdi.
So the layout is three planted pointers inside the decoded image:
obj->ob_type = &fake_type (the overwrite, at offset 130024)
[fake_type + 0xa0] = &fake_bufprocs (tp_as_buffer)
[fake_bufprocs + 8] = TARGET (bf_releasebuffer, the call destination)
To stay reliable across small shifts in heap layout, I do not bet on one exact offset. I spray the fake-type pointer across a wide stretch of the buffer and the target across another, so wherever ob_type lands, the +0xa0 read still finds a fake type and the +8 read still finds the target. That trades a precise offset for a wide window, a few kilobytes of slack.
Writing exact bytes through JPEG
There is a catch in "lay the region out as a fake type". The region is the output of a JPEG decode, and JPEG is lossy. I cannot just place the eight pointer bytes I want at an offset; I have to find JPEG input that decodes to them.
At quality 100 with subsampling disabled, the round trip is effectively lossless for the values I need, so a direct attempt usually lands. To be exact about it, I run a short feedback loop: build a JPEG from a guess, decode it, compare each target offset to what I wanted, nudge the source by the error, and repeat until it matches.
The loop needs an oracle: a way to predict what the vulnerable JCS_UNKNOWN path will decode to.
The vulnerable path emits the raw components, call them R. Pillow's own CMYK decode of the same file returns 255 - R, the inversion from earlier. So 255 minus whatever Pillow hands back is exactly what the buggy path will write:
INV = bytes(255 - i for i in range(256))
def decode_row(jpg):
# Pillow's CMYK decode returns 255 - R; the vulnerable JCS_UNKNOWN path writes R.
# Inverting Pillow's own output predicts the bug, with no external libjpeg.
px = Image.open(io.BytesIO(jpg)).tobytes()
return px[(H - 1) * W * 4:].translate(INV)
The entire payload builder ends up being a few hundred lines of pure Pillow, and the feedback loop converges on the first pass because the bytes I need round-trip exactly at quality 100.
Finding system without a leak
A type confusion that gives me a controlled call still needs a useful place to point it. The natural target is system, and the usual obstacle is finding it: on a position-independent binary you would need a libc leak, and a good chunk of any exploit is the plumbing to get one.
This target spares me that. The system Python interpreter on Ubuntu is a non-PIE executable. It loads at the fixed address 0x400000, and because libpython is statically linked into the interpreter, the interpreter's own PLT entry for system is at a fixed address too, 0x4206c0, no matter how the shared libraries are scattered. Point the fake vtable's bf_releasebuffer there and there is nothing to leak; the address was a constant the whole time.
The PLT (Procedure Linkage Table) is the small trampoline a binary calls to reach an external function like
system. In a non-PIE executable the table sits at a fixed virtual address, so the call target does not move between runs.
At the call, rdi already holds obj, the address of the victim object. system reads its argument as a C string starting there, and the first bytes of that object are inside the region I am overflowing. Write the command into them and PyBuffer_Release becomes system(command):
offset 130016: "id\0..." <- obj+0, becomes rdi, the string system() runs
offset 130024: &fake_type <- ob_type, drives the vtable walk to 0x4206c0
The inline argument is short, bounded by where ob_type sits a few bytes later, so the command is capped at seven bytes. id is two of them, and uid=0(root) is all the proof I need.
Defeating ASLR with a second bug
The pointers I plant are addresses into the decode buffer, and ASLR moves that buffer between runs. To place them, I need the heap base, which means a leak.
The same audit gave me one: an out-of-bounds read in Pillow's map.c, reachable through a McIdas AREA file opened with Image.open, or directly through Image.frombuffer. When Pillow maps a buffer as an image, it lays out the row pointers from an attacker-controlled stride and offset:
// src/map.c, PyImaging_MapBuffer
for (y = 0; y < ysize; y++) {
im->image[y] = (char *)view.buf + offset + y * stride;
}
xsize, ysize, stride, and offset all come from the file. The bounds check did not account for the full per-row width: each row is read at xsize * pixelsize, not at stride. Set stride smaller than a row and the last rows run out to offset + (ysize - 1) * stride + xsize * pixelsize, past the end of the mapping. Pillow reads that adjacent heap memory and hands it back as ordinary pixel data.
So I open a crafted McIdas file, read a few hundred bytes of live heap out of the returned image, and pick out a pointer I recognize. Masking off the low bits recovers the randomized heap base. That base is the only unknown in the FlashPix chain: feed it into the builder and the planted ob_type and vtable pointers land where they should, with nothing else changed. I ran the two bugs together against a stock, fully randomized box and got a shell.
They compose because a heap base leaked once stays valid for the life of the process. Leak with one file, exploit with the next, same base for both. That is exactly the setup a long-running service hands you, which is the next section.
A shell from one Image.open()
The leak from the last section supplies the one random value; every other address is a constant, so the whole thing packages into a single file. The example below pins the heap base as well, so it reproduces byte for byte, but the leak is the only thing that value depends on. The victim side is the line every web application already wrote:
# vulnerable.py
from PIL import Image
import sys
Image.open(sys.argv[1]).load()
And opening the crafted file runs the 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'
$ python3 vulnerable.py shell.fpx
uid=0(root) gid=0(root) groups=0(root)
corrupted size vs. prev_size
The S=0xdc8800 in the builder output is the decode buffer's address, the heap base plus a fixed offset; it is pinned here and recovered from the leak on a live target. The uid=0(root) line is system("id") running inside the process that opened the image. The corrupted size vs. prev_size after it is glibc noticing, on the way out, that we trampled its heap; by then the command has already run.
Root cause and the fix
The overflow exists because nobody checks that the bytes libjpeg is about to write per row fit the buffer Pillow allocated for the row. Once jpeg_start_decompress has run, both numbers are known, so the check is a few lines at the sink:
// src/libImaging/JpegDecode.c, right after jpeg_start_decompress
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;
}
output_width * output_components is exactly what libjpeg writes per scanline; state->bytes is exactly what the buffer holds. For every legitimate mode the two are equal, so the guard never fires on a valid image. Only the mismatched JCS_UNKNOWN case, four components into a two-byte buffer, trips it, and the crafted file turns into a clean OSError instead of a shell. It is the check that should have sat between the two layers from the start.
The vulnerability was assigned CVE-2026-112398.
Good, but it needs a dependency
A shell from a single Image.open() is a strong result, but the whole chain depends on FlashPix, and FlashPix only loads if the optional olefile package is installed. It is common, but it is not default. A plain pip install pillow is not vulnerable to the FlashPix bug.
I wanted a bug that fires on a default install, no extras. So I went back through the audit, this time looking for attacker-controlled writes in Pillow's own Python code, rather than in a single C decoder. One that stood out was in the multi-frame seek() path.
A reused buffer
Some formats store more than one frame in a file: animated GIFs, multi-page TIFFs, MPO stereo pairs, layered PSDs. You move between frames with seek():
img = Image.open("multi.ext")
img.load() # frame 0
img.seek(1) # frame 1
img.load()
On seek(), Pillow re-parses the target frame's header and updates the image's mode, size, and decode tiles. load() then decodes the current frame into the image buffer.
Nothing requires every frame to use the same mode. Frame 0 can be grayscale "L" at one byte per pixel, and frame 1 can be "RGBA" at four. Same width, different bytes per pixel.
The buffer is allocated in src/PIL/ImageFile.py:
def load_prepare(self):
# create image memory if necessary
if self._im is None:
self.im = core.new(self.mode, self.size)
...
It is allocated once, the first time it is needed, for whatever mode is current then. It is never reallocated when the mode changes. So:
Image.open()reads frame 0: mode"L".load()allocates a 1 byte/pixel buffer.seek(1)moves to frame 1 and sets mode"RGBA"._imis notNone, soload_preparekeeps the old buffer.load()decodes frame 1's 4 byte/pixel data into the 1 byte/pixel buffer.
Every row writes four bytes per pixel into room for one. The overflowing bytes are frame 1's decoded pixels, so they are attacker-controlled. It is the same primitive as the FlashPix bug, reached through an ordinary multi-frame read.
Four formats, one bug
The flaw is in ImageFile, the base class every plugin inherits, so it is not specific to one format. Any multi-frame plugin whose seek() widens the mode without resetting the buffer is affected. Four qualify:
- MPO: stereo JPEG pairs. No dependency.
- DCX: multi-page PCX. No dependency.
- PSD: grayscale composite, then an RGBA layer. No dependency.
- MIC: Microsoft Image Composer. Needs
olefile, like FlashPix.
Three of the four need nothing but Pillow. pip install pillow, open a file, iterate its frames, and the overflow is reachable. That is the property I was after.
From crash to shell
The write is the same class as the FlashPix overflow: attacker-controlled bytes past the end of a heap buffer, with a live Python object sitting right after it. In principle the FlashPix chain applies directly, minus olefile: overwrite the neighbor's type pointer, aim it into a region you control, and the next PyBuffer_Release becomes a call.
In practice the finish depends on which decoder writes the overflow, and the default-install formats put up a fight. The RGB unpackers behind DCX and MPO write each pixel as MAKE_UINT32(R, G, B, 255), which forces every fourth byte to 0xFF. That is the byte I need to control when spraying a pointer, and 0xFF in the top byte lands the address in non-canonical space, so the dereference faults instead of calling. Those vehicles gave me reliable, attacker-influenced crashes, not a shell.
The way through was to pick a frame whose unpacker does not force that byte. An MPO can have a CMYK JPEG as its second frame, and the CMYK unpacker (unpackCMYKI) inverts all four bytes without pinning any of them, so every byte of the overflow is mine again. JPEG is lossy, so I wrote a small solver that works backward from the bytes I need to the DCT coefficients that decode to them. With that, the full chain fires from a single Image.open() on a default install, no optional dependency, about 80% of the time.
MIC reaches a shell too, and more reliably, but it needs
olefile, which puts it back in the FlashPix bug's dependency bracket. The no-dependency result is the MPO one.
In the wild: ComfyUI
A bug pattern is only interesting if something real reaches it. ComfyUI is a widely used node-based UI for image generation that runs an HTTP server and takes image uploads. Its LoadImage node is where an upload gets decoded:
# ComfyUI nodes.py, LoadImage.load_image (abridged)
def load_image(self, image):
image_path = folder_paths.get_annotated_filepath(image)
components = InputImpl.VideoFromFile(image_path).get_components() # pyav / ffmpeg
if components.images.shape[0] > 0:
return (...) # pyav handled it
# fallback: Pillow
img = node_helpers.pillow(Image.open, image_path)
for i in ImageSequence.Iterator(img): # open -> seek -> load
...
Pillow is only a fallback, tried after pyav, and there is no try/except around the pyav call, so if get_components raises, the node fails and Pillow is never reached. And when Pillow does run, ComfyUI iterates every frame with ImageSequence.Iterator, which is the open → seek → load sequence the seek bug needs.
So the way in is to make pyav return empty without raising. pyav probes by extension first, so a file uploaded as .png whose contents are not PNG fails the PNG decoder and drops to content sniffing. ffmpeg does not recognize OLE2 or MPO, so get_components returns zero frames and no exception. ComfyUI falls through to Pillow, Pillow recognizes the real format, and ImageSequence.Iterator walks it into the overflow.
Everything is driven over the HTTP API. Uploads go to /upload/image; the .png name is enough to pass, and Pillow ignores it and reads the bytes:
def upload(filename, content):
files = {"image": (filename, io.BytesIO(content), "image/png")}
return SESSION.post(COMFY_URL + "/upload/image", files=files).json()
def queue_prompt(workflow):
cid = uuid.uuid4().hex
return SESSION.post(COMFY_URL + "/prompt",
json={"prompt": workflow, "client_id": cid}).json()
The workflow is two nodes, LoadImage feeding SaveImage:
workflow = {
"1": {"class_type": "LoadImage", "inputs": {"image": name}},
"2": {"class_type": "SaveImage", "inputs": {"images": ["1", 0]}},
}
That SaveImage is the other part needed for the ASLR bypass.
- First, I upload a McIdas AREA file (the
map.cread) under a.pngname, run the graph, and fetch the saved "image" from/view: its pixels are the leaked heap bytes, and I recover the base from them. - Then, I build the overflow payload against that base, upload it (again with a
.pngextension), and run the same graph. The secondLoadImagehands the bytes to Pillow,ImageSequence.Iteratortriggers the seek overflow, the planted vtable resolves, andsystem()runs:
$ python ./exploit.py "id"
$ cat /tmp/comfyui_rce_output.txt
uid=0(root) gid=0(root) groups=0(root)
The leak is a McIdas file and the overflow is the MPO/CMYK payload, so nothing on the server needs requires external dependencies. It is not one-shot though, since the worker's heap drifts between requests, so a run takes a few attempts.
ComfyUI is one example. The same pattern reaches anything that opens user-supplied images and touches more than the first frame, which covers most thumbnailers, converters, and upload pipelines.
Beaten to the punch
I sat on the seek bug for a while before reporting it. I've filed a lot of disclosures for other projects that were never fixed (not out of any bad faith on the maintainers' side, but because open-source maintainers rarely have the resources to triage and patch everything that lands in their inbox). By the time I submitted the seek bug, it was labeled "Duplicate".
Someone had already reported the same bug privately, and it was fixed in the same release under CVE-2026-96639.
It was the second time this audit that someone reached a bug ahead of me. The map.c read behind the ASLR leak had gone the same way: found independently and disclosed as CVE-2026-54058 before I would have reported it. Two of the three bugs, found in parallel by other people, in the same window. LLMs are getting good!
Disclosure timeline
- May 2026. Audited Pillow's native decoding stack and found all three bugs
- June 4, 2026. Disclosed the FlashPix overflow to the maintainers
- June 5, 2026. A first tentative patch went up for review
- July 7, 2026. The OOB read I used for the ASLR leak was disclosed as CVE-2026-54058, already fixed in Pillow 12.3.0 the week before.
- September 18, 2026. The maintainers asked me to review a reworked FlashPix fix
- September 19, 2026. Fix approved
- September 19, 2026. Reported the multi-frame
seek()overflow (duplicate) - October 16, 2026. Both fixes (FPX and seek) shipped in Pillow 13.0.0