GitHub Advisoryadvisory · PyPI

GHSA-rfgq-q3w2-pmv2 Pillow: Image.quantize() with empty palette leaks uninitialized stack memory

MEDIUM Published 2026-10-21 Modified 2026-10-21 Official page ↗
Aliases
CVE-2026-112898
Ecosystem
PyPI
Packages
Pillow
Severity
Moderate
CVSS vector
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N
Weaknesses
CWE-457
Reviewed
2026-10-21

Affected packages

• PyPI Pillow: >= 9.1.0, < 13.0.0 → fixed in 13.0.0

Description

``` ============================================================================ 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 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=, 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 size; i++) { /* size == 0: body never runs */ if (dmin[i] palette` is a fixed 1024-byte array, so the subsequent `palette->palet…

Related

Alias · CVE (1)

Weakness · CWE (1)

  • CWE-457 Use of Uninitialized Variable weakness

References