GHSA-jg86-v3jv-hrpm: Pip/Pillow vulnerability

Published Oct 21, 2026
·
Updated

============================================================================ Quadratic-time DoS in PDF saveall (PdfParser.nextobjectid) Location: src/PIL/PdfParser.py:629 in nextobjectid() Commit: a707c6b21c313880d0771e54965d142dd42358b6 ============================================================================

Pillow's PDF writer takes time proportional to the square of the number of frames when it saves a multi-frame image as a multi-page PDF. Anyone who can get an application to convert their uploaded image this way can tie up a worker at full CPU for minutes with a small file. A 240 KB animated image takes about 70 seconds, a 720 KB one an estimated 10 minutes, and a 125-byte animated PNG that merely declares a large frame count triggers the same slowdown with no upper bound short of 2³¹ frames. Memory stays under 100 MB and the decompression-bomb limit never applies.

The cause is PdfParser.nextobjectid() in src/PIL/PdfParser.py, which finds the next free id by computing max(self.xreftable.keys()). XrefTable.keys() rebuilds three sets over every id reserved so far on each call. PdfImagePlugin.save() calls it three times (four for palette images with transparency) per frame, for nframes frames, before any page is written. That makes the id-reservation loop alone O(N²). Because nframes is taken from the file before any frame is seeked, APNG's acTL count drives the loop even when the frames don't exist. In that case the save raises OSError only after the full delay.

The precondition is that the application calls im.save(..., "PDF", saveall=True) on the attacker's file; Image.open() and load() alone do not reach this code. The attached reproducer shows 4× the frames costing about 11× the CPU for a valid GIF and about 16× for a fixed 125-byte APNG. The behaviour was introduced in 6207b44ab1ff (2018-01-18) and first shipped in Pillow 5.1.0. A small patch that tracks the highest id in XrefTable makes the save linear.

Details

Saving a multi-frame image as a multi-page PDF (im.save(fp, "PDF", saveall=True)) costs CPU time proportional to the square of the frame count, independent of pixel content, because every object id the PDF writer reserves is found by rebuilding and scanning the set of all ids reserved so far.

A 1×1-pixel animated GIF made of N identical 15-byte frames (built by the reproducer) gives, on a 2-CPU container with the stock build at this commit (absolute timings vary by machine; the growth rate does not):

| frames | GIF size | im.save(BytesIO(), "PDF", saveall=True) | peak RSS | |---|---|---|---| | 2 000 | 30 KB | 1.1 s | | | 8 000 | 120 KB | 14.7 s | | | 16 000 | 240 KB | 70 s | 60 MB | | 32 000 | 480 KB | 262 s | 95 MB | | 48 000 | 720 KB | still running at 120 s (extrapolated ≈ 10 min) | |

Each doubling of the input roughly quadruples the time. cProfile of the 8 000-frame save attributes 13.5 s of 17.6 s to 24 003 calls of PdfParser.nextobjectid (8.9 s inside XrefTable.keys, 3.9 s inside max); encoding and writing the 8 000 pages is the remaining ~4 s and scales linearly.

The per-page id reservation loop runs before any page is written:

python src/PIL/PdfImagePlugin.py:247-259 (save) for im in ims: imnumberofpages = 1 if saveall: imnumberofpages = getattr(im, "nframes", 1) … for i in range(imnumberofpages): imagerefs.append(existingpdf.nextobjectid(0)) if im.mode == "P" and "transparency" in im.info: imagerefs.append(existingpdf.nextobjectid(0))

pagerefs.append(existingpdf.nextobjectid(0)) contentsrefs.append(existingpdf.nextobjectid(0))

nframes comes straight from the file (for GIF it is the number of image blocks; the GIF plugin counts them by walking block headers, which is cheap). The frames do not even have to exist: the loop runs on nframes before any frame is seeked, and for APNG nframes is the numframes value declared in the acTL chunk, rejected only if it is 0 or above 0x80000000 (src/PIL/PngImagePlugin.py:716-720). A 125-byte APNG declaring 8 000 frames therefore costs the same ~10–13 s as the 120 KB GIF, and more without bound as the declared count grows. Each of the 3 (or 4, for P images with transparency) calls per frame lands here:

