Pillow: 埋め込みTIFFファイルの場合、MICのn_framesで無限ループが発生
```
============================================================================
埋め込みTIFFファイルが3つ以上のIFDを持つ場合、MICのn_framesで無限ループが発生します。
場所: src/PIL/MicImagePlugin.py:86 in MicImageFile.tell()
コミット: a707c6b21c313880d0771e54965d142dd42358b6
============================================================================
```
特定の5.6 KBの画像ファイルを使用すると、Pillowが無限ループに陥り、CPU使用率が100%になります。これは、アプリケーションが画像のフレーム数を問い合わせた場合に発生します。この問題は、オプションの`olefile`パッケージがインストールされている環境でのみ発生します。信頼できない画像を開き、`im.n_frames`を読み取るアプリケーションは、いずれかのプロセスが無限に停止する可能性があります。これには、一般的な「この画像はアニメーションですか?」というチェックや、`save_all=True`オプションでTIFF、PDF、またはWebP形式で再保存する処理が含まれます。この処理は決して戻らず、例外も発生せず、メモリも増加しないため、外部の監視プロセスでなければ停止させることができません。
このファイルは、Microsoft Image Composer (MIC) コンテナ形式であり、Pillowは拡張子を必要とせずに、そのマジックバイトによってこれを認識します。このコンテナ内には、3つの連鎖した画像ディレクトリを持つTIFFストリームが含まれています。`MicImageFile`は`TiffImageFile`を継承し、`seek()`と`tell()`をオーバーライドしているため、「フレーム」はコンテナ内の1つのストリームを意味します。しかし、`TiffImageFile.n_frames`(`src/PIL/TiffImagePlugin.py:1212`)は依然として継承されており、これは埋め込みTIFFのディレクトリチェーンを`self._seek(self.tell() + 1)`で走査し、`_n_frames`を設定します。この走査が進行しないようにする要因は2つあります。
- `MicImageFile.tell()`(`src/PIL/MicImagePlugin.py:86`)は、常に同じストリームインデックスを返します。
- `MicImageFile.seek()`は、`TiffImageFile._open()`を呼び出し、これにより`_n_frames`が`None`にリセットされます。
その結果、埋め込みストリームに3つ以上のディレクトリが含まれている場合、ループは常に2番目のディレクトリを再解析します。`Image.open()`、`load()`、`thumbnail()`、`convert()`、`is_animated`、および`ImageSequence`の反復処理は、このファイルでハングアップすることはありません。この問題は、`MicImageFile`自身の`n_frames`プロパティを削除した0b33a87b0d15(2020年4月、7.2.0で初めてリリース)で導入されました。添付の再現コードは、コード内でファイルを生成し、期待どおりに3フレームと1フレームを返す2つのチェックを行います。悪意のあるファイルは、20秒後に強制終了されるまで実行され続けます。
## 詳細
`MicImageFile`(`olefile`がインストールされている場合に`Image.open`で使用される)は、`TiffImageFile`を継承し、「フレーム」をOLEの`*.ACI/Image`ストリームとして再定義し、それに応じて`seek()`と`tell()`をオーバーライドします。`n_frames`プロパティはオーバーライドされていないため、継承されたTIFFの実装が実行され、その実装は`tell()`がIFDがロードされる際に増加することに依存しています。`MicImageFile.tell()`は、OLEストリームのインデックスを返し、これは`n_frames`の内部では決して変化しません。そのため、ループは同じIFDを永遠に再解析します。
5632バイトのOLE2複合ファイルで、`img0.ACI/Image`ストリームが3つの連結された1×1 IFDを持つ351バイトのTIFFであるだけで十分です。このファイルに対して`Image.open()`を実行すると成功します(形式は`MIC`で、OLEのシグネチャによって検出され、拡張子は不要です)。`im.n_frames`を評価すると、無限に実行され、1つのコアが100%の負荷で動作し続けます。27秒で取得されたPythonのスタックトレースは次のとおりです。
```
File "src/PIL/TiffImagePlugin.py", line 1622 in _setup
File "src/PIL/TiffImagePlugin.py", line 1271 in _seek
File "src/PIL/TiffImagePlugin.py", line 1219 in n_frames
```
`_seek`をトレースすると、`frame=1`、`tell()==0`、`_frame_pos==[8, 122]`、`_n_frames is None`という同じ状態で呼び出されていることがわかります。つまり、ループは単に遅いだけでなく、終了しません。同じ351バイトのTIFFを直接開くと、ミリ秒単位で`n_frames == 3`が返され、同様のコンテナで1つのIFDを持つTIFFの場合、`n_frames == 1`が返されます。
問題のあるループは、`src/PIL/TiffImagePlugin.py:1212`にあります。
```python
@property
def n_frames(self) -> int:
current_n_frames = self._n_frames
if current_n_frames is None:
current = self.tell()
self._seek(len(self._frame_pos))
while self._n_frames is None:
self._seek(self.tell() + 1) # 1219
self.seek(current)
```
そして、その進行状況の前提を破るオーバーライドについて:`src/PIL/MicImagePlugin.py:76`
```python
def seek(self, frame: int) -> None:
…
self.fp = self.ole.openstream(filename)
TiffImagePlugin.TiffImageFile._open(self) # 82: _n_frames を None にリセット
self.frame = frame
def tell(self) -> int:
return self.frame # 87: OLE ストリームのインデックス、TIFF の __frame ではない
```
通常の TIFF ファイルの場合、`_seek(k)` (`TiffImagePlugin.py:1236`) は、`k` までの IFD を読み込み、各オフセットを `_frame_pos` に追加し、IFD の次のポインタが 0 (または既に確認済み) の場合に `_n_frames` を設定し、プライベート変数 `__frame = k` を設定します。`TiffImageFile.tell()` は `__frame` を返します。したがって、`self.tell() + 1` はチェーンの末尾まで移動します。
MICファイルの場合、`Image.open` → `MicImageFile._open` が行 69 で `self._n_frames = len(self.images)` を設定しますが、その後 `self.seek(0)` → `TiffImageFile._open` (行 82) が呼び出されます。これにより、条件なしに `self._n_frames = None` が設定されます (`TiffImagePlugin.py:1203`)。また、埋め込みストリームへの `_seek(0)` が実行されます。3つのIFDを持つストリームの場合、IFD 0はゼロでない次のポインタを持つため、`_n_frames` は `open` 処理後も `None` のままです。その後、`n_frames` の計算が行われます。`current = self.tell()` は `0` (OLEインデックス) になります。`_seek(len(_frame_pos))` = `_seek(1)` によって IFD 1 が読み込まれますが、IFD 1の次のポインタもゼロでないため、まだ `_frame_pos` に含まれていません。したがって、`_n_frames` は依然として `None` です。`while` ループの中では、`self.tell() + 1` = `0 + 1` が計算され、`_seek(1)` が再度呼び出されます。`len(_frame_pos)` はすでに 2 であるため、新しいデータは読み込まれません。IFD 1 は `tag_v2.load` と `_setup()` を介して再読み込みされ、状態は変更されません。この処理は無限に繰り返され、IFD 2 には到達しません。2つの連鎖したIFDでは不十分です (IFD 1 の次のポインタが 0 の場合、最初の `_seek(1)` で `_n_frames = 2` が設定されます)。3つ以上のIFDが必要です。
`Tests/test_file_mic.py::test_n_frames` は、`hopper.mic` のストリームが単一のIFDしか持たないため、`_seek(0)` が `_n_frames = 1` を設定し、ループが実行されない場合にのみパスします。行 69 の `len(self.images)` の代入は、最初の `_seek_check(0)` でのみ使用され、呼び出し元が `n_frames` を読み取る前に上書きされます。
同様の不整合による副作用として、2つのIFDを持つストリームの場合、`n_frames` は 2 (TIFF IFD) を返しますが、`seek(1)` は `self.images[1]` (OLEストリーム) を参照し、`IndexError` を発生させます。これは `save_all=True` を指定した場合に確認できます。
## 影響
Pillowでアップロードされたファイルを開き、`im.n_frames` を読み取るようなアプリケーションは、通常、画像がアニメーションかどうかを判断するために使用されます (`getattr(im, "n_frames", 1) > 1`)。また、Pillowの`TiffImagePlugin._save_all`(`:2333行目)、`PdfImagePlugin._save`(`:250行目)および`WebPImagePlugin._save_all`(`:171行目)で、アプリケーションが`save_all=True`で再保存する際に評価される`hasattr(im, "n_frames")`/`getattr(im, "n_frames", 1)`と同じ処理を行います。
これらのアプリケーションは、5.6 KBのアップロードだけで、CPU使用率100%で無限に処理を続けさせることができます。ファイルには特定の拡張子が必要ではありません(OLEの仕組みを利用します。`FpxImagePlugin`はCLSIDで拒否し、`MicImagePlugin`は受け入れます)。デフォルトの制限は適用されず、メモリは増加せず、例外も発生しないため、外部の監視プロセスでなければ停止させることができません。
前提条件:`olefile`がインストールされていること(これはオプションの依存関係であり、インストールされていない場合、MICプラグインは登録されません)。また、アプリケーションが`n_frames`または`save_all`に関連する処理を実行している必要があります。`Image.open()`自体、`load()`, `thumbnail()`, `convert()`, `is_animated`および`ImageSequence`の反復処理は、このファイルでハングアップすることはありません。
これは、CVE-2021-28676 (FLI) および CVE-2021-28677 (EPS) と同様の、小さなファイルでPythonだけで無限ループが発生する問題であり、CVE-2019-19911 (FPX) と同様に `olefile` に依存しますが、デフォルトのファイルを開く/読み込むパスではこの問題は発生しません。
この問題は、`0b33a87b0d15`(「n_framesとis_animatedのプロパティメソッドを通常のプロパティに置き換え」、2020年4月13日)で導入され、バージョン7.2.0で初めてリリースされました。この変更により、`MicImageFile`自身の`n_frames`プロパティが、`TiffImageFile._open`によって上書きされる`_n_frames`属性に置き換えられました。
## 再現方法
`repro`は、インストールされているPillow(`olefile`を含む。これによりMICプラグインが登録されます)に対して実行される単一のPythonスクリプトです。このスクリプトは、すべての入力をコード内で生成し、拡張子なしの`x`という名前の一時ファイルに書き込みます(これによりPillowはマジックバイトからファイル形式を識別します)。そして、`Image.open(path)`を実行し、`im.format`と`im.n_frames`を出力する子Pythonプロセスでこのファイルを開きます。
このコードは、以下の2つのものを生成します。
- `tiff(n)`: Pillow自体が書き込む、nページ分のTIFF形式の4x4のグレースケール画像ファイル。`save_all=True`が指定されているため、IFD(Image File Directory)がPillowの書き込み機能が生成するIFDと全く同じように連鎖されます。最後の次のIFDへのポインタは0です。
- `mic(stream)`: 最小限のOLE2複合ファイルで、`img0.ACI`という名前のストレージを含んでいます。このストレージには、`Image`という名前のストリームが含まれており、これは`MicImageFile._open`が探しているレイアウトです。セクタ0はFAT(File Allocation Table)、セクタ1はディレクトリです。ストリームはセクタ2から始まり、4096バイトにパディングされるため、このファイルにはミニFATは必要ありません。結果として、ファイルサイズは5632バイトになります。
このスクリプトは、3つの入力を実行します。それぞれが独自のプロセスで実行され、20秒のタイムアウトが設定されています。
1. control: 通常の3ページ分のTIFFファイル。期待される値は`TIFF n_frames = 3`です。
2. control: 1ページ分のTIFFファイルをMIC形式でラップしたもの。期待される値は`MIC n_frames = 1`です。
3. attack: 3ページ分のTIFFファイルをMIC形式でラップしたもの。
タイムアウトは、プロセス内のアラームではなく、子プロセスを強制終了することで実現されます。これは、`ImageFileDirectory_v2.load()`が解析中に`TimeoutError`が発生した場合、その`except OSError`ブロック(`TiffImagePlugin.py:958`)によって捕捉され、IFDが途中で解析されずにループが停止してしまう可能性があるためです。
スクリプトは、両方のコントロールが完了し、攻撃側のプロセスが20秒後にまだ実行中の場合に`BUG`と表示します。それ以外の場合は`OK`と表示します。添付されたパッチを適用すると、攻撃側のケースで`MIC n_frames = 1`と表示され、スクリプトは`OK`と表示されます。
## 提案される修正
添付されたパッチは、埋め込まれたTIFFストリームが開かれた後に、OLEレベルのフレーム数を復元します。これにより、継承された`n_frames`がIFDの解析ループに入ることがなくなり、`n_frames`と`seek()`がフレームの定義について一致するようになります。`MicImageFile.seek`の`src/PIL/MicImagePlugin.py:82`の、`TiffImagePlugin.TiffImageFile._open(self)`の直後に以下のコードを追加します。
```diff
TiffImagePlugin.TiffImageFile._open(self)
+ # TiffImageFile._open() は、埋め込まれたTIFFストリームのフレーム数をリセットしますが、
+ # MICファイルのフレームは、そのストリームの数と同じです。
+ self._n_frames = len(self.images)
+ self.is_animated = self._n_frames > 1
+
self.frame = frame
```
`seek()` 関数でこれを実行することで、最初の `seek` のみならず、すべての `seek` でこの動作が維持されます。`_open` 関数の69〜70行にある既存の代入は維持されます。初期の `seek(0)` → `_seek_check(0)` は、`TiffImageFile._open` が実行される前に `n_frames` を読み取るため、依然として必要です。以前の 0b33a87b0 のように、単純な読み取り専用の `n_frames` / `is_animated` プロパティは、`TiffImageFile._seek` が `self.is_animated` を代入するため、代替手段として使用できません。この修正をクリーンなコードツリーに適用して再構築すると、攻撃ファイルはすぐに `n_frames == 1` を返し、2-IFD ストリームは `seek(1)` で `IndexError` ではなく `EOFError` を発生させ、`save_all=True` を指定して TIFF および PDF ファイルを保存すると完了し、`Tests/test_file_mic.py` および `Tests/test_file_tiff.py` が正常に実行されます。回帰テストは含まれていません。なぜなら、新しいバイナリ MIC ファイルを `Tests/images` に追加する必要があるからです。
この修正はチェックに合格しましたが、最終的な修正版としてレビューは行われていません。この修正は、以下の2つの点を変更していません。1つは、`TiffImageFile.seek` が `_seek` の後に行う `_im` のリセットです(`TiffImagePlugin.py:1229`)。これは、サイズまたはモードが異なる OLE ストリームを切り替える場合に重要です。もう1つは、`TiffImageFile.n_frames` が、オーバーライド可能な `tell()` ではなく、プライベートな `__frame` を介してインクリメントされることです。したがって、`__frame` を使用するように変更すると、`tell()` を再定義する他のサブクラスも保護されます。
## 修正内容
MicImageFile.seek() で、TiffImageFile._open() がリセットした後、OLE ストリームのフレーム数を復元します。これにより、n_frames が無限にループすることを防ぎます。
これは、上記のコミットにおける `src/PIL/MicImagePlugin.py` に適用されます。機械的なチェックは問題なく通過しました — クリーンな状態からビルドし直したところ、再現スクリプトが動作しなくなった —ものの、レビュー担当者はこれが正しい修正方法ではないと判断しました。
```diff
diff --git a/src/PIL/MicImagePlugin.py b/src/PIL/MicImagePlugin.py
index f8cf3a76f..2bb0109ce 100644
--- a/src/PIL/MicImagePlugin.py
+++ b/src/PIL/MicImagePlugin.py
@@ -81,6 +81,11 @@ class MicImageFile(TiffImagePlugin.TiffImageFile):
TiffImagePlugin.TiffImageFile._open(self)
+ # TiffImageFile._open() は、埋め込まれたTIFFストリームのフレーム数をリセットしますが、
+ # MICファイルのフレームは、そのストリームです。
+ self._n_frames = len(self.images)
+ self.is_animated = self._n_frames > 1
+
self.frame = frame
def tell(self) -> int:
```
## 再現スクリプト
<details>
<summary>再現スクリプト — <code>repro</code>、2,330バイト(開くにはクリック)</summary>
このスクリプトの最終行は、問題が発生すると `BUG` と表示され、発生しない場合は `OK` と表示されます。同じコミットをクリーンな状態でビルドし直したところ、`BUG` と表示されました。
```python
#!/usr/bin/env python3
# MicImageFile は TiffImageFile.n_frames を継承しており、そのループ `self._seek(self.tell()+1)`
# は tell() が値を返すことを前提としています。MicImageFile.tell() は OLE ストリームのインデックス(定数)を返すため、
# Image ストリームが >=3 の連結された IFD を持つ MIC ファイルの場合、im.n_frames が無限ループになります。
import io, struct, subprocess, sys, tempfile, os
from PIL import Image
def tiff(n): # Pillow 自身が作成する n ページの TIFF
b = io.BytesIO(); fr = [Image.new("L", (4, 4), i) for i in range(n)]
fr[0].save(b, "TIFF", save_all=True, append_images=fr[1:]); return b.getvalue()
```
```
def mic(stream): # 最小限のOLE2ファイル:ストリーム"Image"を含む、ストレージ"img0.ACI"
END, FREE, FATSECT = 0xFFFFFFFE, 0xFFFFFFFF, 0xFFFFFFFD
data = stream.ljust(4096, b"\0"); n = len(data) // 512
fat = [FATSECT, END] + [3 + k for k in range(n - 1)] + [END]; fat += [FREE] * (128 - len(fat))
def entry(name, kind, child, start, size):
raw = name.encode("utf-16-le") + b"\0\0"
return raw.ljust(64, b"\0") + struct.pack("<HBBiii", len(raw), kind, 1, -1, -1, child) + b"\0"*36 + struct.pack("<III", start, size, 0)
d = entry("Root Entry", 5, 1, END, 0) + entry("img0.ACI", 1, 2, 0, 0) + entry("Image", 2, -1, 2, len(data)) + b"\0"*128
hdr = (b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1" + b"\0"*16 + struct.pack("<HHHHH6x", 0x3E, 3, 0xFFFE, 9, 6)
+ struct.pack("<9I", 0, 1, 1, 0, 4096, END, 0, END, 0) + struct.pack("<I", 0) + b"\xff"*432)
return hdr + struct.pack("<128I", *fat) + d + data
CHILD = "import sys;from PIL import Image;im=Image.open(sys.argv[1]);print(im.format, 'n_frames =', im.n_frames)"
def run(label, data, limit=20):
p = os.path.join(tempfile.mkdtemp(), "x"); open(p, "wb").write(data)
try:
r = subprocess.run([sys.executable, "-c", CHILD, p], capture_output=True, text=True, timeout=limit)
out = (r.stdout.strip() or r.stderr.strip().splitlines()[-1]); hung = False
except subprocess.TimeoutExpired:
out, hung = f"STILL RUNNING after {limit}s (killed)", True
print(f"{label:32s} {len(data):6d} bytes -> {out}"); return hung
c1 = run("control: 3-page TIFF, direct", tiff(3))
c2 = run("control: MIC around 1-page TIFF", mic(tiff(1)))
bug = run("attack: MIC around 3-page TIFF", mic(tiff(3)))
print("BUG" if bug and not c1 and not c2 else "OK")
```
影響を受けるパッケージ:
- pip (Python) Pillow: 影響を受けるバージョン >= 7.2.0, < 13.0.0、修正済みのバージョン 13.0.0