GHSA-5xm2-6676-qcp9: Pip/Pillow vulnerability

Published Oct 21, 2026
·
Updated

============================================================================ APNG background disposal allocates full canvas before decompression bomb check Location: src/PIL/PngImagePlugin.py:1000 in PngImageFile.seek() Commit: a707c6b21c313880d0771e54965d142dd42358b6 ============================================================================

A 126-byte animated PNG file makes Pillow's Image.open() allocate and fill memory sized by the image dimensions the file claims, before Pillow's built-in check for oversized images runs. A file claiming 22000×22000 pixels costs about 1.9 GB. Anyone who can get an application to open an image they supply, with default settings and no further calls such as load(), can cause a multi-gigabyte memory spike per file. If the claimed size exceeds the machine's free memory, the operating system kills the process outright.

The bug is in PngImageFile.seek() in src/PIL/PngImagePlugin.py (around line 1000). For any APNG, open() calls self.seek(0). When the first fcTL chunk requests OPBACKGROUND disposal, seek() calls Image.core.fill(self.mode, self.size) with the full IHDR size, which allocates and zero-fills width×height×4 bytes. OPPREVIOUS takes the same path, because frame 0 rewrites it to OPBACKGROUND. This all happens inside the plugin factory, so Image.open()'s decompressionbombcheck(im.size) only runs after the memory has been touched. A 1×1 frame rectangle passes the only checks on the way.

Below available memory, the allocation is freed when DecompressionBombError propagates, so sustained pressure needs repeated or concurrent uploads. A 45000×45000 header gets the process killed by the out-of-memory killer on an 8 GB host. The attached reproducer opens two such files that differ in one byte (disposeop 0 vs 1). Both end in DecompressionBombError, but peak resident memory is 17 MB for the first and 1864 MB for the second. The eager fill was introduced in 5e4e0fa6ee67 ("Use disposal settings from previous frame", December 2020) and first shipped in 8.1.0. Two candidate patches with a regression test are attached.

Details

For an APNG whose first fcTL chunk requests OPBACKGROUND disposal, PngImageFile.open() creates and zero-fills a core image of the full IHDR-declared size. This happens inside the plugin factory, i.e. before Image.open() gets to decompressionbombcheck(im.size), so the check that is meant to reject oversized headers without allocating anything runs only after the allocation has been made and touched.

The following 126-byte file (all chunk lengths and CRCs valid) makes Image.open(io.BytesIO(data)), with default MAXIMAGEPIXELS and no load(), reach 1.86 GB resident (rumaxrss) and then raise DecompressionBombError. The byte-identical file with disposeop=0 raises the same error at 17 MB.

IHDR width=22000 height=22000 bitdepth=8 colourtype=6 (RGBA) acTL numframes=2 numplays=0 fcTL seq=0 w=1 h=1 x=0 y=0 delay=1/10 disposeop=1 (OPBACKGROUND) blendop=0 IDAT zlib(00 00 00 00 00) # one 1x1 RGBA scanline IEND

With width=height=45000 (8.1 GB canvas) the same call attempts the full allocation: under a 4 GiB RLIMITAS it fails with MemoryError from the allocator instead of DecompressionBombError; without a cap, on an 8 GB host with no swap, the process is OOM-killed.

Code path

Image.open() runs the bomb check only after the plugin's constructor (and therefore open()) has returned:

python src/PIL/Image.py:3699-3701 im = factory(fp, filename) decompressionbombcheck(im.size) return im

For a plain PNG that ordering is fine: PngImageFile.open() only parses chunks up to the first IDAT and records self.size from IHDR; nothing is allocated from it. For an APNG (acTL seen, so imnframes is set) open() additionally positions on frame 0:

python src/PIL/PngImagePlugin.py:845-853 if self.png.imnframes is not None: … self.seek(0)

seek(0) copies disposal and bbox from the fcTL chunk into self.disposeop / self.disposeextent and then prepares the disposal image for the next frame eagerly, using the full image size:

