KEVWire

Security intelligence, with context.

Response snapshot Oct 27, 2026, 00:10 UTC

Back to the wire
Permalink
VulnerabilityCVE-2026-112898

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

``` ============================================================================ Image.quantize() with empty palette leaks uninitialized stack memory Location: src/libImaging/Palette.

At a glance

Exploited
Not known
Severity
6.9 Medium (CVSS 4.0)
Chance of exploitation
0.4% in the next 30 days
Sources
1 source

What to do

GitHub lists first patched versions: Pillow: 13.0.0. Confirm the affected range and upgrade guidance in the exact advisory.

Read GitHub-reviewed advisory GHSA-rfgq-q3w2-pmv2 (opens a new tab)

What happened

``` ============================================================================ 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.

Continue reading

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

Affected package ranges reported by GitHub: Pillow: >= 9.1.0, < 13.0.0

Scores and how they were measured

CVSS base severity

6.9

Version 4.0As of

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

Measurement source (opens a new tab)

How bad it is if exploited. Not a prediction of exploitation or proof you are exposed.

EPSS probability

0.4%

Scored Percentile 32.4%

Model and measurement source (opens a new tab)

A model's estimate of exploitation in the next 30 days, not certainty or your own risk.

Related technology

Sources

  1. Primary source

    GitHub-reviewed advisory GHSA-rfgq-q3w2-pmv2 (opens a new tab)

    The public advisory supplies the description, affected package/version ranges, and any listed CVSS metric. GitHub review is not local review.

    Published Checked

  2. Primary source

    GitHub public advisory API page (opens a new tab)

    The API page supplied GHSA-rfgq-q3w2-pmv2, its actual identifiers, source dates, and structured metrics.

    Published Date not suppliedChecked

  3. Primary source

    GitHub public advisory API page (opens a new tab)

    The API page supplied GHSA-rfgq-q3w2-pmv2, its actual identifiers, source dates, and structured metrics.

    Published Date not suppliedChecked

  4. Primary source

    GitHub public advisory API page (opens a new tab)

    The API page supplied GHSA-rfgq-q3w2-pmv2, its actual identifiers, source dates, and structured metrics.

    Published Date not suppliedChecked

  5. Primary source

    GitHub public advisory API page (opens a new tab)

    The API page supplied GHSA-rfgq-q3w2-pmv2, its actual identifiers, source dates, and structured metrics.

    Published Date not suppliedChecked

  6. Primary source

    GitHub public advisory API page (opens a new tab)

    The API page supplied GHSA-rfgq-q3w2-pmv2, its actual identifiers, source dates, and structured metrics.

    Published Date not suppliedChecked

  7. Primary source

    GitHub public advisory API page (opens a new tab)

    The API page supplied GHSA-rfgq-q3w2-pmv2, its actual identifiers, source dates, and structured metrics.

    Published Date not suppliedChecked

  8. Primary source

    GitHub public advisory API page (opens a new tab)

    The API page supplied GHSA-rfgq-q3w2-pmv2, its actual identifiers, source dates, and structured metrics.

    Published Date not suppliedChecked

  9. Primary source

    GitHub public advisory API page (opens a new tab)

    The API page supplied GHSA-rfgq-q3w2-pmv2, its actual identifiers, source dates, and structured metrics.

    Published Date not suppliedChecked

  10. Primary source

    GitHub public advisory API page (opens a new tab)

    The API page supplied GHSA-rfgq-q3w2-pmv2, its actual identifiers, source dates, and structured metrics.

    Published Date not suppliedChecked

  11. Primary source

    GitHub public advisory API page (opens a new tab)

    The API page supplied GHSA-rfgq-q3w2-pmv2, its actual identifiers, source dates, and structured metrics.

    Published Date not suppliedChecked

  12. Primary source

    FIRST measured EPSS score (opens a new tab)

    Measured EPSS probability and percentile for CVE-2026-112898, scored 2026-10-26. This is a model estimate, not proof of exposure.

    Published Checked

Cite this record

For factual claims, cite the primary sources above; cite KEVWire for its compilation, dates, and classifications.

APA

KEVWire. (2026, October 21). Pillow: Image.quantize() with empty palette leaks uninitialized stack memory (CVE-2026-112898). https://kevwire.com/record/CVE-2026-112898

MLA

"Pillow: Image.quantize() with empty palette leaks uninitialized stack memory (CVE-2026-112898)." KEVWire, 21 Oct. 2026, kevwire.com/record/CVE-2026-112898.

Chicago