python src/PIL/PdfParser.py:626-634 def nextobjectid(self, offset: int | None = None) -> IndirectReference: try: # TODO: support reuse of deleted objects reference = IndirectReference(max(self.xreftable.keys()) + 1, 0) except ValueError: reference = IndirectReference(1, 0) if offset is not None: self.xreftable[reference.objectid] = (offset, 0) return reference

src/PIL/PdfParser.py:175-178 (XrefTable) def keys(self) -> set[int]: return ( set(self.existingentries.keys()) - set(self.deletedentries.keys()) ) | set(self.newentries.keys())

save passes offset=0, so every reserved id is inserted into xreftable.newentries. The k-th call therefore materialises three sets covering all k−1 previous ids, computes a difference and a union, and then max() scans the result — O(k) work to produce the integer k. Summed over 3N calls this is O(N²): for 48 000 frames, 144 002 calls over sets growing to 144 002 elements, on the order of 10¹⁰ set-element operations, all spent before writeimage is reached for the first frame. Nothing on this path consults MAXIMAGEPIXELS (the frames are 1×1) and nothing raises; for the GIF the save eventually completes and produces a valid PDF, and for the declared-only APNG it raises OSError only once the id loop has finished and the first missing frame is seeked.

This reservation scheme was introduced in 6207b44ab1ff ("issue #2959: support appending to existing PDFs", 2018-01-18) and first shipped in Pillow 5.1.0; before it, the writer numbered objects arithmetically.

For contrast, writeobj(ref, …) with an already-reserved ref (src/PIL/PdfParser.py:548-555) only does a dict store, which is why the page-writing phase that follows is linear; the super-linear cost is entirely in how the next free id is computed.

Impact

The precondition is that the application saves the attacker's image as PDF with saveall=True; Image.open() and load() alone do not reach this code. An application that converts uploaded images to PDF this way — the documented way to get one page per frame from animated GIF/APNG/WebP or multi-page TIFF input — can have a worker held at 100 % CPU for about 10 minutes by a 720 KB GIF, about 4½ minutes by a 480 KB one, with the cost continuing to grow quadratically with upload size. With APNG the file size does not matter at all: a 125-byte file declaring N frames in acTL drives the same quadratic loop, up to the 2³¹ cap. The attacker controls only the file bytes; no unusual dimensions, modes or options are needed, memory use stays under 100 MB so memory limits do not intervene, and the decompression-bomb guard is not applicable. The consequence is denial of service of the process performing the conversion. Single-image PDF saves, and saveall saves of images with tens or hundreds of frames, are unaffected in practice.

This is the same family as earlier accidentally-quadratic parsing issues fixed in Pillow (e.g. CVE-2021-28677, EPS readline), but on the PDF writer side, reached only through a save rather than on open, and driven by frame count rather than by line structure.

Reproducing

repro is a single Python script. Run it with the Python that has Pillow installed (./repro). It uses under 100 MB of RAM and writes no files.

Input. Two kinds of file are built in memory, each with 2 000 and 8 000 frames:

- gif(n): a valid animated GIF — a GIF89a header with a 1×1 logical screen and a 2-colour global palette, then n copies of one 15-byte frame (a 1×1 image descriptor plus the LZW data for one pixel), then the trailer (30 and 120 KB). The only unusual thing about it is how many frames it has. - apng(n): a 125-byte APNG — a 1×1 greyscale IHDR, an acTL chunk declaring n frames, one fcTL, one IDAT and IEND. Only the first frame exists; the other n − 1 are merely declared.

Action. Each file goes through what an upload-to-PDF converter would do: Image.open(BytesIO(data)), then im.save(BytesIO(), "PDF", saveall=True), timed with time.processtime(). For the APNG the save raises OSError once the writer tries to seek to the second frame; that happens after the id-reservation loop has run for all n declared frames, so the exception is caught and the CPU time up to it is recorded.

Expected output (stock build at this commit):

