# Pillow: Heap out-of-bounds write in `ImageFilter.RankFilter` via integer overflow in `ImagingExpand`

Pillow's RankFilter/MedianFilter expands the image before validating filter size, so size 4294967295 overflows ImagingExpand's int math and writes out of bounds.

- Payout: Research
- Severity: high
- Program: Pillow
- Found by: Serotav
- Disclosed: 2026-07-20
- CVE: CVE-2026-59197
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-xj96-63gp-2gmr
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-xj96-63gp-2gmr
- Tags: area:oss domain:oss impact:dos kind:research platform:ghsa sev:high tech:python technique:source-review vuln:dos vuln:memory-corruption

**Target:** Pillow native C extension (libImaging Filter.c / RankFilter.c) via ImageFilter public API
**Root cause:** RankFilter.filter() calls image.expand(size//2, size//2) before rank-filter size validation; ImagingExpand computes output dimensions with unchecked signed int arithmetic, wrapping to a tiny allocation while the border loop still uses the huge margin.
**Steps:**
1. Create a small image, e.g. `Image.new("L", (3, 3), 128)`.
2. Call `im.filter(ImageFilter.MedianFilter(4294967295))` (or RankFilter/MinFilter/MaxFilter with that size).
3. RankFilter.filter() computes margin `size // 2 = 2147483647` (INT_MAX) and calls `image.expand(margin, margin)` before any size check.
4. ImagingExpand computes `xsize + 2*xmargin` which wraps to 1x1, allocating a 1-byte buffer.
5. The border-expansion loop `imOut->image[yout][x] = imIn->image[yin][0]` iterates to xmargin, writing past the allocation (1-byte for mode L, 4-byte for mode I/F).
**How found:** Reporter noticed RankFilter.filter() calls image.expand() before the rank-filter size validation in RankFilter.c, then traced ImagingExpand's unchecked signed int dimension arithmetic and confirmed the wrap with an ASAN build.
**Exploitation:** Reachable through documented public classes (RankFilter, MedianFilter, MinFilter, MaxFilter) with an attacker-controlled filter size; mode L gives 1-byte OOB stores, modes I/F give 4-byte stores whose value is copied from the source border pixel, so attacker-supplied image bytes influence the written value. Sequential overwrite, not arbitrary-address write.
**Insight:** When a Python wrapper does preprocessing (expand/pad) before the native function's own validation, the pre-validation path is the real attack surface; check ordering of validation vs. allocation in wrapper-vs-C boundaries and look for unchecked `xsize + 2*margin` style int math.

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

**Minimal ASAN crash PoC with MedianFilter**

```
from PIL import Image, ImageFilter

im = Image.new("L", (3, 3), 128)
im.filter(ImageFilter.MedianFilter(4294967295))
```

**4-byte write variant with TIFF source pixel**

```
from io import BytesIO
from PIL import Image, ImageFilter

SIZE = 4294967295
PIXEL = 0x41424344

src = BytesIO()
Image.new("I", (3, 3), PIXEL).save(src, format="TIFF")

im = Image.open(BytesIO(src.getvalue()))
im.load()
assert im.mode == "I"
assert im.getpixel((0, 0)) == PIXEL

im.filter(ImageFilter.MedianFilter(SIZE))
```