python src/PIL/PngImagePlugin.py:991-1001 if self.previm is None and self.disposeop == Disposal.OPPREVIOUS: self.disposeop = Disposal.OPBACKGROUND

self.dispose = None if self.disposeop == Disposal.OPPREVIOUS: … elif self.disposeop == Disposal.OPBACKGROUND: self.dispose = Image.core.fill(self.mode, self.size) self.dispose = self.crop(self.dispose, self.disposeextent)

Image.core.fill is fill() in src/imaging.c:711, which does ImagingNewDirty(mode, xsize, ysize) followed by ImagingFill(); ImagingFill (src/libImaging/Fill.c:23) writes every row, so the whole widthheight4 bytes become resident rather than merely reserved. self.size here is the IHDR size (22000×22000), not the 1×1 frame rectangle. The crop on the next line does call decompressionbombcheck, but on the fcTL rectangle (1×1) and only after the fill has completed. Lines 991-992 rewrite OPPREVIOUS to OPBACKGROUND on frame 0, so disposeop=2 takes the same path (verified: identical 1.86 GB peak). When the first fcTL rectangle is large, the crop copy is a second extent-sized buffer on top of the full-canvas fill, so the transient peak can reach about twice the canvas: a 13000×13000 full-frame fcTL (below the error threshold, so Image.open() succeeds with only a DecompressionBombWarning) peaks at 1.31 GB for a 0.66 GB canvas.

The only validation chunkfcTL applies (src/PIL/PngImagePlugin.py:740-745) is that the frame rectangle fits inside the IHDR size, which a 1×1 frame always does; IHDR width/height themselves are unconstrained 32-bit values at this point. Control then returns to opencore, decompressionbombcheck((22000, 22000)) raises, and the 1.9 GB core image is released when the half-constructed PngImageFile is discarded.

The eager full-size fill in seek() was introduced in 5e4e0fa6ee67 ("Use disposal settings from previous frame", 2020-12-24) and first shipped in 8.1.0; before it, the OPBACKGROUND fill lived in loadend() and could not be reached from Image.open().

Impact

Any application that calls Image.open() on untrusted bytes with default settings — the configuration in which the decompression-bomb check is supposed to make that call cheap and safe — can be made to allocate and write an attacker-chosen amount of memory from a ~126-byte PNG upload, before any error is reported and without load(), frame iteration, or LOADTRUNCATEDIMAGES. The amount is set directly by the IHDR width/height (4 bytes per pixel for colour type 6): values below the host's free memory cause a transient multi-GB spike per request (≈15,000,000× amplification at 22000×22000) that is freed when the exception propagates, so concurrent or repeated uploads are needed to sustain pressure; values above it get the worker process OOM-killed outright. PNG is accepted by essentially every upload pipeline, and the file passes accept and chunk CRC checks as a well-formed APNG.

This is the same class as the recent bomb-check additions for WMF (load at a different DPI), FITS, GIF and FontFile — an allocation sized from file-declared numbers on a path decompressionbombcheck does not cover — but here the uncovered path is Image.open() itself rather than a later load()/seek().

Reproducing

repro is a single Python script (no build step, no companion files). It builds two PNGs in memory, opens each one with plain Image.open(io.BytesIO(data)) in a fresh child interpreter using default settings and no load(), and has each child report the exception it got and its own peak RSS (rumaxrss). It needs about 2 GB of free RAM and finishes in about a second.

The two files are 126 bytes each and differ in exactly one byte, the disposeop of the first fcTL chunk: 0 (OPNONE) for the control and 1 (OPBACKGROUND) for the attack. Both declare IHDR 22000×22000 RGBA. That is 484 Mpixel, above the 2×MAXIMAGEPIXELS error threshold, so Image.open() has to reject both with DecompressionBombError. Each chunk has a job:

- The acTL chunk (2 frames) makes open() call seek(0). - The 1×1 fcTL rectangle passes chunkfcTL's bounds check and crop's bomb check. - The one-scanline IDAT ends header parsing.

Expected output on this tree:

126-byte APNG declaring 22000x22000 RGBA; Image.open(BytesIO(data)), defaults control disposeop=0 OPNONE -> DecompressionBombError peak RSS 17 MB attack disposeop=1 OPBACKGROUND -> DecompressionBombError peak RSS 1864 MB Image.open() touched a full 22000x22000 canvas before the bomb check ran BUG

Both files are rejected, but the control is rejected at interpreter size. The attack file only reaches the bomb check after Image.core.fill("RGBA", (22000, 22000)) has allocated and zero-filled about 1.9 GB. The script prints BUG if the attack child's peak RSS is at least 1000 MB and at least 20× the control's, or if the attack child is killed by SIGKILL (for example by the OOM killer on a small host). Otherwise it prints OK; any other non-zero child exit is treated as a harness failure, printed with the child's output, and also ends in OK. With either attached patch applied, both children report about 17–22 MB and the script prints OK.

The allocation grows with the header, so it can be scaled by changing SIZE in the script. The 45000×45000 case (an 8.1 GB canvas) is covered in Details and is not part of the script.

Suggested fix

Do not build the OPBACKGROUND disposal image from the declared size during open(). Two alternative patches are attached; both change only src/PIL/PngImagePlugin.py, and both add the same regression test to Tests/testfileapng.py (a 2³¹−1 × 1 APNG built in memory with a 1×1 OPBACKGROUND first frame must make Image.open() raise DecompressionBombError; on the current tree the fill fails first with MemoryError). Attached patch 1 is the preferred fix.

Attached patch 1 moves the fill to its only point of use, the frame > 0 branch of seek() right after self.load() — which can only run once Image.open() has returned (bomb check passed) and the canvas itself has been allocated — and, as GifImagePlugin already does, fills only the previous frame's dispose extent instead of a full canvas that is then cropped:

diff --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -931,6 +931,13 @@ class PngImageFile(ImageFile.ImageFile): # ensure previous frame was loaded self.load() + if self.disposeop == Disposal.OPBACKGROUND: + # Created here rather than in the previous seek(), so that + # nothing is allocated from the declared image size before + # the decompression bomb check in Image.open() has run. + # Only dispose the extent of the previous frame. + x0, y0, x1, y1 = self.disposeextent + self.dispose = Image.core.fill(self.mode, (x1 - x0, y1 - y0)) if self.dispose: self.im.paste(self.dispose, self.disposeextent) self.previm = self.im.copy() @@ -996,9 +1003,6 @@ class PngImageFile(ImageFile.ImageFile): if self.previm: self.dispose = self.previm.copy() self.dispose = self.crop(self.dispose, self.disposeextent) - elif self.disposeop == Disposal.OPBACKGROUND: - self.dispose = Image.core.fill(self.mode, self.size) - self.dispose = self.crop(self.dispose, self.disposeextent)

At that point self.disposeop and self.disposeextent still hold the previous frame's values (they are reassigned further down in seek), so disposal semantics are unchanged; the OPPREVIOUS copy stays eager because loadend() pastes into previm in place (src/PIL/PngImagePlugin.py:1110). With this patch Image.open() allocates nothing from the declared size, including in the DecompressionBombWarning range.

Attached patch 2 is a smaller diff that keeps the eager disposal setup in seek() and mirrors GifImagePlugin.seek()'s OPBACKGROUND handling exactly: it computes the size of the fcTL rectangle (already validated by chunkfcTL to lie within the image), runs Image.decompressionbombcheck() on it, and fills an image of only that size instead of filling self.size and cropping. It also narrows the disposeextent annotation to tuple[int, int, int, int] so the bomb-check call type-checks. The trade-off is that a full-canvas first frame still allocates a bomb-checked image inside Image.open() — up to about 2×MAXIMAGEPIXELS×4 bytes (660 MB for a 13000×13000 full-frame fcTL, with DecompressionBombWarning) — and a first fcTL covering a canvas above the error threshold now raises DecompressionBombError from seek(0) rather than from Image.open()'s own check.