GIF frames= 2000 file= 30020 B save CPU= 1.03s result=ok GIF frames= 8000 file=120020 B save CPU= 11.68s result=ok GIF: 4x frames -> 11.3x CPU (linear ~4x, quadratic ~16x) APNG frames= 2000 file= 125 B save CPU= 0.65s result=OSError(Truncated File Read) APNG frames= 8000 file= 125 B save CPU= 10.48s result=OSError(Truncated File Read) APNG: 4x frames -> 16.1x CPU (linear ~4x, quadratic ~16x) BUG

A linear save would take about 4× as long for 4× the frames; here the GIF save grows ~11× and the APNG save ~16×, the latter with the file size fixed at 125 bytes. The script prints BUG when the save CPU for either format grows at least 8× for 4× the frames (twice the linear expectation), otherwise OK. Extending the same curve gives the figures in the table above: about 70 s at 16 000 frames (240 KB GIF), and about 10 minutes for a 720 KB file.

With the attached patch applied, the GIF save takes 0.32 s / 1.20 s (3.7×) and the APNG save 0.01 s / 0.03 s, and the script prints OK.

Suggested fix

Make nextobjectid() O(1) by tracking the highest object id ever stored in XrefTable instead of rebuilding the key set on every call. The attached patch does this in src/PIL/PdfParser.py:

diff --- a/src/PIL/PdfParser.py +++ b/src/PIL/PdfParser.py @@ class XrefTable: self.deletedentries = {0: 65536} # object ID => generation self.readingfinished = False + self.maxobjectid = 0 # highest object ID ever stored, deleted or not

def setitem(self, key: int, value: tuple[int, int]) -> None: if self.readingfinished: self.newentries[key] = value else: self.existingentries[key] = value + if key > self.maxobjectid: + self.maxobjectid = key if key in self.deletedentries: del self.deletedentries[key] @@ class PdfParser: def nextobjectid(self, offset: int | None = None) -> IndirectReference: - try: - # TODO: support reuse of deleted objects - reference = IndirectReference(max(self.xreftable.keys()) + 1, 0) - except ValueError: - reference = IndirectReference(1, 0) + # TODO: support reuse of deleted objects + reference = IndirectReference(self.xreftable.maxobjectid + 1, 0) if offset is not None: self.xreftable[reference.objectid] = (offset, 0) return reference

existingentries is only ever populated through setitem (from readxreftable / readprevtrailer), so the counter also covers ids read from an existing file in append=True mode. The one behavioural difference: when the highest-numbered existing object has just been deleted (delroot() / rewritepages() in append mode), the current code hands that id out again with generation 0, whereas the patch moves on to the next unused id — the more conservative choice for an xref table, and the one the existing TODO: support reuse of deleted objects comment points at. With the patch applied and Pillow rebuilt, the 16 000-frame save drops from ~70 s to ~2.5 s, fresh PDF output is unchanged, Tests/testfilepdf.py and Tests/testpdfparser.py pass, and the reproducer prints OK.

An alternative limited to the writer would be to reserve the 3 × numberofpages ids in PdfImagePlugin.save() arithmetically from a single nextobjectid() call, but fixing the allocator also covers writeobj(None, …) callers.

The patch does not address a separate, linear issue: PdfImagePlugin.save() still trusts nframes before any frame is decoded, so a tiny file whose format reports a declared-only frame count (APNG acTL up to 2³¹) still makes it loop 3 × nframes times before the first failing seek. Reserving ids per successfully seeked frame, or validating nframes in such plugins, would close that.

The patch

Track the highest object ID in XrefTable so PdfParser.nextobjectid() is O(1) instead of rebuilding and scanning the key set

Applies to src/PIL/PdfParser.py at the commit above.

