Pillow: ゼロサイズの画像に対するランクフィルタ処理におけるNULL参照エラー

```
============================================================================
ゼロサイズの画像に対するランクフィルタ処理におけるNULL参照エラー
場所: src/libImaging/Filter.c:79 in ImagingExpand()
コミット: a707c6b21c313880d0771e54965d142dd42358b6
============================================================================
```

Pillowのメディアン、最小値、最大値、またはランクフィルタを、幅または高さがゼロの画像に適用すると、エラーを発生させる代わりに、Pythonプロセス全体がクラッシュします。このような空の画像は、通常の操作で簡単に作成できます。例えば、左右の端(または上下の端)が一致するクロップボックス、または、細工された複数ページの画像ファイルの中の2ページなどです。攻撃者が、アプリケーションにクロップ座標やアップロードされたファイルを供給し、そのアプリケーションがこれらのフィルタのいずれかを実行する場合、そのアプリケーションのワーカプロセスを停止させることができます。この影響は、クラッシュのみであり、メモリの漏洩や破損は発生しません。

問題は、`src/libImaging/Filter.c`の`ImagingExpand()`関数にあります。これは、`RankFilter.filter()`が常に実行する、エッジパディングのステップであり、`MedianFilter`、`MinFilter`、および`MaxFilter`もこの処理を経由します。この処理は、入力画像の端のピクセルをコピーしてマージンを埋めますが、入力画像にピクセルが存在するかどうかを確認しません。空の画像の場合、`ImagingAllocateArray`は行ポインタをNULLのままにしてしまうため、最初の読み込みでNULL参照が発生し、すべてのモードでSIGSEGVによってプロセスが終了します。

ゼロサイズの画像は、以下の公開APIを通じてこの状態に到達します。

- `Image.crop()`、`ImageOps.crop()`、および`Image.new()`
- パスで開かれた、2番目のIFDで`ImageLength = 0`と宣言されている、複数ページのTIFFファイル。このファイルに対して`seek(1)`と`load()`を実行すると成功します。

他の`ImageFilter`では、同じ入力に対して常に空の画像が返されます。高さがゼロの場合、PILの初期バージョンからクラッシュが発生しており、幅がゼロの場合、Pillow 4.3.0以降でクラッシュが発生しています。これは、#9755で修正された`margin == 0`とは別の問題です。パスは同じですが、ここでは問題の原因がカーネルのサイズではなく、画像のサイズであるという点が異なります。これは、GHSA-f87v-9578-p47wで修正された、ゼロ次元の`ImagingOffset`のクラッシュと同じような状況です。

添付の再現例では、1ピクセル幅のコントロール領域が通常通りフィルタリングされますが、ランクフィルタを通過する4つの空の領域がそれぞれSIGSEGVによって停止されます。空の入力に対する`ImagingExpand`関数で早期リターンを行うための修正が含まれています。

## 詳細

`ImagingExpand()`(`src/libImaging/Filter.c`)は、`ImageFilter.RankFilter`が実際のランクフィルタを実行する前に呼び出す、エッジパディングを行うヘルパー関数です。この関数は、入力画像にピクセルが存在するかどうかを確認せずに、各入力行の最初のピクセルを読み取ります。幅が0または高さが0の画像の場合、行ポインタはNULLになるため、`Image.filter()`で`MedianFilter`、`MinFilter`、`MaxFilter`または`RankFilter`を使用すると、エラーが発生する代わりにインタプリタがSIGSEGVで停止します。

```python
from PIL import Image, ImageFilter
im = Image.new("L", (10, 10)).crop((3, 0, 3, 10)) # size (0, 10); crop() accepts left == right
im.filter(ImageFilter.MedianFilter(3)) # SIGSEGV
```

