Feb 12, 2026·6 min read·14 visits
Pillow versions 10.3.0 to <12.1.1 fail to sanity-check negative coordinates in PSD layers. By crafting a Photoshop file with a layer offset like `x=-100`, an attacker can trick the C backend into writing data before the start of the image buffer. This heap corruption primitive can be leveraged for RCE.
A high-severity Out-of-Bounds Write vulnerability exists in Pillow, the de facto Python Imaging Library, specifically within its Photoshop Document (PSD) handler. The flaw arises from a failure to validate negative image offsets in the C extension modules, allowing attackers to write pixel data to arbitrary memory locations preceding the allocated buffer. This can lead to heap corruption, denial of service, or potentially remote code execution when processing malicious images.
If you write Python, you use Pillow. It's the library that powers image processing for Django, Flask, and practically every data science pipeline that touches a JPEG. But under the hood, Pillow isn't just friendly Python code; it's a wrapper around a massive, ancient C codebase designed to parse the most cursed file formats known to man.
Enter the Photoshop Document (PSD). Adobe's spec for PSD is notorious for being less of a standard and more of a memory dump of a 1990s Macintosh. In CVE-2026-25990, the vulnerability isn't in the complex compression algorithms or the color profiles—it's in basic arithmetic. The kind of arithmetic that C developers have been getting wrong since the Nixon administration.
This vulnerability is a classic "Out-of-Bounds Write," but with a twist. Usually, we worry about writing past the end of a buffer. Here, we are writing before it. It's a negative offset bug, allowing an attacker to reach back into the heap and overwrite whatever unfortunate data structure sits just prior to our image buffer.
To understand the bug, you have to look at how Pillow handles image layers (or "tiles"). When you load a PSD, it might consist of multiple layers, each with a specific position on the canvas. These positions are defined by xoff (x-offset) and yoff (y-offset).
The vulnerability lives in src/decode.c and src/encode.c, specifically in the _setimage function. This function initializes the state for the image decoder. The developers were responsible citizens: they checked if the image was too big. They checked if the layer extended past the right edge of the canvas. They checked if it extended past the bottom edge.
But they forgot one direction: Left.
The code implicitly assumed that xoff and yoff would be positive integers. In the C language, buffer[-1] is not a helpful syntax for accessing the last element like in Python; it is a direct memory instruction to access the address base_address - sizeof(type). By supplying a negative offset in the PSD metadata, the validation checks passed (because width + (-10) is still less than max_width), but the memory pointer calculation pointed into the abyss.
Let's look at the vulnerable logic in src/decode.c. The original code tried to ensure the tile didn't extend outside the image boundaries.
// Vulnerable Code
if (state->xsize <= 0 ||
state->xsize + state->xoff > (int)im->xsize ||
state->ysize <= 0 ||
state->ysize + state->yoff > (int)im->ysize) {
PyErr_SetString(PyExc_ValueError, "tile cannot extend outside image");
return NULL;
}Do you see the gap? If xoff is -100, the check state->xsize + (-100) > im->xsize evaluates to false (assuming xsize is normal). The check passes.
However, later in the rendering loop, the destination pointer is calculated roughly like this:
pixel_dest = buffer + (y + state->yoff) * stride + (x + state->xoff);If state->xoff is negative, pixel_dest points before buffer.
The fix, implemented in commit 9000313cc5d4a31bdcdd6d7f0781101abab553aa, is embarrassingly simple. They just added checks for zero-floors:
- if (state->xsize <= 0 || state->xsize + state->xoff > (int)im->xsize ||
+ if (state->xoff < 0 || state->xsize <= 0 ||
+ state->xsize + state->xoff > (int)im->xsize || state->yoff < 0 ||This is the difference between a secure application and a segfault (or RCE).
Exploiting this requires some finesse, but the primitive is strong. We have a controlled linear underflow write.
xoff: -64.B for the image. The decoder starts writing pixel data at B - 64.If we positioned our chunks correctly, we overwrite the metadata of the previous chunk. By controlling the pixel data (the colors in the PSD layer), we control exactly what is written to that metadata. If we overwrite a reference count, we trigger a premature free. If we overwrite a type pointer, we cause type confusion. If we overwrite a function pointer, we hijack control flow directly.
This isn't just a Denial of Service (though crashing the worker process is the most likely outcome for a script kiddie). The CVSS score of 8.9 reflects the reality that memory corruption in a language runtime like Python (via C extensions) is catastrophic.
In a cloud environment, image processing is often offloaded to worker nodes (Celery, RQ). If an attacker gets RCE on the worker, they are inside your perimeter. They can dump environment variables (AWS keys, database credentials) or pivot to the internal network.
Because this is an interactionless exploit (the server just needs to process the file), it is wormable within specific ecosystems. Any service that auto-generates thumbnails for PSDs is immediately vulnerable.
The remediation is straightforward: Update Pillow to version 12.1.1 immediately.
If you are stuck on an older version due to dependency hell (we've all been there), you have two options, neither of them good:
PSD plugin from loading if you don't strictly need it.Just update the package.
pip install --upgrade Pillow
# Verify you are on >= 12.1.1
python3 -c "import PIL; print(PIL.__version__)"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:P| Product | Affected Versions | Fixed Version |
|---|---|---|
Pillow python-pillow | >= 10.3.0, < 12.1.1 | 12.1.1 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-787 (Out-of-bounds Write) |
| CVSS Score | 8.9 (High) |
| Attack Vector | Network (Image Upload) |
| Impact | RCE / Denial of Service |
| Component | src/decode.c (_setimage) |
| Fix Version | 12.1.1 |
The product writes data past the end, or before the beginning, of the intended buffer.