Like ordering a small coffee and the barista force-pouring a venti into your espresso cup — Pillow's JPEG decoder never checks if the drink fits the glass
CVE-2026-112403 is a heap-based buffer overflow in Pillow's ImagingJpegDecode function, affecting all versions prior to 13.0.0. When an application calls Image.thumbnail() — one of Pillow's most commonly used APIs — it internally invokes JpegImageFile.draft() to request the JPEG decoder to produce a downscaled image (1/2, 1/4, or 1/8 size). Pillow allocates an output buffer sized for those *scaled* dimensions. However, when the input is a lossless SOF3 JPEG and the build is linked against libjpeg-turbo ≥ 3.0, the library silently ignores the scaling request and emits full-width scanlines. The result: full-resolution pixel rows are written into a buffer sized for a thumbnail. On images ≥ 1024×1024, this produces a controlled, repeated heap overflow that corrupts adjacent allocations and can potentially achieve code execution. The fix in Pillow 13.0.0 (commit 242a059a) adds post-decode dimension validation.
No official vendor CVSS was published by the Pillow project. Third-party aggregators scored this between 8.1 (HIGH, CVSS v3.1) and 9.2 (CRITICAL, CVSS v4.0). The CRITICAL label from CVSS v4.0 overstates the real-world risk. While the attack vector is network-reachable and requires no authentication, *reliable* code execution from a heap overflow in a CPython extension demands serious exploit-development effort — ASLR, heap randomization, and glibc hardening in modern Linux deployments are all working against the attacker. The bug is genuinely dangerous in server-side image-processing pipelines (Django, Flask, FastAPI apps accepting user-uploaded photos), but the path from "heap corruption" to "arbitrary code execution" is not free. A HIGH rating at 7.5 better reflects the current threat posture: massive attack surface, credible impact, but meaningful exploitation friction and zero weaponization evidence.
5 steps from start to impact.
Craft a malicious SOF3 JPEG
- Attacker can craft arbitrary JPEG files
- SOF3 lossless JPEG is uncommon in the wild — WAF/upload validation rules that restrict to baseline JPEG (SOF0/SOF2) would block this input
Deliver JPEG to a Pillow-based processing endpoint
Image.thumbnail(), Image.draft(), or any wrapper that requests JPEG downscaling. This is extremely common: Django's ImageField with thumbnail generation, Flask-based APIs with image upload, content management systems, and ML data pipelines all routinely call thumbnail(). The attacker does not need authentication if the upload endpoint is public (e.g., profile photo upload, support ticket attachment).- Target application accepts image uploads and processes them with Pillow's thumbnail() or draft() methods
- Upload endpoint is reachable by the attacker (public or authenticated)
- Applications that only store images without resizing are not affected
- Endpoints behind authentication narrow the attacker pool
libjpeg-turbo 3.x ignores scaling, returns full-width rows
ImagingJpegDecode() calls jpeg_start_decompress() with a scale_denom > 1, libjpeg-turbo 3.x accepts the parameter but hardwires lossless streams to no scaling — the scale request is silently discarded. The decoder returns output_width equal to the original image width, but Pillow has already allocated the output Imaging buffer based on the *requested* scaled dimensions. Pillow does not verify output_width or output_components against the buffer it allocated.- Pillow < 13.0.0 is installed
- Pillow is linked against libjpeg-turbo ≥ 3.0 (bundled in standard PyPI wheels for Pillow 10.1+)
- Source-compiled Pillow on RHEL 8/9 or Ubuntu 22.04 LTS links against system libjpeg-turbo 2.x, which *rejects* SOF3 input entirely
- Pillow installed in conda environments may use IJG libjpeg rather than libjpeg-turbo
Heap buffer overflow — full scanlines into undersized buffer
jpeg_read_scanlines() writes a full-width row (e.g., 4096 bytes for a 4096-wide RGB image) into a buffer allocated for the scaled width (e.g., 1024 bytes at scale_denom=4). This overflow is repeated for every scanline in the image, producing a large, controlled, sequential heap overwrite. Depending on heap layout, this corrupts adjacent allocations including Python object headers, function pointers, and other C-level structures. The process will almost certainly crash; achieving controlled code execution requires heap grooming.- Steps 1-3 conditions met
- Modern glibc heap hardening (safe-unlinking, tcache key checks) makes reliable exploitation significantly harder than raw overflow suggests
- ASLR and NX are default-on in all modern Linux distributions
- CPython's object allocator (pymalloc) adds an additional layer of heap layout unpredictability
ImagingJpegDecode are a strong indicator.Potential code execution in application context
www-data or a similarly unprivileged user, but with access to application secrets, database connection strings, environment variables, and internal network connectivity. On containerized deployments, the blast radius is contained to the container unless a container escape is chained.- Attacker has developed a reliable heap-grooming primitive for the target environment
- Target is not running under ASAN or other memory-safety instrumentation
- No public exploit or PoC exists — this step requires original exploit development
- Heap layout varies across Python versions, OS versions, and libjpeg-turbo builds, making a universal exploit impractical
- Container isolation, seccomp, and AppArmor further limit post-exploitation impact
pip install --upgrade Pillow>=13.0.0 resolves CVE-2026-112403 and 43 other security issues in the same release. Per the noisgate remediation SLA for HIGH, deploy within 180 days. Given the 44-CVE bundle, prioritize this upgrade — it is the single highest-ROI Python dependency update available right now. Pin the version in your requirements.txt or pyproject.toml and propagate through CI/CD.apt or yum system libjpeg). Deploy within 30 days per the noisgate mitigation SLA for HIGH. Note: this sacrifices lossless JPEG support.- WAF JPEG content inspection — Standard WAFs inspect HTTP headers and request bodies but do not parse JPEG frame markers at the SOF level. A SOF3 JPEG has a valid
image/jpegMIME type and passes all standard content-type checks. - File extension filtering — The malicious file is a valid
.jpg/.jpegfile. Extension-based filtering provides zero protection. - Image dimension limits alone — While images must be ≥ 1024×1024 to trigger the scaling code path, rejecting large images is impractical for most applications (1024×1024 is a normal photo size). A dimension cap low enough to prevent this bug would break legitimate functionality.
- Python-level sandboxing (RestrictedPython, etc.) — The vulnerability is in Pillow's C extension (
libImaging), not in Python code. Python-level execution restrictions do not apply to native code.
The supporting signals.
| In-the-Wild Exploitation | None observed. Not listed in CISA KEV. No GreyNoise, Shadowserver, or campaign reporting as of 2026-10-22. This is a library-level bug, not a network service, so mass scanning is not applicable — exploitation requires targeting specific applications. |
|---|---|
| Proof-of-Concept | No public PoC found. No dedicated repos on GitHub matching CVE-2026-112403. Not indexed on pocindex.io. The SecureWithUmer/CVE-2026-PoCs collection does not include this CVE. Crafting a triggering SOF3 JPEG is straightforward; weaponizing the heap overflow into RCE is not. |
| EPSS | 0.00321 (0.321%) — 23.7th percentile. Low predicted exploitation probability over the next 30 days. Consistent with no known weaponization. |
| CVSS Vectors | CVSS v3.1: 8.1 HIGH CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H (GitLab Advisory). CVSS v4.0: 9.2 CRITICAL CVSS:4.0/AV:N/AC:H/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N (GHSA). The AC:H in both vectors correctly reflects the heap exploitation complexity. |
| Affected Versions | All Pillow versions < 13.0.0. The bug has existed since Pillow first added draft() JPEG scaling support, but only became *exploitable* when libjpeg-turbo 3.0 introduced SOF3 lossless decoding (August 2024). Pillow PyPI wheels bundle libjpeg-turbo ≥ 3.x starting from approximately Pillow 10.1+. |
| Fixed Version | Pillow 13.0.0 (released 2026-10-16). Fix commit: 242a059a. Adds post-decode validation of output_width and output_components against the allocated buffer dimensions. Note: libjpeg-turbo also fixed the analogous TurboJPEG API issue in 3.1.4, but the Pillow-side fix is the authoritative remediation. |
| Exposure Surface | Pillow has ~400M monthly PyPI downloads and is a transitive dependency of Django, Flask, Matplotlib, scikit-image, and thousands of other packages. However, only deployments that (a) process *untrusted* image input via thumbnail()/draft() AND (b) link against libjpeg-turbo 3.x are reachable. Standard PyPI wheels meet condition (b); source builds on enterprise LTS distros typically do not. |
| Disclosure Timeline | 2026-10-16: Pillow 13.0.0 released with fix. 2026-10-19: CVE-2026-112403 and GHSA-8g5g-8vmr-9782 published. 3-day window between patch availability and public disclosure. |
| Reporter | Not publicly attributed in the GHSA advisory. The fix commit is authored by the Pillow core maintainers. |
| Related Vulns | Part of a batch of 44 security fixes in Pillow 13.0.0, including CVE-2026-112400 (DoS), CVE-2026-112401 (GIF quadratic copy), CVE-2026-112404 (TIFF OOB read), CVE-2026-96639 (stale image storage). Organizations should treat the Pillow 13.0.0 upgrade as a security-critical bundle. |
Sources.
Why this verdict
- Massive library footprint with common trigger path: Pillow averages ~400M monthly PyPI downloads and
thumbnail()is among its most-called methods. Any Python web application accepting image uploads and generating thumbnails — Django, Flask, FastAPI, Celery workers — sits directly in the blast zone. The network-reachable, no-auth, no-interaction trigger path is legitimate. - PyPI wheels bundle libjpeg-turbo 3.x, widening the vulnerable population: The critical prerequisite (libjpeg-turbo ≥ 3.0) is *satisfied by default* for the majority of Pillow installations via
pip install. This eliminates the most significant friction point for pip-based deployments. Source-built installs on RHEL 8/9 and Ubuntu 22.04/24.04 LTS linking against system libjpeg-turbo 2.x are *not* vulnerable, representing a meaningful but minority share of production deployments. - Heap exploitation complexity is real and unresolved: AC:H is correctly assigned. There is no public PoC, no weaponized exploit, and no evidence of in-the-wild exploitation. Transforming the heap overflow into reliable code execution against modern glibc (safe-unlinking, tcache hardening), ASLR, NX, and CPython's pymalloc allocator is a non-trivial research project. This is the primary reason the score sits at 7.5 rather than 8.1+.
- Role multiplier — typical deployment role (application tier): Pillow is a library, not infrastructure. In its low-value role (developer workstations, Jupyter notebooks), exploitation impact is minimal. In its typical role (web application servers processing user-uploaded images), successful exploitation yields code execution as the web server user — access to app secrets, DB credentials, and internal network, but scoped to that application tier. In a plausible high-value role (CI/CD pipelines processing images from untrusted PRs, ML training pipelines ingesting external datasets), the blast radius extends to build secrets and artifact integrity — but this represents a small fraction (<5%) of Pillow's installed base. No canonical high-value infrastructure role (this is not a hypervisor, IdP, or network edge) → no floor override. The typical-role outcome (application-tier RCE) is serious but not fleet-scale.
- No exploitation evidence, low EPSS: EPSS at 0.321% (23.7th percentile), not KEV-listed, no GreyNoise/Shadowserver signals. The 3-day head start between patch release and CVE publication further reduces the window for opportunistic exploitation.
Why not higher?
CRITICAL would require either active exploitation evidence or a reliable, low-complexity path to code execution. Neither exists. The heap overflow is real but heap grooming against modern mitigations (ASLR, glibc tcache hardening, pymalloc) has no published solution. Pillow is not canonically high-value infrastructure — it is a library running inside application processes, and even successful RCE is scoped to the application tier, not fleet-level or identity-level impact. The 9.2 CVSS v4.0 score from GHSA overweights the theoretical ceiling and underweights practical exploitation friction.
Why not lower?
MEDIUM would undervalue the combination of Pillow's enormous attack surface (~400M monthly downloads), the fact that PyPI wheels bundle the vulnerable libjpeg-turbo 3.x by default, and the realistic network-reachable trigger via commonplace thumbnail() calls on untrusted input. The vulnerability class (heap buffer overflow) has a credible path to code execution even if that path is complex. Downgrading below HIGH would be irresponsible for an image-processing library that is a transitive dependency of most Python web frameworks.
Crowdsourced verification payload.
Run on any host where Pillow is installed. Execute with python check_cve_2026_112403.py — no special privileges needed. The script checks the installed Pillow version and the bundled libjpeg-turbo version to determine exploitability. Works in virtualenvs and system Python alike.
#!/usr/bin/env python3
"""check_cve_2026_112403.py — Detect CVE-2026-112403 (Pillow heap overflow in ImagingJpegDecode)
Outputs: VULNERABLE / PATCHED / UNKNOWN
Exit codes: 1 = VULNERABLE, 0 = PATCHED, 2 = UNKNOWN
"""
import sys
def main():
try:
from PIL import Image
import PIL
except ImportError:
print("UNKNOWN — Pillow is not installed in this environment.")
sys.exit(2)
pillow_version = getattr(PIL, '__version__', '0.0.0')
print(f"[*] Pillow version: {pillow_version}")
# Parse version tuple
try:
parts = pillow_version.split('.')
ver_tuple = tuple(int(x.split('dev')[0].split('a')[0].split('b')[0].split('rc')[0]) for x in parts[:3])
except (ValueError, IndexError):
print(f"UNKNOWN — Could not parse Pillow version '{pillow_version}'.")
sys.exit(2)
# Check if Pillow >= 13.0.0 (patched)
if ver_tuple >= (13, 0, 0):
print(f"PATCHED — Pillow {pillow_version} >= 13.0.0. CVE-2026-112403 is fixed.")
sys.exit(0)
# Pillow < 13.0.0 — check libjpeg-turbo version
print(f"[*] Pillow {pillow_version} < 13.0.0 — checking libjpeg-turbo linkage...")
ljt_version = None
try:
# Pillow exposes jpeg library info via features
from PIL import features
jpeg_info = features.version('libjpeg')
if jpeg_info:
print(f"[*] libjpeg version string: {jpeg_info}")
# libjpeg-turbo reports versions like '3.0.0', IJG reports like '9e'
try:
ljt_parts = jpeg_info.split('.')
if len(ljt_parts) >= 2:
ljt_major = int(ljt_parts[0])
ljt_version = ljt_major
except ValueError:
pass
except Exception:
pass
# Also check via pillow_info for turbo indicator
is_turbo = False
try:
from PIL import features
is_turbo = bool(features.check('libjpeg_turbo'))
if is_turbo:
print("[*] libjpeg-turbo detected (turbo=True).")
else:
print("[*] libjpeg-turbo NOT detected (using IJG libjpeg or unknown).")
except Exception:
pass
if ver_tuple < (13, 0, 0):
if is_turbo and ljt_version is not None and ljt_version >= 3:
print(f"VULNERABLE — Pillow {pillow_version} with libjpeg-turbo {ljt_version}.x.")
print(" CVE-2026-112403: Heap buffer overflow in ImagingJpegDecode")
print(" with SOF3 lossless JPEG when thumbnail()/draft() is used.")
print(" Remediation: pip install --upgrade 'Pillow>=13.0.0'")
sys.exit(1)
elif is_turbo and (ljt_version is None or ljt_version < 3):
print(f"PATCHED — Pillow {pillow_version} links libjpeg-turbo < 3.0.")
print(" libjpeg-turbo 2.x rejects SOF3 input; not exploitable.")
print(" Note: Upgrade Pillow to >= 13.0.0 anyway for 43 other fixes.")
sys.exit(0)
elif not is_turbo:
print(f"PATCHED — Pillow {pillow_version} uses IJG libjpeg (not turbo).")
print(" IJG libjpeg does not decode SOF3; not exploitable.")
print(" Note: Upgrade Pillow to >= 13.0.0 anyway for 43 other fixes.")
sys.exit(0)
else:
print(f"UNKNOWN — Pillow {pillow_version} < 13.0.0 but could not")
print(" determine libjpeg-turbo version. Assume VULNERABLE and upgrade.")
sys.exit(2)
if __name__ == '__main__':
main()