diff diff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py index 672557190..82ebfb867 100644 --- a/src/PIL/PdfParser.py +++ b/src/PIL/PdfParser.py @@ -133,12 +133,15 @@ class XrefTable: ) # object ID => (offset, generation) self.deletedentries = {0: 65536} # object ID => generation self.readingfinished = False + self.maxobjectid = 0 # highest object ID ever stored, deleted or not def setitem(self, key: int, value: tuple[int, int]) -> None: if self.readingfinished: self.newentries[key] = value else: self.existingentries[key] = value + if key > self.maxobjectid: + self.maxobjectid = key if key in self.deletedentries: del self.deletedentries[key] @@ -624,11 +627,8 @@ class PdfParser: self.origpages = self.pages[:] def nextobjectid(self, offset: int | None = None) -> IndirectReference: - try: - # TODO: support reuse of deleted objects - reference = IndirectReference(max(self.xreftable.keys()) + 1, 0) - except ValueError: - reference = IndirectReference(1, 0) + # TODO: support reuse of deleted objects + reference = IndirectReference(self.xreftable.maxobjectid + 1, 0) if offset is not None: self.xreftable[reference.objectid] = (offset, 0) return reference

The reproducer

<details> <summary>Reproducer — <code>repro</code>, 1,791 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 PDF saveall is O(frames^2): PdfParser.nextobjectid() does max(XrefTable.keys()), and keys() rebuilds sets of every id reserved so far, 3 calls per frame. (a) valid 1x1 animated GIF, 15 bytes/frame; (b) 125-byte APNG whose acTL merely declares N frames - ids are reserved for all N before the first seek fails. import io, struct, time, zlib from PIL import Image

def gif(n): return (b"GIF89a\x01\x00\x01\x00\x80\x00\x00\x00\x00\x00\xff\xff\xff" + b"\x2c\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02\x44\x01\x00" n + b"\x3b")

def apng(n): def chunk(t, d): return struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d)) return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", 1, 1, 8, 0, 0, 0, 0)) + chunk(b"acTL", struct.pack(">II", n, 0)) + chunk(b"fcTL", struct.pack(">IIIIIHHBB", 0, 1, 1, 0, 0, 1, 1, 0, 0)) + chunk(b"IDAT", zlib.compress(b"\x00\x00")) + chunk(b"IEND", b""))

def savepdf(data): im = Image.open(io.BytesIO(data)) t = time.processtime() try: im.save(io.BytesIO(), "PDF", saveall=True) res = "ok" except OSError as e: # APNG: frames don't exist, fails after the id loop res = f"OSError({e})" return time.processtime() - t, res

bug = False for name, make in (("GIF", gif), ("APNG", apng)): t = {} for n in (2000, 8000): data = make(n) t[n], res = savepdf(data) print(f"{name:4} frames={n:5} file={len(data):6} B save CPU={t[n]:6.2f}s result={res}") growth = t[8000] / t[2000] print(f"{name}: 4x frames -> {growth:.1f}x CPU (linear ~4x, quadratic ~16x)") if growth >= 8: bug = True print("BUG" if bug else "OK") </details>

---

Affected Software

1 affected componentFixes available
pip/Pillow>=5.1.0<13.0.0
13.0.0

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade pip/Pillow to a version that resolves this vulnerability.

    Fixed in 13.0.0
  2. Compensating control

    Modify src/PIL/PdfParser.py so XrefTable tracks the highest object ID ever stored and PdfParser.next_object_id() allocates from that counter in O(1), instead of rebuilding and scanning XrefTable.keys() on every call.

  3. Compensating control

    In PDF save handling, reserve object IDs only for frames successfully sought, or validate the declared n_frames value in plugins such as APNG before looping, so a declared-only frame count cannot drive the reservation loop.

Event History

Oct 21, 2026
Advisory Published
via GitHub·09:11 PM
Data Sourced
via GitHub·09:11 PM
DescriptionWeaknessAffected Software

Frequently Asked Questions

1

Which deployments are exposed to attacker-controlled input?

Applications that accept uploaded images and convert them to multi-page PDFs using Pillow are exposed. An attacker needs to supply a multi-frame image that is processed through that conversion path.

2

Can this be triggered without a large upload or high memory use?

Yes. A 125-byte animated PNG that declares a large frame count can cause the slowdown, and memory remains below 100 MB. The decompression-bomb limit does not apply to this condition.

3

What operational impact has been observed?

The affected worker can be held at full CPU for minutes. Reported examples include about 70 seconds for a 240 KB animated image and an estimated 10 minutes for a 720 KB image; the declared frame count can reach up to 2³¹ frames.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203