gdbで標準のビルドを実行すると、エラーは`ImagingExpand (imIn=…, margin=1) at src/libImaging/Filter.c:105`(`EXPAND(UINT8, image8)`の展開)で発生し、これは`_expand_image`関数から`src/_imaging.c:1151`で呼び出されます。このとき、`imIn->xsize = 0`であり、`imIn->image[0] = 0x0`となっています。ランクフィルタがサポートするすべてのモード(`L`、`RGB`、`RGBA`、`I`、`F`)で同様の問題が発生し、`expand(m)`メソッドで`m >= 1`の場合、どのモードでも同じようにクラッシュします(`P`モードも含む)。同じ入力が`BoxBlur`、`GaussianBlur`、`Kernel`、`ModeFilter`、`UnsharpMask`を通過すると、`(0, 10)`の画像が返され、`MedianFilter`で`(1, 10)`の領域を切り出すと、通常通り動作します。

未チェックのエッジ読み込み処理は、PILのインポート (`9a640e315`, 2010年、つまりPillowのすべてのバージョン) からのデータを使用します。ゼロ高さの場合、その後のバージョンでクラッシュが発生し、ゼロ幅の場合も `fd907fbdc` ("0幅および0高さの画像の別の方法での修正", Pillow 4.3.0) 以降に問題が発生しました。このバージョンでは、ゼロ幅の画像に対するダミーブロックのサポートが廃止され、行ポインタがNULLになりました。これは、#9755で修正された `margin == 0` の除算エラーの修正処理と隣接しています。どちらも `expand()` → `ImagingExpand()` という同じパスを使用しますが、ここで問題となるのはカーネルサイズではなく、画像サイズです。

処理のパスは以下の通りです。

```python
# src/PIL/ImageFilter.py:111-116 RankFilter.filter (MedianFilter/MinFilter/MaxFilter はこれを受け継ぐ)
image = image.expand(self.size // 2)
return image.rankfilter(self.size, self.rank)
```

`expand(m)` (ただし `m > 0` の場合) は `ImagingExpand(self->image, m)` (`src/_imaging.c:1142-1151`) を呼び出します。この処理では、`margin` のみが検証され、`(xsize+2*margin) × (ysize+2*margin)` のサイズの出力バッファが割り当てられます。このバッファは空ではないため、割り当ては成功します。その後、マージン部分は、入力画像の端のピクセルを複製することで埋められます。

```c
// src/libImaging/Filter.c:70-101
imOut =
ImagingNewDirty(imIn->mode, imIn->xsize + 2 * margin, imIn->ysize + 2 * margin);
…
#define EXPAND_LINE(type, image, yin, yout) \
{ \
for (x = 0; x < margin; x++) { \
imOut->image[yout][x] = imIn->image[yin][0]; \ // line 79
} \
… \
for (x = 0; x < margin; x++) { \
imOut->image[yout][margin + imIn->xsize + x] = \
imIn->image[yin][imIn->xsize - 1]; \ // [-1] when xsize == 0
} \
}
```

```
#define EXPAND(type, image) \
{ \
for (y = 0; y < margin; y++) { \
EXPAND_LINE(type, image, 0, y); \ // 画像の0行目
} \
…
for (y = 0; y < margin; y++) { \
EXPAND_LINE(type, image, imIn->ysize - 1, margin + imIn->ysize + y); \ // ysizeが0の場合、-1行
} \
}
```

行ポインタがNULLになる理由:`ImagingNewPrologueSubtype`は、`im->image`のポインタ配列(`src/libImaging/Storage.c:216`、少なくとも1つのエントリ)を`calloc`で確保しますが、`ImagingAllocateArray`は、空の画像の場合、それらを割り当てずに値を返します。

```c
// src/libImaging/Storage.c:423-426
/* 幅または高さが0の画像。何もしなくてもよい */
if (!im->linesize || !im->ysize) {
return im;
}
```

したがって、`xsize == 0`の場合、すべての`imIn->image[y]`がNULLになり、`imIn->image[yin][0]`でエラーが発生します。`ysize == 0`の場合、トップマージン処理では`imIn->image[0]`(単一の`calloc`されたNULLスロット)が読み取られ、同様にエラーが発生し、ボトムマージン処理では`imIn->image[-1]`にアクセスしようとします。`ImagingRankFilter`は呼び出されません。

ゼロサイズの画像は、Pillowでは通常のオブジェクトであり、公開APIを通じて`filter()`に到達します。

