high

CVE-2026-112406

PyPI · Pillow

Summary

Pillow: Infinite loop in PdfParser.rewrite_pages on page tree Parent cycle

Severity
high
EPSS
0.4% (p36)
CWE
CWE-835
Also known as
GHSA-f2g6-542v-9j58
Published
2026-10-21
Updated
2026-10-21

Advisory details

============================================================================
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.

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.

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:

References

Related advisories

Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.

Check my repo

Summarize with AI

ChatGPTClaudePerplexity

Sources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.