---
id: "CVE-2026-112898"
kind: "vulnerability"
aliases: ["CVE-2026-112898", "GHSA-rfgq-q3w2-pmv2"]
title: "Pillow: Image.quantize() with empty palette leaks uninitialized stack memory"
severity: "medium"
cvss: 6.9
epss: 0.0029100000392645597
exploited: false
status: "open"
category: "package"
products: [{"name": "Pillow", "fixed": ["13.0.0"], "affected": ">= 9.1.0, < 13.0.0", "ecosystem": "pypi"}]
ecosystems: ["pypi"]
tags: ["cwe-457", "file-upload", "image-processing-apps", "package", "pillow", "python"]
not_affected_if: ["fixed-version-13.0.0-or-later", "no-user-supplied-palette", "palette-image-application-owned"]
published_at: "2026-10-19T22:17:10+00:00"
first_seen_at: "2026-10-23T19:11:32+00:00"
updated_at: "2026-10-23T21:57:19+00:00"
sources: [{"source": "ghsa", "url": "https://github.com/advisories/GHSA-rfgq-q3w2-pmv2", "first_seen_at": "2026-10-23T19:11:32+00:00"}, {"source": "nvd", "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112898", "first_seen_at": "2026-10-23T21:57:19+00:00"}]
---

# Pillow: Image.quantize() with empty palette leaks uninitialized stack memory

Pillow's Image.quantize(palette=...) with a palette image that has zero entries returns up to 512 bytes of the server's uninitialized stack memory as output pixel values. An attacker who controls the palette image (an 80-byte PNG with an empty PLTE chunk, for example) can read stack addresses and the stack-protector canary from the worker process.

_Summary, check and fix written by a model from the sources; labelled as such._

## Affected

- pypi:Pillow affected >= 9.1.0, < 13.0.0 fixed 13.0.0

## How to check

pip show pillow or python -c "import PIL; print(PIL.__version__)" and check whether it is >= 9.1.0 and < 13.0.0
grep -rn "quantize(" over app code to find calls that pass a user-supplied palette= image

## Fix

Upgrade Pillow to 13.0.0 or later (pip install -U "pillow>=13.0.0").
Until then, do not pass user-uploaded images as the palette argument to quantize(); use an application-owned palette.

## Timeline

- 2026-10-23T19:11:32+00:00 ghsa https://github.com/advisories/GHSA-rfgq-q3w2-pmv2
- 2026-10-23T21:57:19+00:00 nvd https://nvd.nist.gov/vuln/detail/CVE-2026-112898

## From the sources

```
============================================================================
Image.quantize() with empty palette leaks uninitialized stack memory
Location: src/libImaging/Palette.c:216  in ImagingPaletteCacheUpdate()
Commit:   a707c6b21c313880d0771e54965d142dd42358b6
============================================================================
```

When an application lets a user supply the palette image for Pillow's `Image.quantize(palette=...)`, the user can get back 512 bytes of the server process's stack memory in the pixels of the returned image. An 80-byte PNG with an empty palette is enough, and the leaked bytes survive saving to PNG or GIF. In the runs examined, those bytes included memory addresses inside the stack, heap, libc, libpython and Pillow's own C module, along with the stack-protector canary.

The bug is in `ImagingPaletteCacheUpdate()` in `src/libImaging/Palette.c`. It fills a local `UINT8 c[512]` only inside loops bounded by `palette->size`. When the palette has zero entries, the array is never written, but it is still copied into the colour cache. `topalette()` in `src/libImaging/Convert.c` then emits the cached values as output pixel indices. `topalette()` rejects a `NULL` palette but not one with `size == 0`. Three inputs produce such a zero-entry `"P"` image: a PNG with a zero-length `PLTE` chunk opened with `Image.open()`, a colour-mapped TGA with colour-map length 0, or a bare `Image.new("P", ...)`. The attacker must control the *palette* image; the source image can be anything, and `RGB` sources are affected with either dither mode. There is no out-of-bounds access and no memory corruption. The consequence is a bounded stack disclosure that can reveal address layout and the canary of a long-lived worker, with no crash and no log entry. The attached reproducer quantizes a 512-colour strip against the empty-palette PNG and finds 8 of the 64 output words pointing into the process's own `[stack]` mapping, while a one-entry palette control yields all zeros. The behaviour was introduced in commit `54cb09d8b48c` (first released in 9.1.0). The `Image.new("P", ...)` route exists since 10.1.0.

## Details

`ImagingPaletteCacheUpdate()` (`src/libImaging/Palette.c:154`) computes, for one 8×8×8 box of the colour cache, the nearest palette index for each of the 512 slots into a local `UINT8 c[512]` and then copies `c[]` into the cache. `c[]` is only ever written inside `for (i = 0; i < palette->size; i++)`. When the palette has zero entries nothing writes it, the untouched stack bytes are copied into the cache, and `topalette()` (`src/libImaging/Convert.c:1145`) emits them as the output pixels of `Image.quantize(palette=...)`. The root cause is that `topalette()` only rejects a `NULL` palette, never one with `palette->size == 0`, while `ImagingPaletteCacheUpdate()` silently assumes `size >= 1`. A zero-entry `"P"` image is what `Image.open()` produces for a PNG whose `PLTE` chunk has length 0 (or a colour-mapped TGA with colour-map length 0); `Image.new("P", size)` without a subsequent `putpalette()` yields one as well. Both dither modes (`Dither.NONE` and the default Floyd-Steinberg) are affected for `RGB` sources (an `L` source takes the grayscale copy path and never consults the cache).