With either patch applied to a clean checkout, the reproducer prints OK and all 54 files in Tests/images/apng/ decode byte-identically frame by frame, including after a rewind.

The patch (1 of 2)

Create the APNG OPBACKGROUND disposal image when the next frame is sought, sized to the frame extent, not in open() from the IHDR size

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

diff diff --git a/Tests/testfileapng.py b/Tests/testfileapng.py index 15689476c..6e61c8b4b 100644 --- a/Tests/testfileapng.py +++ b/Tests/testfileapng.py @@ -161,6 +161,37 @@ def testapngdisposeopbackgroundpmode() -> None: assert im.size == (128, 64) +def testapngdisposeopbackgrounddecompressionbomb() -> None: + # The disposal image must not be created from the declared image size + # before the decompression bomb check in Image.open() has run. + # A canvas this wide cannot be allocated, so if it is attempted, + # a MemoryError is raised instead of a DecompressionBombError. + o16 = PngImagePlugin.o16 + o32 = PngImagePlugin.o32 + + b = BytesIO() + b.write(PngImagePlugin.MAGIC) + PngImagePlugin.putchunk(b, b"IHDR", o32(231 - 1), o32(1), b"\x08\x06\x00\x00\x00") + PngImagePlugin.putchunk(b, b"acTL", o32(2), o32(0)) + PngImagePlugin.putchunk( + b, + b"fcTL", + o32(0), # sequence number + o32(1), # width + o32(1), # height + o32(0), # x offset + o32(0), # y offset + o16(1), # delay numerator + o16(10), # delay denominator + bytes([PngImagePlugin.Disposal.OPBACKGROUND, PngImagePlugin.Blend.OPSOURCE]), + ) + PngImagePlugin.putchunk(b, b"IDAT") + PngImagePlugin.putchunk(b, b"IEND") + + with pytest.raises(Image.DecompressionBombError): + Image.open(b) + + def testapngblend() -> None: with Image.open("Tests/images/apng/blendopsourcesolid.png") as im: assert isinstance(im, PngImagePlugin.PngImageFile) diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 0eb2fd5e4..24fa587c1 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -931,6 +931,13 @@ class PngImageFile(ImageFile.ImageFile): # ensure previous frame was loaded self.load() + if self.disposeop == Disposal.OPBACKGROUND: + # Created here rather than in the previous seek(), so that + # nothing is allocated from the declared image size before + # the decompression bomb check in Image.open() has run. + # Only dispose the extent of the previous frame. + x0, y0, x1, y1 = self.disposeextent + self.dispose = Image.core.fill(self.mode, (x1 - x0, y1 - y0)) if self.dispose: self.im.paste(self.dispose, self.disposeextent) self.previm = self.im.copy() @@ -996,9 +1003,6 @@ class PngImageFile(ImageFile.ImageFile): if self.previm: self.dispose = self.previm.copy() self.dispose = self.crop(self.dispose, self.disposeextent) - elif self.disposeop == Disposal.OPBACKGROUND: - self.dispose = Image.core.fill(self.mode, self.size) - self.dispose = self.crop(self.dispose, self.disposeextent) def tell(self) -> int: return self.frame

The patch (2 of 2)

Bomb-check and fill only the fcTL extent for APNG OPBACKGROUND disposal instead of a full IHDR-sized canvas in PngImageFile.seek()

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