KEVWire. "Pillow: Image.quantize() with empty palette leaks uninitialized stack memory (CVE-2026-112898)." Last modified October 21, 2026. https://kevwire.com/record/CVE-2026-112898.

Evidence summary (plain text)

Pillow: Image.quantize() with empty palette leaks uninitialized stack memory (Oct 21, 2026).
```
============================================================================
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.
CVSS 6.9 (version 4.0; evidence as of Oct 21, 2026; https://github.com/advisories/GHSA-rfgq-q3w2-pmv2).
EPSS 0.4% for the following 30 days, scored Oct 26, 2026 (https://api.first.org/data/v1/epss?cve=CVE-2026-112898&date=2026-10-26).
Sources:
GitHub-reviewed advisory GHSA-rfgq-q3w2-pmv2: https://github.com/advisories/GHSA-rfgq-q3w2-pmv2
GitHub public advisory API page: https://api.github.com/advisories?type=reviewed&per_page=100&sort=updated&direction=desc&after=Y3Vyc29yOnYyOpK0MjAyNi0xMC0yMlQxNTo1Mjo1NlrOAAfxiA%3D%3D
GitHub public advisory API page: https://api.github.com/advisories?type=reviewed&per_page=100&sort=updated&direction=desc&after=Y3Vyc29yOnYyOpK0MjAyNi0xMC0yMlQxNDowNjoxNFrOAAfxZw%3D%3D
GitHub public advisory API page: https://api.github.com/advisories?type=reviewed&per_page=100&sort=updated&direction=desc&after=Y3Vyc29yOnYyOpK0MjAyNi0xMC0yMlQwODoyNzoyM1rOAAYp9A%3D%3D
GitHub public advisory API page: https://api.github.com/advisories?type=reviewed&per_page=100&sort=updated&direction=desc&after=Y3Vyc29yOnYyOpK0MjAyNi0xMC0yMVQyMTozNTo1OFrOAAfwCQ%3D%3D
GitHub public advisory API page: https://api.github.com/advisories?type=reviewed&per_page=100&sort=updated&direction=desc&after=Y3Vyc29yOnYyOpK0MjAyNi0xMC0yMVQyMTozMDoxM1rOAAfv2A%3D%3D
GitHub public advisory API page: https://api.github.com/advisories?type=reviewed&per_page=100&sort=updated&direction=desc&after=Y3Vyc29yOnYyOpK0MjAyNi0xMC0yMVQyMToyODowOVrOAAfv1g%3D%3D
GitHub public advisory API page: https://api.github.com/advisories?type=reviewed&per_page=100&sort=updated&direction=desc&after=Y3Vyc29yOnYyOpK0MjAyNi0xMC0yMVQyMToyNDoyMFrOAAfv1Q%3D%3D
GitHub public advisory API page: https://api.github.com/advisories?type=reviewed&per_page=100&sort=updated&direction=desc&after=Y3Vyc29yOnYyOpK0MjAyNi0xMC0yMVQyMDo0NTowN1rOAAfvzQ%3D%3D
GitHub public advisory API page: https://api.github.com/advisories?type=reviewed&per_page=100&sort=updated&direction=desc&after=Y3Vyc29yOnYyOpK0MjAyNi0xMC0yM1QxODozMDoyN1rOAAY_5Q%3D%3D
GitHub public advisory API page: https://api.github.com/advisories?type=reviewed&per_page=100&sort=updated&direction=desc&after=Y3Vyc29yOnYyOpK0MjAyNi0xMC0yM1QxODoyOTowNVrOAAfz8g%3D%3D
FIRST measured EPSS score: https://api.first.org/data/v1/epss?cve=CVE-2026-112898&date=2026-10-26
Record ID: CVE-2026-112898.
Record origin: Ingested record.
AI evidence: Not assessed.
Known exploitation: Not assessed.
Zero-day status: Not assessed. KEV membership alone does not establish zero-day status.
About this data

This record was ingested from a data source. Retrieved: Oct 26, 2026, 19:40 UTC. Ingestion is not an independent editorial investigation.

A CISA KEV listing documents known exploitation, not necessarily a zero-day; absence from the catalog does not prove a vulnerability has never been exploited.

Technology tags indicate possible relevance, not verified exposure. Confirm the affected product, version, and configuration against your own inventory; KEVWire does not scan your systems.

A personal reading checklist on this device, not verified remediation.

Record JSON

Your account

Email alerts

Email alerts are not available yet. To follow your stack today, use its RSS feed, which honors your filters, or connect an AI agent through the API.

Suggest an improvement

Ideas, missing sources, corrections, and bugs are all welcome. No account needed. Suggestions are reviewed by a person before anything changes.

Record details

Record detail