- `Image.crop(box)` は、反転したバウンディングボックスのみを拒否します(`src/PIL/Image.py:1385-1390`)。`left == right` または `upper == lower` の場合、0×N または N×0 の画像が生成されます。`ImageOps.crop(im, border)` で `border == width // 2` を指定した場合、または `Image.new(mode, (0, n))` を使用した場合も、同様の結果になります。
- ファイルからの読み込み:`ImageFile.__init__` は、最初のフレームのサイズがゼロの場合、エラーとなります(`src/PIL/ImageFile.py:167-173`)。しかし、`TiffImagePlugin._setup()` は、後続のすべての IFD の `ImageWidth` と `ImageLength` をそのまま `seek()` で使用します(`src/PIL/TiffImagePlugin.py:1468-1481`)。パスで開かれた画像の非圧縮ページの場合、`ImageFile.load()` は mmap の機能を使用します(`src/PIL/ImageFile.py:307, 325-347`)、そして `Image.core.map_buffer` は、エラーメッセージなしに `8×0` のコア画像を構築します(`src/map.c:127-141`)。したがって、`im.seek(1); im.load()` は成功し、その後に `im.filter(ImageFilter.MedianFilter(3))` を実行すると、`ImagingExpand` でセグメンテーション違反が発生します。これは、上記の状況と全く同じです(gdb で検証済み:`imIn->ysize = 0`, `imIn->image[0] = 0x0`)。384 バイトの 2 ページ TIFF ファイルで再現できます。

## 影響

サービス拒否:埋め込みプロセス全体(ウェブワーカー、パイプラインプロセス)が、Python の例外でリクエストが失敗するのではなく、SIGSEGV で終了します。これは、リモートユーザーが以下の 2 つの現実的な状況で遭遇する可能性があります。(a)ユーザーが指定した座標で画像をトリミングし、その後、メディアン/最小/最大フィルタ(ノイズ除去、膨張/収縮、サムネイルのクリーニング)を適用するアプリケーション。この場合、`x0 == x1` または `y0 == y1` のバウンディングボックスが `crop()` に渡されると、ワーカーがクラッシュします。(b)アップロードされた TIFF ファイルをディスク上のパスから開き、そのページを反復処理し、各ページにランクフィルタを適用するアプリケーション。この場合、`ImageLength = 0` のように細工された 2 番目の IFD が存在すると、クラッシュします。問題が発生するアクセスは、アドレス 0 への読み込み(または、`[-1]` の場合、ポインタ配列からの固定された小さな負のオフセットですが、これは最初の読み込みで既にエラーになるため到達しません)であり、クラッシュ以外のメモリの開示や破損は発生しません。同様の問題、つまり、公開 API を介してアクセスされる C コードでゼロ次元の画像がクラッシュを引き起こす問題は、`ImagingOffset` で修正されました(コミット ID: 39d14a0db、GHSA: f87v-9578-p47w)。

## 再現方法

`repro`は、インストール済みのPillowライブラリをそのまま利用する(再コンパイルしない)単一のPythonスクリプトです。各テストケースは、コアダンプを無効にして、新しい子プロセスで実行されます。これにより、セグメンテーション違反は、子プロセスの終了ステータスとして表示され、スクリプト自体は実行を継続します。各子プロセスは、`Image.new("L", (10, 10)).crop(box)`を作成し、そのサイズを表示し、`im.filter(...)`を呼び出し、次に結果のサイズを表示します。

- **制御テスト**: `(1, 10)`の領域を`MedianFilter(3)`で処理します。ランクフィルタは、小さな画像を処理し、正常に終了する必要があります。
- **PoC(Proof of Concept)テスト**: 空の領域に対するランクフィルタのテストです。
- `(0, 10)`の領域を`MedianFilter(3)`と`MaxFilter(5)`で処理します。
- `(10, 0)`の領域を`MinFilter(3)`で処理します。
- `(0, 0)`の領域を`RankFilter(3, 4)`で処理します。

標準のビルドでの期待される出力:

```
crop(3, 0, 4, 10) MedianFilter(3) (1, 10) -> (1, 10)
crop(3, 0, 3, 10) MedianFilter(3) (0, 10) -> killed by SIGSEGV
crop(3, 0, 3, 10) MaxFilter(5) (0, 10) -> killed by SIGSEGV
crop(0, 3, 10, 3) MinFilter(3) (10, 0) -> killed by SIGSEGV
crop(3, 3, 3, 3) RankFilter(3, 4) (0, 0) -> killed by SIGSEGV
BUG
```

`->`の前のサイズが表示されることで、エラーなく空の画像が作成されたことがわかります。クラッシュは、`filter()`関数内の`ImagingExpand`で発生します。最後の行が`BUG`である場合、制御テストが正常に終了し、少なくとも1つのランクフィルタのテストがSIGSEGVによって停止したことを意味します。それ以外の場合は`OK`と表示されます。このスクリプトは、クロップの処理パスのみをカバーしています。上記のTIFFの処理パスは、同様に失敗します (`im.seek(1); im.load(); im.filter(ImageFilter.MedianFilter(3))`)。これは、2ページ構成のTIFFファイルで、2番目のIFDに`ImageLength = 0`が設定されている場合に発生します。添付のパッチを適用すると、すべてのPoCテストで、サイズが変更されない状態で表示され(例:`(0, 10) -> (0, 10)`)、スクリプトは`OK`と表示されます。

## 提案される修正

添付のパッチは、`ImagingExpand`関数(`src/libImaging/Filter.c`)における、入力が空の場合の処理を修正しています。これは、`margin`のチェックの後、`ImagingNewDirty`によるメモリ確保と、複製処理のループの直前で行われます。`xsize`または`ysize`が0の場合、複製するエッジピクセルが存在しないため、入力画像のパレットをコピーした、拡張されたサイズのゼロで初期化された`ImagingNew`(`ImagingNewDirty`ではない)画像を返します。これにより、`ImagingExpand`関数のサイズに関する制約が維持され、`ImagingRankFilter`関数は、`xsize - 2*margin` × `ysize - 2*margin`、つまり元の空のサイズで出力を行います。これは、他のすべてのフィルタと一致します。この変更は、`src/libImaging/BoxBlur.c`(127004368)および`src/libImaging/Offset.c`(39d14a0db)で既に存在する、ゼロ次元の入力に対する早期リターン処理を反映しています。

```diff
--- a/src/libImaging/Filter.c
+++ b/src/libImaging/Filter.c
@@ -67,6 +67,17 @@ ImagingExpand(Imaging imIn, int margin) {
return (Imaging)ImagingError_ValueError("filter size too large");
}

+ if (imIn->xsize == 0 || imIn->ysize == 0) {
+ /* 複製するエッジピクセルが存在しない(また、読み込む行も存在しないため)、
+ 拡張されたサイズのゼロで初期化された画像を返す */
+ imOut = ImagingNew(imIn->mode, imIn->xsize + 2 * margin, imIn->ysize + 2 * margin);
+ if (!imOut) {
+ return NULL;
+ }
+ ImagingCopyPalette(imOut, imIn);
+ return imOut;
+ }
+
imOut =
ImagingNewDirty(imIn->mode, imIn->xsize + 2 * margin, imIn->ysize + 2 * margin);
if (!imOut) {
```

この修正は、`Tests/test_image_filter.py` に `test_rankfilter_zero_dimension` を `test_rankfilter_error` の隣に追加します。これは、`Image.new("L", size)` を `size` が `((0, 6), (6, 0), (0, 0))` の場合に、`MinFilter`、`MedianFilter`、`MaxFilter`、および `RankFilter(3, 2)` でフィルタリングし、サイズが維持されることを検証します。クリーンな状態からチェックアウトし、再構築すると、再現テストは `OK` と表示され、`Tests/test_image_filter.py` が合格します。`RankFilter.filter` 内の Python レベルのガード (`if 0 in image.size: return image.copy()`) もクラッシュを防ぐことができますが、Cレベルのチェックは `expand()` を呼び出す他のすべての箇所を保護します。

## 修正内容

入力画像の幅または高さがゼロの場合、`ImagingExpand` からゼロの画像データを返すように変更しました。これにより、NULL ラインポインタの読み込みを回避します。

この修正は、上記のコミットにおける `src/libImaging/Filter.c` に適用されます。