diff diff --git a/Tests/testfileapng.py b/Tests/testfileapng.py index 15689476c..6e61c8b4b 100644 --- a/Tests/testfileapng.py +++ b/Tests/testfileapng.py @@ -161,6 +161,37 @@ def testapngdisposeopbackgroundpmode() -> None: assert im.size == (128, 64) +def testapngdisposeopbackgrounddecompressionbomb() -> None: + # The disposal image must not be created from the declared image size + # before the decompression bomb check in Image.open() has run. + # A canvas this wide cannot be allocated, so if it is attempted, + # a MemoryError is raised instead of a DecompressionBombError. + o16 = PngImagePlugin.o16 + o32 = PngImagePlugin.o32 + + b = BytesIO() + b.write(PngImagePlugin.MAGIC) + PngImagePlugin.putchunk(b, b"IHDR", o32(231 - 1), o32(1), b"\x08\x06\x00\x00\x00") + PngImagePlugin.putchunk(b, b"acTL", o32(2), o32(0)) + PngImagePlugin.putchunk( + b, + b"fcTL", + o32(0), # sequence number + o32(1), # width + o32(1), # height + o32(0), # x offset + o32(0), # y offset + o16(1), # delay numerator + o16(10), # delay denominator + bytes([PngImagePlugin.Disposal.OPBACKGROUND, PngImagePlugin.Blend.OPSOURCE]), + ) + PngImagePlugin.putchunk(b, b"IDAT") + PngImagePlugin.putchunk(b, b"IEND") + + with pytest.raises(Image.DecompressionBombError): + Image.open(b) + + def testapngblend() -> None: with Image.open("Tests/images/apng/blendopsourcesolid.png") as im: assert isinstance(im, PngImagePlugin.PngImageFile) diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 0eb2fd5e4..0111a17d7 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -985,7 +985,7 @@ class PngImageFile(ImageFile.ImageFile): msg = "image not found in APNG frame" raise EOFError(msg) if disposeextent: - self.disposeextent: tuple[float, float, float, float] = disposeextent + self.disposeextent: tuple[int, int, int, int] = disposeextent # setup frame disposal (actual disposal done when needed in the next seek()) if self.previm is None and self.disposeop == Disposal.OPPREVIOUS: @@ -997,8 +997,14 @@ class PngImageFile(ImageFile.ImageFile): self.dispose = self.previm.copy() self.dispose = self.crop(self.dispose, self.disposeextent) elif self.disposeop == Disposal.OPBACKGROUND: - self.dispose = Image.core.fill(self.mode, self.size) - self.dispose = self.crop(self.dispose, self.disposeextent) + # only dispose the extent in this frame, rather than filling an + # image of the declared size before Image.open() has been able to + # run the decompression bomb check + x0, y0, x1, y1 = self.disposeextent + disposesize = (x1 - x0, y1 - y0) + + Image.decompressionbombcheck(disposesize) + self.dispose = Image.core.fill(self.mode, disposesize) def tell(self) -> int: return self.frame

The reproducer

<details> <summary>Reproducer — <code>repro</code>, 3,564 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 """ Image.open() on a 126-byte APNG allocates and zero-fills a full canvas of the header-declared size before Pillow's decompression-bomb check rejects it.

We build two PNGs that are identical except for one byte, the first fcTL chunk's disposeop:

control: disposeop = 0 (OPNONE) attack: disposeop = 1 (OPBACKGROUND)

Both declare 22000x22000 RGBA (484 Mpixel, above the 2 MAXIMAGEPIXELS error threshold), so Image.open() must reject both with DecompressionBombError -- and that check exists precisely so the rejection is cheap. For the attack file, PngImageFile.open() -> seek(0) first runs Image.core.fill(mode, (22000, 22000)), touching ~1.9 GB, and only then is the bomb check reached.

Each file is opened in a fresh interpreter that reports its own peak RSS. """ import struct import subprocess import sys import zlib

SIZE = 22000 # 22000 22000 4 bytes = 1.94 GB canvas

def chunk(cid, data): crc = zlib.crc32(cid + data) return struct.pack(">I", len(data)) + cid + data + struct.pack(">I", crc)

