# Pillow: Memory disclosure and segfault in libtiff encoder via crafted TIFF tags

Pillow's libtiff encoder passes TIFF tag values using Python-value-derived varargs, but libtiff reads them per its own field table, leaking process memory or segfaulting on re-save.

- Payout: Research
- Severity: high
- Program: Pillow
- Found by: carlini
- Disclosed: 2026-10-21
- CVE: CVE-2026-112902
- Write-up quality: 9/10
- Original: https://github.com/advisories/GHSA-xvh5-g6gv-559v
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-xvh5-g6gv-559v
- Tags: area:oss domain:oss impact:data-leak impact:dos kind:research platform:ghsa sev:high tech:python technique:parser-differential technique:source-review vuln:dos vuln:info-disclosure vuln:memory-corruption

**Target:** Pillow libtiff encoder (src/encode.c PyImaging_LibTiffEncoderNew) via crafted TIFF tags
**Root cause:** PyImaging_LibTiffEncoderNew chooses TIFFSetField varargs from the Python value's shape and assumes its TIFFMergeFieldInfo registration defines how libtiff reads them; but TIFFMergeFieldInfo never overrides libtiff's built-in field table, so libtiff consumes (count,pointer) or (pointer) from slots Pillow never wrote.
**Steps:**
1. Craft a compressed TIFF (e.g. 356-byte LZW) with an extra IFD entry `50740 / LONG / count 1 / value 65536` (DNGPrivateData) or `50829 / LONG / 1 / 64` (ActiveArea).
2. Application does `Image.open(BytesIO(src)); im.save(out, "TIFF")` — source compression routes save through libtiff automatically.
3. Encoder autodetects the int as TIFF_LONG, registers a scalar custom field, and calls `ImagingLibTiffSetField(&state, key, (UINT32)PyLong_AsLong(value))`.
4. libtiff's built-in definition (passcount=1 for 50740) reads the scalar as byte count and memcpy's that many bytes from an unwritten vararg slot into the output tag; for 50829 (writecount=4, passcount=0) the scalar becomes the source address → SIGSEGV.
5. With `tiffinfo=im.tag_v2`, a `50829 / LONG8 / 1 / <address>` entry yields a 16-byte arbitrary read into the output ActiveArea.
**How found:** Reporter noticed the encoder derives TIFFSetField argument shape from the Python value and assumes its own field registration governs libtiff's reading; queried libtiff 4.5.0 after Pillow's merge call and found built-in definitions unchanged (50740 passcount=1 writecount=-1; 50829 passcount=0 writecount=4), then built crafted TIFFs to confirm the unwritten vararg slot is consumed as count or pointer.
**Exploitation:** Any service that accepts a TIFF and re-saves it as TIFF (plain `im.save(fp, "TIFF")` on a compressed upload) returns attacker-chosen bytes of worker process memory inside one metadata tag, deterministically. A 262144-byte request covered _imaging.so .rodata/.data.rel.ro/GOT and returned 2800+ resolved pointers into libpython/libc/libtiff/libjpeg — a full ASLR bypass for chaining. The fixed-count variant kills the worker with SIGSEGV from a 181-byte upload. With tiffinfo=im.tag_v2 the source address is fully attacker-chosen.
**Escalation:** Leaked pointers into libpython/libc/libtiff defeat ASLR for chaining with any other memory bug; other branches (BYTE ColorMap, ASCII Photoshop/ICCProfile, RATIONAL tuples, EXIF from JPEG/PNG/WebP) add further deterministic crash and disclosure inputs.
**Insight:** When wrapping a variadic C API, never infer the calling convention from your own value types — query the library's effective field definition (TIFFFindField/TIFFFieldPassCount/TIFFFieldWriteCount) after registration, since merge APIs often refuse to override built-ins. Untyped tags forwarded from parsed files are prime suspects.

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

**Reproducer script: crafted TIFF tags leak memory and segfault**

```
`repro` is one Python script, run against the stock build with the installed `PIL`. It needs no compiler and no extra files.

`upload(tag, value)` has Pillow write a 16×16 red RGB image as an LZW-compressed TIFF, then appends a new IFD holding the same entries plus one extra `<tag> / LONG / count 1 / value` (kept in tag order) and points the header at it — a 356-byte input on this tree. The source is LZW-compressed on purpose. A bare `im.save(out, "TIFF")` keeps the source's compression, so the save goes through the libtiff writer even though the application passes no arguments. `resave()` is the whole "application": `Image.open(BytesIO(src)); im.save(out, "TIFF")`.

The script runs three cases:

1. **Control, tag 65000 = 4096.** libtiff does not know this tag, so the file is re-saved correctly and the output tag reads back as the integer `4096`. This is what should happen to every tag.
2. **Leak, DNGPrivateData 50740 = 65536.** libtiff's built-in definition of this tag is a counted BYTE array. It reads Pillow's single scalar as the *count* and takes the data pointer from a vararg slot Pillow never wrote. The output tag comes back as `bytes` of length 65536 that do not occur in the input. The script prints the first 32 of them and the file offset at which they occur in `_imaging.so` on disk. On this build they always do (offset `0x57588`): the stale pointer aims into the extension's read-only data.
3. **Wild read, ActiveArea 50829 = 64.** libtiff defines this tag as a fixed LONG[4] array with no count, so it uses the scalar 64 as the *source address*. The script re-runs itself as a child process (`repro child`, core dumps disabled) to do this re-save, and the child exits with `-11`, meaning SIGSEGV.

Expected output on the affected tree:

control  tag 65000=4096 -> 4096
leak     tag 50740=65536 from a 356-byte upload -> 65536 bytes not present in input, head a661fcffc661fcff3360fcff7b61fcffd361fcff085ffcffe061fcff3360fcff
         leaked bytes found in _imaging.so at file offset 0x57588
crash    tag 50829=64 -> child exit -11 (SIGSEGV)
BUG

The script prints `BUG` if case 2 leaks or case 3 crashes.
```

**Well-formed CFARepeatPatternDim tuple triggers SIGSEGV**

```
A perfectly well-formed 206-byte 8×8 PackBits TIFF
  carrying `33421 / SHORT / 2 / (2, 2)` re-saved with a bare
  `im.save(out, "TIFF")` dies with SIGSEGV in `memcpy` (gdb: `rsi = 0x2`, the
  tuple length) on every run
```