```
diff --git a/Tests/test_image_filter.py b/Tests/test_image_filter.py
index 974d4b7a3..8dc8e1a1a 100644
--- a/Tests/test_image_filter.py
+++ b/Tests/test_image_filter.py
@@ -155,6 +155,23 @@ def test_rankfilter_error(filter: ImageFilter.RankFilter) -> None:
im.filter(filter).getpixel((1, 1))


+@pytest.mark.parametrize(
+ "filter",
+ (
+ ImageFilter.MinFilter,
+ ImageFilter.MedianFilter,
+ ImageFilter.MaxFilter,
+ ImageFilter.RankFilter(3, 2),
+ ),
+)
+@pytest.mark.parametrize("size", ((0, 6), (6, 0), (0, 0)))
+def test_rankfilter_zero_dimension(
+ filter: ImageFilter.RankFilter, size: tuple[int, int]
+) -> None:
+ im = Image.new("L", size)
+ assert im.filter(filter).size == size
+
+
def test_rankfilter_properties() -> None:
rankfilter = ImageFilter.RankFilter(3, 2)

diff --git a/src/libImaging/Filter.c b/src/libImaging/Filter.c
index 3492a871f..d702db5f9 100644
--- a/src/libImaging/Filter.c
+++ b/src/libImaging/Filter.c
@@ -67,6 +67,17 @@ ImagingExpand(Imaging imIn, int margin) {
return (Imaging)ImagingError_ValueError("filter size too large");
}

+ if (imIn->xsize == 0 || imIn->ysize == 0) {
+ /* No edge pixels to replicate (and no lines to read from), so
+ return a zeroed image of the expanded size */
+ imOut = ImagingNew(imIn->mode, imIn->xsize + 2 * margin, imIn->ysize + 2 * margin);
+ if (!imOut) {
+ return NULL;
+ }
+ ImagingCopyPalette(imOut, imIn);
+ return imOut;
+ }
+
imOut =
ImagingNewDirty(imIn->mode, imIn->xsize + 2 * margin, imIn->ysize + 2 * margin);
if (!imOut) {
```

## 再現手順

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

最後の行が問題が発生すると `BUG`、発生しない場合は `OK` と表示されます。そのコミットをクリーンな状態でビルドすると、`BUG` と表示されました。

```python
#!/usr/bin/env python3
# Pillow: 幅または高さが0の画像に対するランクフィルタ (Median/Min/Max/RankFilter)
# ImagingExpand (src/libImaging/Filter.c) でのSIGSEGVエラー - NULLの行ポインタからの読み込み。
# 各テストケースは子インタープリタで実行されます。制御が戻り、いずれかのゼロサイズのケースでSIGSEGVが発生した場合、それはバグです。
import signal, subprocess, sys

CHILD = """
import resource; resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
from PIL import Image, ImageFilter
im = Image.new("L", (10, 10)).crop(%s) # public API; cropはx0==x1 / y0==y1を受け入れる
print(im.size, "->", end=" ", flush=True)
print(im.filter(ImageFilter.%s).size)
"""

def run(box, flt):
p = subprocess.run([sys.executable, "-c", CHILD % (box, flt)],
capture_output=True, text=True)
out = p.stdout.strip()
if p.returncode < 0:
out += " killed by " + signal.Signals(-p.returncode).name
print(f" crop{box!s:<16} {flt:<18} {out}")
return p.returncode

ctrl = run((3, 0, 4, 10), "MedianFilter(3)") # (1,10): 正常に動作する必要がある
rcs = [run((3, 0, 3, 10), "MedianFilter(3)"), # (0,10)
run((3, 0, 3, 10), "MaxFilter(5)"),
run((0, 3, 10, 3), "MinFilter(3)"), # (10,0)
run((3, 3, 3, 3), "RankFilter(3, 4)")] # (0,0)
bug = ctrl == 0 and any(rc == -signal.SIGSEGV for rc in rcs)
print("BUG" if bug else "OK")
```
</details>

---

影響を受けるパッケージ:
- pip (Python) Pillow: 影響を受けるバージョン <= 12.3.0、修正は13.0.0で実施。