Live › vulnerability › CVE-2026-112406
Go to dashboardPillow's PDF append path (PdfParser.rewrite_pages) loops forever when an existing PDF's page tree has a /Parent cycle, so a few-hundred-byte file pins one CPU core. An attacker who controls the existing PDF bytes can hang any worker that calls save with append=True on it.
Summary written from the sources below. Check the originals before acting.
First seen by InfraStack 2026-10-23 19:11 UTC, 3h before NVD. All times on this page are UTC.
| Product | Ecosystem | Affected | Fixed in |
|---|---|---|---|
| Pillow | pypi | >= 5.1.0, < 13.0.0 | 13.0.0 |
pip show pillow (version >= 5.1.0 and < 13.0.0 is affected)
Grep application code for save(..."PDF", append=True) or save_all=True with append=True on user-supplied PDFs
13.0.0 or laterEach row is the first time that source carried this signal. The analyst’s rows are its own findings.
============================================================================
Infinite loop in PdfParser.rewrite_pages on page tree Parent cycle
Location: src/PIL/PdfParser.py:505 in PdfParser.rewrite_pages()
Commit: a707c6b21c313880d0771e54965d142dd42358b6
============================================================================
A PDF file of a few hundred bytes makes Pillow hang forever, holding one processor core at 100%, when an application tries to append an image to it. Anyone who can supply the existing PDF to an application that calls im.save(fp, "PDF", append=True) (or save_all=True, append=True) can tie up that worker indefinitely. Memory use stays flat, so only an external timeout stops it.
The bug is in PdfParser.rewrite_pages() in src/PIL/PdfParser.py (the while pages_tree_node_ref: loop at line 505). For each original page, it walks the /Parent chain upward and stops only when it reaches a node with no /Parent. It keeps no record of nodes it has already visited, so a loop in that chain never ends. The simplest case is a /Pages root that lists itself as its own /Parent; a longer cycle among page-tree nodes behaves the same way. The path is PdfImagePlugin._save() → write_catalog() → rewrite_pages(), and it is reached whether the save target is a file path or a BytesIO. Plain Image.open() pipelines are not affected, because Pillow only parses PDFs on the append path.
This is a sibling of CVE-2026-42310 (the cyclic trailer /Prev loop in the same parser). The processed_offsets guard added to read_prev_trailer() for that issue does not cover this walk. The loop was introduced in 24ecfe315ab5 (2018-03-03) and has been present in every release since 5.1.0.
The attached reproducer builds two in-memory PDFs and appends a 4×4 image to each. The normal file finishes in about 45 ms. The 333-byte self-parented file is still spinning inside rewrite_pages() when a 5-second watchdog interrupts it. Two alternative fixes are attached: one silently tolerates the cycle, and one rejects the file with an error.
PdfParser.rewrite_pages() walks the /Parent chain of every pre-existing page upward and stops only when a node has no /Parent. It keeps no record of visited nodes, so a page-tree node whose /Parent points at itself, or at any node below it, makes the walk spin forever.
The following 333-byte file is a valid one-page PDF except that object 2, the page-tree root, declares /Parent 2 0 R:
%PDF-1.4
1 0 obj
<</Type /Catalog /Pages 2 0 R>>
endobj
2 0 obj
<</Type /Pages /Kids [3 0 R] /Count 1 /Parent 2 0 R>>
endobj
3 0 obj
<</Type /Page /Parent 2 0 R /MediaBox [0 0 10 10]>>
endobj
xref
0 4
0000000000 65535 f
0000000009 00000 n
0000000056 00000 n
0000000125 00000 n
trailer
<</Size 4 /Root 1 0 R>>
startxref
192
%%EOF
Image.new("RGB", (4, 4)).save(io.BytesIO(pdf), "PDF", append=True) on these bytes never returns and holds one core at 100 % (the same happens when the target is a file path opened r+b). Interrupting it with SIGALRM after 5 s gives this traceback every time:
File "src/PIL/PdfImagePlugin.py", line 263, in _save
existing_pdf.write_catalog()
File "src/PIL/PdfParser.py", line 473, in write_catalog
self.rewrite_pages()
File "src/PIL/PdfParser.py", line 505, in rewrite_pages
while pages_tree_node_ref:
The same file without /Parent 2 0 R on object 2 is appended to in ~30 ms.
The loop:
# src/PIL/PdfParser.py:483-510
def rewrite_pages(self) -> None:
pages_tree_nodes_to_delete = []
for i, page_ref in enumerate(self.orig_pages):
page_info = self.cached_objects[page_ref]
del self.xref_table[page_ref.object_id]
pages_tree_nodes_to_delete.append(page_info[PdfName(b"Parent")])
…
# delete redundant Pages tree nodes from xref table
for pages_tree_node_ref in pages_tree_nodes_to_delete:
while pages_tree_node_ref:
pages_tree_node = self.cached_objects[pages_tree_node_ref]
if pages_tree_node_ref.object_id in self.xref_table:
del self.xref_table[pages_tree_node_ref.object_id]
pages_tree_node_ref = pages_tree_node.get(b"Parent", None)
self.orig_pages = []
How the input gets there: with append=True, PdfImagePlugin._save() opens the existing bytes with PdfParser.PdfParser(f=fp, …, mode="r+b") (src/PIL/PdfImagePlugin.py:199), whose constructor calls read_pdf_info(). That reads the catalog, reads the page-tree root through read_indirect() — which stores every object it reads in self.cached_objects (src/PIL/PdfParser.py:1077) — and flattens the tree with linearize_page_tree(), which read_indirect()s every /Kids entry. So after parsing, orig_pages == [3 0 R] and objects 2 and 3 are both in cached_objects. _save() then calls existing_pdf.write_catalog() → rewrite_pages().
For the file above, the first loop puts page 3's /Parent, 2 0 R, into pages_tree_nodes_to_delete. The inner while looks object 2 up in cached_objects, removes it from the xref table on the first pass, and sets pages_tree_node_ref to object 2's /Parent — which is 2 0 R again. Nothing in the loop body changes on subsequent iterations, so it never exits: cached_objects[2 0 R] is never evicted, and deleting an already-deleted id from XrefTable is silent — __delitem__ only records the id in deleted_entries, while __contains__ keeps answering from existing_entries. The loop performs no I/O and no allocation, so neither MemoryError nor the recursion limit intervenes. A longer cycle behaves identically as long as every node on it was reached through linearize_page_tree() and is therefore cached (e.g. root 2 → /Kids [4 0 R], node 4 → , and root 2 declaring ); a pointing at an object that was never read raises instead, which is harmless.
The parser already defends against exactly this shape of problem one level up: read_prev_trailer() (src/PIL/PdfParser.py:699-722) carries a processed_offsets list and raises PdfFormatError("trailer loop found") when a /Prev offset repeats — the fix for CVE-2026-42310. The /Parent walk in rewrite_pages() has no equivalent guard.
The unguarded walk was introduced in 24ecfe315ab5 ("issue #2959: fix wrong Parent of pre-existing Page objects when appending", 2018-03-03) and has been in every release since 5.1.0.
Any application that appends pages to a PDF it did not produce — im.save(fp, "PDF", append=True) or save_all=True, append=True on a user-supplied document — hangs indefinitely with one CPU core pinned, on an input of a few hundred bytes. The loop is pure Python doing two dict lookups per iteration, so memory stays flat and memory limits do not end it; only an external watchdog does. In a web worker or batch converter that is a denial of service of that worker per request. The attacker needs to control only the bytes of the existing PDF; no non-default Pillow setting is involved beyond the application choosing the append path. Pillow does not open PDFs for reading, so plain Image.open() pipelines are not affected.
This is a sibling of CVE-2026-42310 (infinite loop on a cyclic trailer /Prev chain in the same parser, same entry point): same class, different linked structure, and the guard added for that CVE does not cover it.
repro is a single Python script that runs against the installed Pillow (/src/src/PIL). It needs no build step and no companion files, and takes about 5 seconds.
The script builds two one-page PDFs in memory with make_pdf(). Each has a Catalog (object 1), a /Pages root (object 2) and a /Page (object 3), and each has a correct xref table. The only difference between them is in object 2:
control has <</Type /Pages /Kids [3 0 R] /Count 1>>. This is a normal file.cyclic has `<</Type /Pages /Kids [3 0 R] /CountThis page as .md · .json · in your agent: get_signal("CVE-2026-112406")
GitHub Advisory Database, CC-BY 4.0 · NVD data is provided by NIST and is public domain. Posts quoted belong to their authors.
/Kids [3 0 R] /Parent 2 0 R/Parent 4 0 R/ParentKeyError