def buildapng(disposeop): ihdr = struct.pack(">IIBBBBB", SIZE, SIZE, 8, 6, 0, 0, 0) # 8-bit RGBA actl = struct.pack(">II", 2, 0) # animated: 2 frames -> open() calls seek(0) # frame 0: seq 0, a 1x1 rectangle at (0,0) (passes fcTL's bounds check), # delay 1/10 s, the disposeop under test, blendop 0 fctl = struct.pack(">IIIIIHHBB", 0, 1, 1, 0, 0, 1, 10, disposeop, 0) idat = zlib.compress(b"\x00" + b"\x00\x00\x00\x00") # one 1x1 RGBA scanline return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"acTL", actl) + chunk(b"fcTL", fctl) + chunk(b"IDAT", idat) + chunk(b"IEND", b""))

What an application does with an untrusted upload: Image.open() with default settings, no load(). Prints the outcome and this process's peak RSS in MB. OPENUNTRUSTED = """ import io, resource, sys from PIL import Image try: Image.open(io.BytesIO(sys.stdin.buffer.read())) outcome = "opened" except Exception as e: outcome = type(e).name print(outcome, resource.getrusage(resource.RUSAGESELF).rumaxrss // 1024) """

def openinchild(data): p = subprocess.run([sys.executable, "-c", OPENUNTRUSTED], input=data, captureoutput=True, timeout=60) if p.returncode == -9: # SIGKILL: OOM-killed on a small host -- also the bug return "oom-killed(rc=-9)", None if p.returncode != 0: # anything else is a harness failure, not evidence print(p.stdout.decode(), p.stderr.decode(), sep="\n") print("child failed unexpectedly (rc=%d)" % p.returncode) print("OK") sys.exit(1) outcome, peakmb = p.stdout.split() return outcome.decode(), int(peakmb)

control = buildapng(disposeop=0) attack = buildapng(disposeop=1)

coutcome, cmb = openinchild(control) aoutcome, amb = openinchild(attack)

print("%d-byte APNG declaring %dx%d RGBA; Image.open(BytesIO(data)), defaults" % (len(attack), SIZE, SIZE)) print(" control disposeop=0 OPNONE -> %-22s peak RSS %5s MB" % (coutcome, cmb)) print(" attack disposeop=1 OPBACKGROUND -> %-22s peak RSS %5s MB" % (aoutcome, amb))

Expected: both rejected at roughly interpreter size. Bug: the attack file made the "cheap" rejection cost a full-size canvas (or killed the process). if amb is None or (amb >= 1000 and amb >= 20 cmb): print("Image.open() touched a full %dx%d canvas before the bomb check ran" % (SIZE, SIZE)) print("BUG") else: print("OK") </details>

---

Affected Software

1 affected componentFixes available
pip/Pillow>=8.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. Configuration

    Change APNG OP_BACKGROUND disposal handling so it does not create a full-canvas disposal image during Image.open(). Defer the fill until the next frame is sought after self.load(), and size it to the previous frame's disposal extent; alternatively, if retaining eager setup, compute the fcTL rectangle size, call Image._decompression_bomb_check(dispose_size), and fill only that extent.

    Pillow src/PIL/PngImagePlugin.py APNG OP_BACKGROUND disposal allocation in PngImageFile._seek() = Allocate and fill only the validated fcTL frame extent after the decompression-bomb check, rather than the full IHDR-declared image size

Event History

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

Frequently Asked Questions

1

Are applications using Pillow's default settings exposed?

Yes. An application that calls Image.open() on an image supplied by an attacker can trigger the allocation with default settings, even if it makes no subsequent call such as load().

2

What does an attacker need to provide to trigger the issue?

The input must be an animated PNG whose first fcTL chunk requests OP_BACKGROUND disposal and whose IHDR claims very large image dimensions. Processing reaches the vulnerable path when Pillow opens the APNG.

3

What is the likely operational impact?

Each malicious file can cause a multi-gigabyte memory spike based on its claimed canvas dimensions. If the allocation exceeds available memory, the operating system can terminate the process.

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