Skip to content

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

Moderate severity GitHub Reviewed Published Oct 17, 2026 in python-pillow/Pillow • Updated Oct 21, 2026

Package

pip Pillow (pip)

Affected versions

>= 9.1.0, < 13.0.0

Patched versions

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 < 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
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:

// 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->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.

The 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.

Impact

An 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.

Reproducing

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.

  1. 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.
  2. 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().
  3. Control: a one-entry PLTE. The only valid index is 0, and every output pixel is 0.
  4. Attack: the same PNG with a zero-length PLTE. quantize() returns an image without complaint, and its pixels contain about 80 distinct values.
  5. 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.

The 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.

Suggested fix

The 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:

--- a/src/libImaging/Convert.c
+++ b/src/libImaging/Convert.c
@@ -1159,6 +1159,10 @@ topalette(
 
     alpha = mode == IMAGING_MODE_PA;
 
+    if (palette != NULL && palette->size <= 0) {
+        return (Imaging)ImagingError_ValueError("empty palette");
+    }
+
     if (palette == NULL) {
         /* FIXME: make user configurable */
         if (imIn->bands == 1) {
--- a/src/libImaging/Palette.c
+++ b/src/libImaging/Palette.c
@@ -211,6 +211,7 @@ ImagingPaletteCacheUpdate(ImagingPalette palette, int r, int g, int b) {
 
     for (i = 0; i < BOXVOLUME; i++) {
         d[i] = (unsigned int)~0;
+        c[i] = 0;
     }
 
     for (i = 0; i < palette->size; i++) {

Applied 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.

The patch

Raise ValueError in topalette() for a palette with no entries and zero-initialise the cache slot array in ImagingPaletteCacheUpdate()

Applies to src/libImaging/Convert.c at the commit above.

diff --git a/Tests/test_image_quantize.py b/Tests/test_image_quantize.py
index 887628560..dcd1d901e 100644
--- a/Tests/test_image_quantize.py
+++ b/Tests/test_image_quantize.py
@@ -179,3 +179,13 @@ def test_small_palette() -> None:
     quantized_colors = im.getcolors()
     assert quantized_colors is not None
     assert len(quantized_colors) == 2
+
+
+def test_empty_palette() -> None:
+    im = hopper()
+
+    p = Image.new("P", (1, 1))
+    p.putpalette(b"")
+
+    with pytest.raises(ValueError):
+        im.quantize(palette=p)
diff --git a/src/libImaging/Convert.c b/src/libImaging/Convert.c
index 4a35a9a1f..7746d9255 100644
--- a/src/libImaging/Convert.c
+++ b/src/libImaging/Convert.c
@@ -1159,6 +1159,10 @@ topalette(
 
     alpha = mode == IMAGING_MODE_PA;
 
+    if (palette != NULL && palette->size <= 0) {
+        return (Imaging)ImagingError_ValueError("empty palette");
+    }
+
     if (palette == NULL) {
         /* FIXME: make user configurable */
         if (imIn->bands == 1) {
diff --git a/src/libImaging/Palette.c b/src/libImaging/Palette.c
index b2dacf656..ceb69d3dc 100644
--- a/src/libImaging/Palette.c
+++ b/src/libImaging/Palette.c
@@ -211,6 +211,7 @@ ImagingPaletteCacheUpdate(ImagingPalette palette, int r, int g, int b) {
 
     for (i = 0; i < BOXVOLUME; i++) {
         d[i] = (unsigned int)~0;
+        c[i] = 0;
     }
 
     for (i = 0; i < palette->size; i++) {

The reproducer

Reproducer — repro, 2,975 bytes (click to open)

Its 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.

#!/usr/bin/env python3
# Pillow: Image.quantize(palette=<P image with an empty palette>) returns
# uninitialised C stack bytes as pixel values.
#
# ImagingPaletteCacheUpdate() (src/libImaging/Palette.c) only writes its local
# UINT8 c[512] inside `for (i = 0; i < palette->size; i++)`. A PNG with a
# zero-length PLTE chunk gives palette->size == 0, so c[] is never written and
# its raw stack contents are copied into the colour cache, which topalette()
# (src/libImaging/Convert.c) then emits as the output pixels.
import io
import struct
import zlib

from PIL import Image


def png_with_plte(plte):
    """A 4x4 palette-mode (colour type 3) PNG whose PLTE chunk holds `plte`."""
    def chunk(tag, data):
        return (struct.pack(">I", len(data)) + tag + data
                + struct.pack(">I", zlib.crc32(tag + data)))
    ihdr = struct.pack(">IIBBBBB", 4, 4, 8, 3, 0, 0, 0)
    pixels = zlib.compress(b"\x00" * 4 * 5)
    return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"PLTE", plte)
            + chunk(b"IDAT", pixels) + chunk(b"IEND", b""))


def quantize_against(palette_png):
    """What an "apply this palette" feature does with an uploaded palette image."""
    pal = Image.open(io.BytesIO(palette_png))
    # 512 colours, one per slot of the colour-cache box r,g,b in [0,32), in slot
    # order: output pixel j is exactly c[j] of ImagingPaletteCacheUpdate().
    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)])
    return src.quantize(palette=pal, dither=Image.Dither.NONE).tobytes()


def stack_range():
    for line in open("/proc/self/maps"):
        if line.rstrip().endswith("[stack]"):
            lo, hi = line.split()[0].split("-")
            return int(lo, 16), int(hi, 16)


# Control: a one-entry palette -- every pixel must map to index 0.
control = quantize_against(png_with_plte(b"\x10\x20\x30"))
print(f"control (1-entry PLTE): distinct pixel values = {sorted(set(control))}")

# Attack: the same PNG with an empty PLTE chunk (80 bytes).
try:
    leaked = quantize_against(png_with_plte(b""))
except ValueError as e:
    print(f"attack (empty PLTE): rejected: {e}")
    print("OK")
    raise SystemExit

print(f"attack  (empty PLTE): {len(set(leaked))} distinct pixel values; first 64 bytes:")
print(f"  {leaked[:64].hex()}")

# Read the 512 pixel bytes as 64 little-endian words; any word that is an
# address inside this process's own stack can only have come from the stack.
lo, hi = stack_range()
print(f"this process's [stack] mapping: {lo:#x}-{hi:#x}")
stack_ptrs = 0
for i in range(0, 512, 8):
    word = int.from_bytes(leaked[i:i + 8], "little")
    if lo <= word < hi:
        stack_ptrs += 1
        print(f"  pixels {i:3d}..{i + 7:3d} = {word:#018x}  <- pointer into [stack]")

print(f"{stack_ptrs} of 64 words of the output image are stack addresses")
print("BUG" if set(control) == {0} and stack_ptrs >= 2 else "OK")

References

@radarhere radarhere published to python-pillow/Pillow Oct 17, 2026
Published by the National Vulnerability Database Oct 19, 2026
Published to the GitHub Advisory Database Oct 21, 2026
Reviewed Oct 21, 2026
Last updated Oct 21, 2026

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v4 base metrics

Exploitability Metrics
Attack Vector Network
Attack Complexity Low
Attack Requirements None
Privileges Required None
User interaction None
Vulnerable System Impact Metrics
Confidentiality Low
Integrity None
Availability None
Subsequent System Impact Metrics
Confidentiality None
Integrity None
Availability None

CVSS v4 base metrics

Exploitability Metrics
Attack Vector: This metric reflects the context by which vulnerability exploitation is possible. This metric value (and consequently the resulting severity) will be larger the more remote (logically, and physically) an attacker can be in order to exploit the vulnerable system. The assumption is that the number of potential attackers for a vulnerability that could be exploited from across a network is larger than the number of potential attackers that could exploit a vulnerability requiring physical access to a device, and therefore warrants a greater severity.
Attack Complexity: This metric captures measurable actions that must be taken by the attacker to actively evade or circumvent existing built-in security-enhancing conditions in order to obtain a working exploit. These are conditions whose primary purpose is to increase security and/or increase exploit engineering complexity. A vulnerability exploitable without a target-specific variable has a lower complexity than a vulnerability that would require non-trivial customization. This metric is meant to capture security mechanisms utilized by the vulnerable system.
Attack Requirements: This metric captures the prerequisite deployment and execution conditions or variables of the vulnerable system that enable the attack. These differ from security-enhancing techniques/technologies (ref Attack Complexity) as the primary purpose of these conditions is not to explicitly mitigate attacks, but rather, emerge naturally as a consequence of the deployment and execution of the vulnerable system.
Privileges Required: This metric describes the level of privileges an attacker must possess prior to successfully exploiting the vulnerability. The method by which the attacker obtains privileged credentials prior to the attack (e.g., free trial accounts), is outside the scope of this metric. Generally, self-service provisioned accounts do not constitute a privilege requirement if the attacker can grant themselves privileges as part of the attack.
User interaction: This metric captures the requirement for a human user, other than the attacker, to participate in the successful compromise of the vulnerable system. This metric determines whether the vulnerability can be exploited solely at the will of the attacker, or whether a separate user (or user-initiated process) must participate in some manner.
Vulnerable System Impact Metrics
Confidentiality: This metric measures the impact to the confidentiality of the information managed by the VULNERABLE SYSTEM due to a successfully exploited vulnerability. Confidentiality refers to limiting information access and disclosure to only authorized users, as well as preventing access by, or disclosure to, unauthorized ones.
Integrity: This metric measures the impact to integrity of a successfully exploited vulnerability. Integrity refers to the trustworthiness and veracity of information. Integrity of the VULNERABLE SYSTEM is impacted when an attacker makes unauthorized modification of system data. Integrity is also impacted when a system user can repudiate critical actions taken in the context of the system (e.g. due to insufficient logging).
Availability: This metric measures the impact to the availability of the VULNERABLE SYSTEM resulting from a successfully exploited vulnerability. While the Confidentiality and Integrity impact metrics apply to the loss of confidentiality or integrity of data (e.g., information, files) used by the system, this metric refers to the loss of availability of the impacted system itself, such as a networked service (e.g., web, database, email). Since availability refers to the accessibility of information resources, attacks that consume network bandwidth, processor cycles, or disk space all impact the availability of a system.
Subsequent System Impact Metrics
Confidentiality: This metric measures the impact to the confidentiality of the information managed by the SUBSEQUENT SYSTEM due to a successfully exploited vulnerability. Confidentiality refers to limiting information access and disclosure to only authorized users, as well as preventing access by, or disclosure to, unauthorized ones.
Integrity: This metric measures the impact to integrity of a successfully exploited vulnerability. Integrity refers to the trustworthiness and veracity of information. Integrity of the SUBSEQUENT SYSTEM is impacted when an attacker makes unauthorized modification of system data. Integrity is also impacted when a system user can repudiate critical actions taken in the context of the system (e.g. due to insufficient logging).
Availability: This metric measures the impact to the availability of the SUBSEQUENT SYSTEM resulting from a successfully exploited vulnerability. While the Confidentiality and Integrity impact metrics apply to the loss of confidentiality or integrity of data (e.g., information, files) used by the system, this metric refers to the loss of availability of the impacted system itself, such as a networked service (e.g., web, database, email). Since availability refers to the accessibility of information resources, attacks that consume network bandwidth, processor cycles, or disk space all impact the availability of a system.
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

EPSS score

Exploit Prediction Scoring System (EPSS)

This score estimates the probability of this vulnerability being exploited within the next 30 days. Data provided by FIRST.
(21st percentile)

Weaknesses

Use of Uninitialized Variable

The code uses a variable that has not been initialized, leading to unpredictable or unintended results. Learn more on MITRE.

CVE ID

CVE-2026-112898

GHSA ID

GHSA-rfgq-q3w2-pmv2

Source code

Loading Checking history
See something to contribute? Suggest improvements for this vulnerability.