Live › vulnerability › CVE-2026-112898
Go to dashboardPillow'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 written from the sources below. Check the originals before acting.
First seen by InfraStack 2026-10-23 19:11 UTC, 3h before NVD. All times on this page are UTC.
| Product | Ecosystem | Affected | Fixed in |
|---|---|---|---|
| Pillow | pypi | >= 9.1.0, < 13.0.0 | 13.0.0 |
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
13.0.0 or later (pip install -U "pillow>=13.0.0")Each row is the first time that source carried this signal. The analyst’s rows are its own findings.
============================================================================
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.
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); without a subsequent yields one as well. Both dither modes ( and the default Floyd-Steinberg) are affected for sources (an 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
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 only copies the bytes, so the first is flagged where Python first branches on the pixel data; with the default Floyd-Steinberg dither it is flagged inside (inlined into ), where the cached index is used to address .
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:
// 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:
// 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:
// 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
This page as .md · .json · in your agent: get_signal("CVE-2026-112898")
GitHub Advisory Database, CC-BY 4.0 · NVD data is provided by NIST and is public domain. Posts quoted belong to their authors.
Image.new("P", size)putpalette()Dither.NONERGBLtopaletteuse of uninitialised valuetopaletteImagingConvertpalette->palette