GitHub Advisoryadvisory · PyPI

GHSA-f2g6-542v-9j58 Pillow: Infinite loop in PdfParser.rewrite_pages on page tree Parent cycle

HIGH Published 2026-10-21 Modified 2026-10-21 Official page ↗
Aliases
CVE-2026-112406
Ecosystem
PyPI
Packages
Pillow
Severity
High
CVSS vector
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N
Weaknesses
CWE-835
Reviewed
2026-10-21

Affected packages

• PyPI Pillow: >= 5.1.0, < 13.0.0 → fixed in 13.0.0

Description

``` ============================================================================ 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 > endobj 2 0 obj > endobj 3 0 obj > endobj xref 0 4 0000000000 65535 f 0000000009 00000 n 0000000056 00000 n 0000000125 00000 n trailer > 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: ```python 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: • `control` has `>`. This is a normal file. • `cyclic` has `<</Type /Pages /Kids [3 0 R] /Count 1 /Parent 2…

Related

Alias · CVE (1)

Weakness · CWE (1)

  • CWE-835 Loop with Unreachable Exit Condition weakness

References