Pillow: Heap buffer overflow in ImagingJpegDecode
CVE-2026-112403 · GHSA-8g5g-8vmr-9782 · published 20 Oct 2026
How serious is it
- Severity: critical, CVSS score 9.2 out of 10.
- No exploit prediction (EPSS) score yet.
- Not on the CISA list of exploited vulnerabilities.
- No public exploit in Exploit-DB.
Is your app affected by CVE-2026-112403?
Paste this prompt into Claude Code, Cursor or any AI coding tool: it runs a free Shippable scan of your project and fixes what it finds. Built with Lovable, Bolt or v0? Scan your live app. Or upload your package file.
Who is affected
Pillow(PyPI) <= 12.3.0
How to check
- Run
pip show Pillowin your project to see the installed version, including copies pulled in by other packages. - Or paste your lockfile into the checker: it lists every package with a known vulnerability, this one included.
- Or run
npx shippable-dev scan localhost:3000in your project folder. It reads your lockfile and flags this vulnerability along with every other known one.
How to fix
Copies a prompt for Claude Code, Cursor or Codex: it checks your lockfiles for this and upgrades it.
- Upgrade
Pillowto 13.0.0 or later, commit the lockfile and redeploy. - If a vulnerable version was live, check your logs and rotate the secrets that server could read.
The 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 …
Check your whole app
Dependencies are only half of it. Free and read-only: before you push, run npx shippable-dev scan localhost:3000 in your project folder, no account needed. For your live app, you prove it's yours with a one-line tag first.
References
- github.com/python-pillow/Pillow/security/advisories/GHSA-8g5g-8vmr-9782
- nvd.nist.gov/vuln/detail/CVE-2026-112403
- github.com/python-pillow/Pillow/commit/242a059aedc1935097d9aa1dc3f099750467ba75
- github.com/python-pillow/Pillow/releases/tag/13.0.0
- github.com/advisories/GHSA-8g5g-8vmr-9782
Merged from GitHub Advisory Database. Last updated 23 Oct 2026. All vulnerabilities · RSS · JSON