# Pillow: Heap buffer overflow writing JPEG comment after ICC profile fills buffer

Pillow's JPEG encoder writes the COM marker without checking free buffer space; an exact-fit ICC profile wraps libjpeg's size_t counter, overflowing the heap.

- Payout: Research
- Severity: high
- Program: Pillow
- Found by: carlini
- Disclosed: 2026-10-22
- CVE: CVE-2026-96778
- Write-up quality: 9/10
- Original: https://github.com/advisories/GHSA-g7cr-3p24-7wf7
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-g7cr-3p24-7wf7
- Tags: area:oss domain:oss impact:dos impact:rce kind:research platform:ghsa sev:high tech:python technique:chained technique:incomplete-fix technique:source-review vuln:dos vuln:memory-corruption

**Target:** JPEG encoder state machine (ImagingJpegEncode) via JpegImagePlugin._save re-encode
**Root cause:** State 4 of ImagingJpegEncode calls jpeg_write_marker() for the COM segment without checking free_in_buffer, and _save() sizes the buffer from EXIF/ICC only, reserving nothing for the comment. When the ICC block ends exactly at the buffer end, libjpeg's emit_byte stores one byte past the end then decrements free_in_buffer from 0, wrapping the size_t.
**Steps:**
1. Craft a valid baseline JPEG with an APP2 ICC_PROFILE segment whose payload is 65536-20-18 = 65498 bytes (exact fit after SOI+JFIF APP0 and 18-byte ICC header).
2. Add a COM segment (FFFE) with any comment, e.g. 65533 'C' bytes, spliced right after SOI.
3. Victim runs `im = Image.open(BytesIO(data)); im.save(BytesIO(), "JPEG", icc_profile=im.info.get("icc_profile"))`.
4. State 3 copies the extra block leaving free_in_buffer == 0, falls into state 4, jpeg_write_marker writes buf[65536] and wraps free_in_buffer to ~2^64.
5. State 5's 1024-byte check passes against the wrapped counter; comment and full compressed image are written linearly past the 65536-byte heap buffer; process dies with SIGSEGV or glibc abort.
**How found:** Code review of the resumable encoder state machine: noticed every emitting state (2, 5, 6) guards free_in_buffer but state 4 does not; traced _save() buffer sizing which reserves nothing for the comment, then reasoned about libjpeg's emit_byte post-decrement semantics to find the exact-fit wrap condition.
**Exploitation:** Attacker supplies a ~140-210 KB valid JPEG (or ~60 KB PNG with iCCP+tEXt) to any service that re-encodes preserving ICC. Out-of-bounds write of 4 marker bytes + up to 65533 attacker-chosen comment bytes + compressed image data. Demonstrated escalation to RCE against a stdlib http.server worker: (a) ~2400 APPn spray segments groom the heap, 3020-byte COM re-images libjpeg PERMANENT/IMAGE pools so grayscale_convert copies a chosen scanline over _PyRuntime, setting allocators.obj_arena.free = system with ctx = command; (b) 28-byte COM rewrites a groomed tcache chunk so Pillow's malloc(comment_size) returns _imaging.so .got.plt, replacing free with system, executed by free(context->comment). Library base addresses obtained from separate Pillow TIFF memory-disclosure bugs.
**Escalation:** Chained with independent Pillow TIFF pointer-disclosure bugs to defeat ASLR; heap grooming via APPn spray segments and tcache manipulation to convert linear overflow into controlled function-pointer overwrite and command execution.
**Insight:** When a resumable encoder wraps libjpeg's destination manager, audit every state that emits for a free_in_buffer guard — libjpeg's emit_byte writes before testing the counter, so an exact-fit buffer (free_in_buffer == 0) wraps the size_t instead of suspending. Buffer sizing that counts only metadata lengths also under-counts the SOI/JFIF header libjpeg writes first, making exact fits attacker-constructible.

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

**JPEG layout with ICC profile and comment**

```
FFD8                                   SOI
FFE2 <len> "ICC_PROFILE\0" 01 01 <65498 bytes>     -> im.info["icc_profile"]
FFFE <len> <comment, any length 1..65533>           -> im.info["comment"]
<any baseline RGB JPEG body>
```

**Victim re-save call preserving ICC profile**

```
im = Image.open(BytesIO(data))
im.save(BytesIO(), "JPEG", icc_profile=im.info["icc_profile"])
```

