# Pillow: OOB read in TiffDecode.c tile and RGBA paths leaks heap memory into pixels

Pillow's TIFF tile and RGBA decoders size buffers from libtiff's IFD parse but unpack rows using Pillow's own parse, so duplicated tags cause heap over-read bytes to leak into returned pixels.

- Payout: Research
- Severity: high
- Program: Pillow
- Found by: carlini, Kadd01, bryans3c
- Disclosed: 2026-10-20
- CVE: CVE-2026-112404
- Write-up quality: 9/10
- Original: https://github.com/advisories/GHSA-c6hw-5grf-ff96
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-c6hw-5grf-ff96
- Tags: area:oss domain:oss impact:data-leak impact:dos kind:research platform:ghsa sev:high tech:python technique:incomplete-fix technique:parser-differential technique:source-review vuln:dos vuln:info-disclosure vuln:memory-corruption

**Target:** libImaging TiffDecode.c _decodeTile()/_decodeAsRGBA() TIFF decoder
**Root cause:** _decodeTile() and _decodeAsRGBA() size state->buffer from libtiff's TIFFTileSize()/width*4 but unpack each row with the rawmode chosen from Pillow's own IFD parse, without checking the unpacker row fits libtiff's row. Duplicated PhotometricInterpretation/BitsPerSample tags (libtiff keeps first, Pillow keeps last) make the unpacker read past the buffer.
**Steps:**
1. Craft a compressed TIFF with a duplicated tag: e.g. 16x16 3-sample tile with `PhotometricInterpretation` present twice as `2` then `6` (libtiff keeps 2 = RGB 3B/px, Pillow keeps 6 = RGBX copy4 4B/px). This variant also applies to released versions.
2. Alternatively write `PhotometricInterpretation` as SHORT count 2 `(6,6)` so libtiff ignores the tag (1B/px) while Pillow keeps 6 (RGBX); this lever needs 155330d63 (unreleased).
3. Open with `Image.open()` + `load()`; `_decodeTile()` allocates `TIFFTileSize()` bytes and calls `copy4` on `state->buffer + tile_y*row_byte_size`, reading 64 bytes from 16-byte rows.
4. Observe rows 13-15 differ under `MALLOC_PERTURB_=85` vs `=170`, containing heap pointer bytes; ASan reports heap-buffer-overflow READ of size 64 in `copy4` (Unpack.c:1290) from TiffDecode.c:483.
5. For the RGBA path use a strip with `BitsPerSample` `8,8,8` then `16,16,16` and `PhotometricInterpretation` `6` then `2`: libtiff decodes 8-bit YCbCr (4B/px), Pillow picks `RGB;16N` (6B/px), `unpackRGB16L` over-reads.
6. Use a 65536x16 tile to make the read run off the mapping and SIGSEGV in `copy4` <- `_decodeTile`.
**How found:** Noticed _decodeStrip() had a row-size check added for OSS-Fuzz crash-0da0 but the sibling _decodeTile()/_decodeAsRGBA() did not; traced that buffer sizing uses libtiff's parse while the unpacker comes from Pillow's IFD parse, then hunted for tags the two parsers resolve differently (duplicated tags, wrong count/type).
**Exploitation:** Attacker uploads a malformed TIFF to any service that calls Image.open()+load()/thumbnail()/convert()/save() with default settings; the service returns a thumbnail/preview/converted image whose last pixel rows contain adjacent heap memory (pointer values) from the worker process. A wider tile instead crashes the worker (SIGSEGV), causing DoS. Buffer is reused per tile so disclosed bytes per image are bounded (~(unpacker_bpp/libtiff_bpp - 1) tile rows) but tile width is attacker-chosen.
**Escalation:** Widening TileWidth (up to 2^26+16 with 32-bit wrap in the existing sanity check for 64-bit rawmodes) turns the over-read into a reliable SIGSEGV, converting info leak into worker DoS.
**Insight:** When a decoder sizes buffers from one parser's view but unpacks using another's, hunt for tags the two parsers resolve differently: duplicated tags (libtiff first-wins vs Pillow last-wins), wrong count/type that one ignores, and check every sibling code path for the invariant one path already enforces. Also watch 32-bit arithmetic in size sanity checks that can wrap.

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

**Malformed TIFF file definitions in reproducer**

```
files = {
  # Photometric count 2 (6,6): libtiff ignores tag -> 1 B/px; Pillow keeps 6 -> RGBX copy4 4 B/px (HEAD only)
  "tile_count2.tif": tile(16, 16, 1, [(262, S, [6, 6])]),
  # Photometric duplicated 2 then 6, 3 samples: libtiff RGB 3 B/px; Pillow RGBX 4 B/px (also released versions)
  "tile_dup.tif": tile(16, 16, 3, [(262, S, [2]), (262, S, [6])]),
  # strip, libtiff sees 8-bit YCbCr -> _decodeAsRGBA 4 B/px; Pillow sees RGB;16 -> 6 B/px
  "ycbcr_dup.tif": tiff([(256, S, [16]), (257, S, [16]), (258, S, [8, 8, 8]), (258, S, [16, 16, 16]), (259, S, [8]),
        (262, S, [6]), (262, S, [2]), (273, L, None), (277, S, [3]), (278, S, [16]), (279, L, [len(d)]),
        (530, S, [1, 1])], d),
  "tile_big.tif": tile(65536, 16, 1, [(262, S, [6, 6])]),
}
```