Showing it with the installed build, no special flags. The 80-byte PNG below is 4×4, bit depth 8, colour type 3, with a zero-length `PLTE` and an all-zero `IDAT`:

```
89504e470d0a1a0a 0000000d49484452 0000000400000004 0803000000 9e2f6e4c
00000000 504c5445 4ba88955
0000000b 49444154 789c6360c0040000140001 7dfe1eee
00000000 49454e44 ae426082
```

```python
pal = Image.open(io.BytesIO(png)); pal.load()      # pal.mode == "P", pal.im.getpalette("RGB") == b""
src = Image.new("RGB", (512, 1))
src.putdata([(r*4, g*4, b*4) for r in range(8) for g in range(8) for b in range(8)])
out = src.quantize(palette=pal, dither=Image.Dither.NONE)
out.tobytes()        # 512 bytes, different in every process
```

The 512 source colours are chosen so that each pixel lands in a distinct slot of the cache box for `r,g,b ∈ [0,32)`, in slot order, so `out.tobytes()` is exactly `c[0..511]`. Read as 64 little-endian 8-byte words and resolved against the same process's `/proc/self/maps`, a typical run gives 43 words that are addresses inside the process's own mappings — 8 in `[stack]`, several in `[heap]`, `libc.so.6`, `libpython3.12.so` and `_imaging.cpython-312-x86_64-linux-gnu.so` — and 3 words equal to the thread's stack-protector canary (read from `fs:0x28` for comparison). The same PNG with a 3-byte `PLTE` (one entry) gives 512 zero bytes in every run. `out.save(buf, "PNG")` succeeds, so the bytes leave the process in an ordinary image. Under valgrind (run against a `strip --strip-debug` copy of `_imaging.so` or a gcc build, since valgrind 3.19 cannot read clang's DWARF5) the report carries the origin `Uninitialised value was created by a stack allocation at ImagingPaletteCacheUpdate`. With `Dither.NONE` `topalette` only copies the bytes, so the first `use of uninitialised value` is flagged where Python first branches on the pixel data; with the default Floyd-Steinberg dither it is flagged inside `topalette` (inlined into `ImagingConvert`), where the cached index is used to address `palette->palette`.

### Code path

File → empty C palette. `PngImagePlugin.chunk_PLTE` (`src/PIL/PngImagePlugin.py:499-505`) stores the chunk payload whatever its length, `_open` wraps it as `ImagePalette.raw("RGB", b"")` (`:836-838`), and `Image.load()` realises it with `self.im.putpalette("RGB", "RGB", b"")` (`src/PIL/Image.py:989-990`). `_putpalette` (`src/_imaging.c:1850-1868`) rejects only more than 256 entries and sets `palette->size = palettesize * 8 / bits`, i.e. 0. `TgaImagePlugin._open` (`src/PIL/TgaImagePlugin.py:121-135`) builds the same empty palette from a colour-mapped TGA whose colour-map-length field is 0.

API → sink. `Image.quantize(palette=pal)` (`src/PIL/Image.py:1329-1338`) checks only `palette.mode == "P"` and calls `self.im.convert("P", dither, palette.im)` → `ImagingConvert` → `topalette(..., inpalette=<that palette>, dither)`. `topalette` tests the palette for `NULL` (`Convert.c:1162`, `:1178`) but not for `size == 0`, allocates the cache (every slot `0x100` = "not computed"), and for each source pixel:

```c
// src/libImaging/Convert.c:1319-1327  (non-dither path; the dither path at 1252-1265 is the same)
                    cache = &ImagingPaletteCache(palette, r, g, b);
                    if (cache[0] == 0x100) {
                        ImagingPaletteCacheUpdate(palette, r, g, b);
                    }
                    …
                        out[x] = (UINT8)cache[0];
```

Inside `ImagingPaletteCacheUpdate` the only store to `c[]` is in step 2, and both step 1 and step 2 iterate over palette entries:

```c
// src/libImaging/Palette.c:160-161
    unsigned int d[BOXVOLUME];
    UINT8 c[BOXVOLUME];
…
// src/libImaging/Palette.c:212-221, 240-242
    for (i = 0; i < BOXVOLUME; i++) {
        d[i] = (unsigned int)~0;          /* d[] is initialised, c[] is not */
    }
    for (i = 0; i < palette->size; i++) { /* size == 0: body never runs */
        if (dmin[i] <= dmax) {
            …
                        if ((unsigned int)bd < d[j]) {
                            d[j] = bd;
                            c[j] = (UINT8)i;
                        }
```

For any non-empty palette every slot is written: the entry that produced `dmax` passes `dmin[i] <= dmax`, and because `d[j]` starts at `~0` the first visiting entry always stores into `c[j]`. With `size == 0` the loop is skipped entirely and step 3 publishes the raw stack array:

```c
// src/libImaging/Palette.c:262-266
    j = 0;
    for (r = r0; r < r1; r += 4) {
        for (g = g0; g < g1; g += 4) {
            for (b = b0; b < b1; b += 4) {
                ImagingPaletteCache(palette, r, g, b) = c[j++];
```

`cache[0]` is then ≤ 255 and `palette->palette` is a fixed 1024-byte array, so the subsequent `palette

---

_Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai_
