Pillow: Infinite loop in PdfParser.rewrite_pages on page tree Parent cycle
CVE-2026-112406 · GHSA-f2g6-542v-9j58 · published 21 Oct 2026
How serious is it
- Severity: high, CVSS score 8.7 out of 10.
- No exploit prediction (EPSS) score yet.
- Not on the CISA list of exploited vulnerabilities.
- No public exploit in Exploit-DB.
Is your app affected by CVE-2026-112406?
Paste this prompt into Claude Code, Cursor or any AI coding tool: it runs a free Shippable scan of your project and fixes what it finds. Built with Lovable, Bolt or v0? Scan your live app. Or upload your package file.
Who is affected
Pillow(PyPI) >= 5.1.0, < 13.0.0
How to check
- Run
pip show Pillowin your project to see the installed version, including copies pulled in by other packages. - Or paste your lockfile into the checker: it lists every package with a known vulnerability, this one included.
- Or run
npx shippable-dev scan localhost:3000in your project folder. It reads your lockfile and flags this vulnerability along with every other known one.
How to fix
Copies a prompt for Claude Code, Cursor or Codex: it checks your lockfiles for this and upgrades it.
- Upgrade
Pillowto 13.0.0 or later, commit the lockfile and redeploy. - If a vulnerable version was live, check your logs and rotate the secrets that server could read.
The advisory
```
============================================================================
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:
```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 exac…
Check your whole app
Dependencies are only half of it. Free and read-only: before you push, run npx shippable-dev scan localhost:3000 in your project folder, no account needed. For your live app, you prove it's yours with a one-line tag first.
References
- github.com/python-pillow/Pillow/security/advisories/GHSA-f2g6-542v-9j58
- nvd.nist.gov/vuln/detail/CVE-2026-112406
- github.com/python-pillow/Pillow/commit/b3ebf20c694f2f12537a629c30229a1d5a53f777
- github.com/python-pillow/Pillow/releases/tag/13.0.0
- github.com/advisories/GHSA-f2g6-542v-9j58
- github.com/python-pillow/Pillow
Merged from GitHub Advisory Database, OSV. Last updated 25 Oct 2026. All vulnerabilities · RSS · JSON