Pillow: Dereferencja wskaźnika NULL w ImagingExpand poprzez filtry rangowe na obrazach o zerowym rozmiarze
============================================================================
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()iImage.new();- wielostronicowy plik TIFF otwarty ze ścieżki, którego drugi IFD deklaruje
ImageLength = 0, na którymseek(1)iload()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:
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:
# 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:
// 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:
// 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 == rightlubupper == lowerzwraca obraz 0×N / N×0.ImageOps.crop(im, border)zborder == width // 2orazImage.new(mode, (0, n))zachowują się tak samo.- Z pliku:
ImageFile.__init__odrzuca zerowy rozmiar pierwszej klatki (src/PIL/ImageFile.py:167-173), leczTiffImagePlugin._setup()przyjmujeImageWidth/ImageLengthkażdego kolejnego IFD bez zmian wseek()(src/PIL/TiffImagePlugin.py:1468-1481). Dla nieskompresowanej strony obrazu otwartego ze ścieżkiImageFile.load()wybiera gałąź mmap (src/PIL/ImageFile.py:307, 325-347), aImage.core.map_bufferbez przeszkód buduje bazowy obraz8×0(src/map.c:127-141), w wyniku czegoim.seek(1); im.load()kończy się powodzeniem, a następujące wywołanieim.filter(ImageFilter.MedianFilter(3))powoduje błąd naruszenia ochrony pamięci wImagingExpanddokł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 przezMedianFilter(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 przezMedianFilter(3)iMaxFilter(5). - Przycięcie
(10, 0)(upper == lower,(0, 3, 10, 3)) przetworzone przezMinFilter(3). - Przycięcie
(0, 0)((3, 3, 3, 3)) przetworzone przezRankFilter(3, 4).
- Przycięcie
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):
--- 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 --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.
#!/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")
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.
| Platforma | Paczka | Podatne wersje | Poprawka w wersji |
|---|---|---|---|
| Python (PyPI) | pillow | < 13.0.0 | 13.0.0 |
Odnośniki
- https://github.com/python-pillow/Pillow/security/advisories/GHSA-27h8-gf7j-v5hc
- https://nvd.nist.gov/vuln/detail/CVE-2026-112392
- https://github.com/python-pillow/Pillow/commit/443e3130a8c11594fd0e296afad497e11e678961
- https://github.com/python-pillow/Pillow
- https://github.com/python-pillow/Pillow/releases/tag/13.0.0
- 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ć.
- 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
- 2017: 1
- 2018: 0
- 2019: 1
- 2020: 10
- 2021: 20
- 2022: 8
- 2023: 3
- 2024: 2
- 2025: 1
- 2026: 31
wcześniej: 12
-
Pillow: Dereferencja wskaźnika NULL w ImagingExpand poprzez filtry rangowe na obrazach o zerowym rozmiarze ten wpis
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.