---
id: "CVE-2026-112406"
kind: "vulnerability"
aliases: ["CVE-2026-112406", "GHSA-f2g6-542v-9j58"]
title: "Pillow: Infinite loop in PdfParser.rewrite_pages on page tree Parent cycle"
severity: "high"
cvss: 8.7
epss: 0.0029100000392645597
exploited: false
status: "open"
category: "package"
products: [{"name": "Pillow", "fixed": ["13.0.0"], "affected": ">= 5.1.0, < 13.0.0", "ecosystem": "pypi"}]
ecosystems: ["pypi"]
tags: ["batch-converters", "cwe-835", "package", "pillow", "python", "web-workers"]
not_affected_if: ["image-open-only", "managed-hosting-without-user-pdfs", "no-pdf-append"]
published_at: "2026-10-19T22:17:08+00:00"
first_seen_at: "2026-10-23T19:11:32+00:00"
updated_at: "2026-10-23T21:57:19+00:00"
sources: [{"source": "ghsa", "url": "https://github.com/advisories/GHSA-f2g6-542v-9j58", "first_seen_at": "2026-10-23T19:11:32+00:00"}, {"source": "nvd", "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112406", "first_seen_at": "2026-10-23T21:57:19+00:00"}]
---

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

Pillow'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, check and fix written by a model from the sources; labelled as such._

## Affected

- pypi:Pillow affected >= 5.1.0, < 13.0.0 fixed 13.0.0

## How to check

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

## Fix

Upgrade Pillow to 13.0.0 or later.
Until then, do not append to PDFs you did not generate, or enforce a process timeout around the save call.

## Timeline

- 2026-10-23T19:11:32+00:00 ghsa https://github.com/advisories/GHSA-f2g6-542v-9j58
- 2026-10-23T21:57:19+00:00 nvd https://nvd.nist.gov/vuln/detail/CVE-2026-112406

## From the sources

```
============================================================================
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 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 `<</Type /Pages /Kids [3 0 R] /Count 1>>`. This is a normal file.
- `cyclic` has `<</Type /Pages /Kids [3 0 R] /Count 

---

_Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai_
