Pillowの`Image.transform()`関数において、`calloc`の失敗時に、`ImagingScaleAffine`関数で二重にメモリが解放される問題があります。これにより、大きな変換処理中にメモリが不足した場合に、プログラムがクラッシュする可能性があります。

```
============================================================================
Pillowの`Image.transform()`関数における、`calloc`の失敗時の`ImagingScaleAffine`関数での二重メモリ解放
場所: src/libImaging/Geometry.c:866 (関数: ImagingScaleAffine)
コミット: a707c6b21c313880d0771e54965d142dd42358b6
============================================================================
```

通常、`MemoryError`が発生するだけで済むはずですが、Pillowはエラー処理中に出力画像のメモリを一度解放し、さらにPythonオブジェクトのクリーンアップ時に再度解放します。`Image.transform()`の出力サイズを自由に選択できるユーザーは、この問題を発生させることができます。ただし、これはメモリ制限のあるワーカープロセス、または32ビットプロセスでのみ発生します。その結果、ワーカープロセスがクラッシュし、さらにヒープが破損する可能性があります。このヒープの破損は、制御可能かどうかは不明です。

バグは `src/libImaging/Geometry.c` の `ImagingScaleAffine()` 関数 (866行目) にあります。この関数が、各列のルックアップテーブル用の `4 × width` バイトのメモリを `calloc()` で確保できない場合、`ImagingDelete(imOut)` を呼び出します。この関数が `Image.transform()` から呼び出されると、`imOut` は、動作中の Python の `ImagingCore` オブジェクトが所有する `Imaging` 構造体です。その後、`src/_imaging.c` の `_dealloc()` 関数が、解放済みの構造体に対して再度 `ImagingDelete()` を呼び出し、解放済みのメモリにロックや参照カウントを書き込み、ピクセルブロックを2回目に解放します。この問題は、デフォルトのニアレスト法によるリサンプリングで、`Transform.EXTENT` または純粋なスケール/トランスレート行列を使用した `Transform.AFFINE` を実行する際に発生します。非常に幅広く、1行の出力サイズにすると、ルックアップテーブルの確保が失敗し、画像自体はまだメモリに収まる場合があります。デフォルトのオーバーコミット設定が有効な 64 ビット Linux では、`ulimit -v`、`uwsgi --limit-as`、または `systemd LimitAS=` のようなアドレス空間の制限が必要です。`resize()`、`thumbnail()`、および `rotate()` 関数は影響を受けません。添付の再現スクリプトは、1 GB の制限で動作する標準的なビルドで実行できます。`transform((300000000, 1), EXTENT)` を実行すると、`MemoryError` が発生し、キャッチされた後、プロセスが `_dealloc()` から呼び出された `free()` で SIGSEGV を引き起こして終了します。メモリチェック機能付きのビルドでは、同じパスがヒープにおける使用後の解放として報告されます。この問題のある行は、元のフォークコミットで PIL 1.1.7 から引き継がれており、そのため Pillow の 1.0 以降のすべてのバージョンが影響を受けています。修正パッチが添付されています。

## 詳細

`ImagingScaleAffine()` 関数は、所有していない画像を解放します。特定の状況下で、列ごとのルックアップテーブル用のメモリを `calloc()` で確保できない場合、`ImagingDelete(imOut)` を呼び出します。`Image.transform()` を経由して呼び出された場合、`imOut` は、動作中の Python の `ImagingCore` オブジェクトが保持する `Imaging` 構造体です。その後、そのオブジェクトの `_dealloc()` 関数が、すでに解放された構造体に対して再度 `ImagingDelete()` を呼び出し、ピクセルブロックを2回目に解放します。

標準的なビルドでの最短のデモンストレーション (アドレス空間の制限は、ワーカー上で `ulimit -v` / `uwsgi --limit-as` / `systemd LimitAS=` の代わりになります):

```python
import resource, gc
from PIL import Image
resource.setrlimit(resource.RLIMIT_AS, (1_000_000_000,) * 2)
src = Image.new("L", (8, 8))
try:
src.transform((300_000_000, 1), Image.Transform.EXTENT, (0, 0, 8, 8))
except MemoryError:
pass # 予想通りであり、プロセスは続行されるべき
gc.collect() # 代わりに、_dealloc -> ImagingDelete -> free() で SIGSEGV が発生する
```

300MB の出力画像が割り当てられますが、`calloc(300000000, 4)` (1.2GB) は割り当てられません。 `MemoryError` は意図したとおりに発生しますが、その後、インタプリタが SIGSEGV でクラッシュします。gdb を使用すると、`free()` が `memory_return_block`/`ImagingDestroyArray` (`src/libImaging/Storage.c:396,408`) から呼び出され、それが `ImagingDelete` (`Storage.c:271`) から、さらに `_dealloc` (`src/_imaging.c:188`) から呼び出されていることがわかります。 同じソースコードを AddressSanitizer でコンパイルした場合(`max_allocation_size_mb` を使用して `calloc` が失敗するようにし、rlimit を使用しない)、`ImagingDelete src/libImaging/Storage.c:257` で `heap-use-after-free` が報告されます。これは、`_dealloc src/_imaging.c:188` から呼び出されています。128 バイトの領域が、`ImagingDelete` ← `ImagingScaleAffine src/libImaging/Geometry.c:866` ← `ImagingTransformAffine Geometry.c:1043` ← `ImagingTransform Geometry.c:1135` ← `_transform src/_imaging.c:2153` によって「解放された」ものであり、以前は `ImagingNewPrologueSubtype Storage.c:53` ← … ← `_new src/_imaging.c:756` によって「割り当てられた」ものです。

### コードの実行パス

`Image.transform()` は、出力画像を通常の Python が所有する画像として作成し、その後、その画像に変換を行います。

```python
# src/PIL/Image.py:3008…3021
im = new(self.mode, size, fillcolor)
…
im.__transformer(
(0, 0, *size), self, method, data, resample, fillcolor is None
)
```

`__transformer()` は、`Transform.EXTENT` を軸に沿ったアフィン行列に書き換えます (`src/PIL/Image.py:3040-3046`: `data = (xs, 0, x0, 0, ys, y0)`, `method = Transform.AFFINE`)。そして、`self.im.transform(box, image.im, method, data, resample, fill)` (`Image.py:3117`) を呼び出します。 `resample` はデフォルトで `Resampling.NEAREST` (`Image.py:2936`) に設定されています。

C言語では、`_transform()`関数は、変換後の画像を自身の画像として扱います。

```c
/* src/_imaging.c:2153 */
imOut = ImagingTransform(
self->image, imagep->image, method, x0, y0, x1, y1, a, filter, fill
);
```

`ImagingTransformAffine()`関数は、フィルタがNEARESTであり、行列にせん断成分がないため、高速なスケーリング処理を使用します。

```c
/* src/libImaging/Geometry.c:1035-1043 */
if (filterid || imIn->type == IMAGING_TYPE_I16) {
return ImagingGenericTransform(…);
}
if (a[1] == 0 && a[3] == 0) {
/* スケーリング */
return ImagingScaleAffine(imOut, imIn, x0, y0, x1, y1, a, fill);
}
```

そして、`ImagingScaleAffine()`関数は、`imOut->xsize`個の整数を格納するテーブルを割り当てます。

```c
/* src/libImaging/Geometry.c:863-868 */
/* mallocのチェックは問題ありません。オーバーフロー対策としてcallocを使用します */
xintab = (int *)calloc(imOut->xsize, sizeof(int));
if (!xintab) {
ImagingDelete(imOut); /* <-- imOutは呼び出し元が所有 */
return (Imaging)ImagingError_MemoryError();
}
```

`ImagingDelete()`関数(`src/libImaging/Storage.c:250-279`)は、参照カウントを0に設定し、ピクセルブロックをメモリ領域に返却するか、`im->destroy`を介して`free()`で解放し、さらに`free(im)`を実行します。`ImagingCore`オブジェクト内の`self->image`は、解放された構造体へのポインタのままです。`_transform()`関数は`NULL`を返し、`Image.transform()`関数は`MemoryError`を伝播させ、まだ作成途中の出力`Image`オブジェクトが最後の参照を失い、`_dealloc()`関数(`src/_imaging.c:188`)が再度`ImagingDelete(imagep->image)`を呼び出します。これにより、`im->mutex`がロックされ、`im->refcount`がデクリメントされますが、これは解放済みのメモリ領域内で行われます。その後、`im->blocks`(すでに解放されており、通常は再利用されています)を走査し、各エントリが2回目に解放されます。観察された標準ビルドの実行では、解放された`blocks`配列が上書きされていたため、`free()`関数に不正なポインタが渡され、プロセスがセグメンテーション違反を引き起こしました。他のヒープの状態では、これは単に300MBのブロックが2回解放されるという問題です。

`Geometry.c`における他のエラー発生時(`ImagingError_ModeError`、`ImagingError_Mismatch`、"bad filter number"、"bad transform method")は、`imOut`をそのままにしておきます。866行のみが`imOut`を削除します。この削除は、別の呼び出し元である`_resize()`(`src/_imaging.c:2026-2030`)でのみ正しい動作となります。`_resize()`では、`imOut`は他の場所から参照されない一時的な`ImagingNewDirty()`によって作成されます。この削除は、Pillowのgit履歴以前のものであり、PIL 1.1.7のインポート(`9a640e315715`、"Forking PIL"、2010年7月30日)によって導入されました。そのため、Pillow 1.0以降のすべてのバージョンが影響を受けています。

出力画像の幅によって、処理が成功するかどうかが決まります。出力画像は`幅 × 高さ × 1ピクセルあたりのサイズ`のバイト数、テーブルはさらに`4 × 幅`のバイト数を消費します。非常に幅が広く、1行しかない出力(例えば、300,000,000 × 1の"L"形式の画像で300MB、テーブルで1.2GB)の場合、テーブルのメモリ確保が失敗する可能性があります。`ImagingNewPrologueSubtype()`は`xsize`を`INT_MAX/4 - 1`に制限しているため、テーブルの最大サイズは約2GBになります。

## 影響

`Image.transform()`関数の出力サイズをユーザーが選択できるアプリケーションにおいて、`Transform.EXTENT`(トリミングと拡大)、純粋な拡大/移動行列を使用した`Transform.AFFINE`、または`ImageTransform.ExtentTransform`/`AffineTransform`ヘルパーを使用する場合、デフォルトのNEARESTリサンプリングが適用されます。しかし、これらの処理において、メモリ割り当ての失敗が、APIが本来発生させるはずの`MemoryError`ではなく、ワーカープロセス内でuse-after-free(解放済みのメモリへのアクセス)やdouble free(同じメモリ領域の二重解放)を引き起こす可能性があります。`resize()`、`thumbnail()`、および`rotate()`関数は、この問題を引き起こしません。`Image.resize(..., NEAREST, box=...)`関数は`ImagingScaleAffine()`関数を経由しますが、その出力は所有され、クリーンな`MemoryError`が発生します。

2つ目の前提条件は、出力画像自体はメモリを割り当てますが、追加の`4 × width`バイト分のメモリは割り当てられないことです。64ビットLinux環境でデフォルトのオーバーコミット設定を使用している場合、この状況が発生するには、プロセスに対してアドレス空間の制限を設定する必要があります(`ulimit -v`、`uwsgi --limit-as`、`systemd LimitAS=`、またはアプリケーション内での`setrlimit`)。これは、画像処理を行うプロセスに対する一般的なセキュリティ対策です。32ビットプロセスでは、通常のメモリ不足によって同様の問題が発生する可能性があります。

この問題が発生すると、リクエストを処理しているプロセスが、`MemoryError`が発生し、おそらく処理された後にクラッシュします。2回目の`ImagingDelete()`関数も、解放された128バイトの構造体(mutex、参照カウント)に書き込みを行い、攻撃者が指定したサイズのメモリ領域を二重に解放します。これは、クリーンな異常終了ではなく、ヒープの破損を引き起こしますが、書き込まれるデータの制御は示されていません。

## 再現方法

任意のディレクトリから `repro` (スタンドアロンのPythonスクリプト) を実行します。これはインストール済みの標準ビルドを使用するため、再コンパイルは不要で、関連ファイルもありません。このスクリプトは、上記のデモンストレーションを子インタープリタ (`python3 -c`) で実行します。この子インタープリタは、`WIDTH` を引数として受け取り、コアファイルを無効にし、`RLIMIT_AS` を 1 GB に設定します (これは、画像処理タスクに一般的に割り当てられる制限値です)。次に、8×8 の "L" 画像を作成し、デフォルトのニアレストリサンプリングを使用して `src.transform((WIDTH, 1), Image.Transform.EXTENT, (0, 0, 8, 8))` を呼び出します。もし `MemoryError` が発生した場合、それを検出し、`gc.collect()` を実行し、`alive` と表示します。`EXTENT` と `NEAREST` の組み合わせは単純な拡大であるため、この呼び出しは `ImagingScaleAffine()` を経由します。`ImagingScaleAffine()` は、出力画像の `WIDTH` バイトに加えて、`4 × WIDTH` バイトのテーブルを動的に確保します。

このスクリプトは、子プロセスを2回実行します。

- **コントロール:** 幅 50,000,000。50 MB の画像と 200 MB のテーブルの両方が制限値内に収まるため、変換は成功します。これは、制限自体が問題ないことを示しています。
- **攻撃:** 幅 300,000,000。300 MB の出力画像を確保しようとしますが、`calloc(300000000, 4)` (1.2 GB) が失敗します。Pillow は正しく `MemoryError` を発生させ、子プロセスがそれを捕捉します。しかし、`ImagingScaleAffine()` は、所有していない出力画像に対してすでに `ImagingDelete()` を呼び出しています。出力画像が解放されると、再び解放されます。子プロセスは `MemoryError caught` と表示し、`alive` と表示する代わりに、SIGSEGV で終了します。

攻撃を行う子プロセスがシグナルによって終了し、`gdb` がインストールされている場合、スクリプトは `gdb` を使用して攻撃を再度実行し、バックトレースを表示します。クラッシュが発生するのは `free()` ← `memory_return_block`/`ImagingDestroyArray` (`Storage.c:396,408`) ← `ImagingDelete` (`Storage.c:271`) ← `_dealloc` (`_imaging.c:188`) の部分です。つまり、Python オブジェクトのデストラクタが、すでに解放された構造体を2回目に削除しようとしています。このステップの結果は、最終的な判断には影響しません。

最後の行が `BUG` と表示されるのは、制御が 0 で終了し、攻撃が `MemoryError caught` と表示された後、`alive` を表示せずにシグナルによって終了する場合です。それ以外の場合は `OK` と表示されます。 添付された修正を適用すると、攻撃は `MemoryError caught` と表示され、その後 `alive` と表示され、制御が 0 で終了し、スクリプトは `OK` と表示されます。

## 提案される修正

`ImagingScaleAffine()` は、`imOut` を所有することはありません。 添付された修正は、`src/libImaging/Geometry.c` の 866 行にある `ImagingDelete()` を削除し、それを使用していた `_resize()` 関数(`src/_imaging.c` 内)が、自身のテンポラリ変数を適切に解放するように変更します。 これは、`_gaussian_blur()`/`_unsharp_mask()` で既に採用されている方法と同じです。 また、この修正は、`_resize()` が `ImagingNewDirty()` から返される可能性のある `NULL` を `ImagingTransform()` に渡す問題を修正し、`ImagingTransform()` がエラーを返した場合にテンポラリ変数が解放されない問題を修正します。

```diff
--- a/src/libImaging/Geometry.c
+++ b/src/libImaging/Geometry.c
@@ -863,7 +863,7 @@ ImagingScaleAffine(
/* malloc check ok, uses calloc for overflow */
xintab = (int *)calloc(imOut->xsize, sizeof(int));
if (!xintab) {
- ImagingDelete(imOut);
+ /* imOut is owned by the caller; don't delete it here */
return (Imaging)ImagingError_MemoryError();
}
--- a/src/_imaging.c
+++ b/src/_imaging.c
@@ -2024,10 +2024,16 @@ _resize(ImagingObject *self, PyObject *args) {
a[5] = box[1];

imOut = ImagingNewDirty(imIn->mode, xsize, ysize);
+ if (!imOut) {
+ return NULL;
+ }

- imOut = ImagingTransform(
- imOut, imIn, IMAGING_TRANSFORM_AFFINE, 0, 0, xsize, ysize, a, filter, 1
- );
+ if (!ImagingTransform(
+ imOut, imIn, IMAGING_TRANSFORM_AFFINE, 0, 0, xsize, ysize, a, filter, 1
+ )) {
+ ImagingDelete(imOut);
+ return NULL;
+ }
} else {
```

すべての `ImagingTransform*()` 関数は、成功した場合に同じ `imOut` を返します。したがって、`_resize()` 関数で元のポインタを `PyImagingNew()` でラップする処理は変更されていません。パッチを適用してクリーンな状態から再構築すると、再現スクリプトは `OK` と表示し、`Image.resize(..., NEAREST, box=...)` を同じ制限下で使用すると、エラーメッセージが表示されます。

## パッチの内容

`ImagingScaleAffine()` 関数が、呼び出し元が所有する出力画像を `calloc` の失敗時に削除しないように修正します。また、`_resize()` 関数が、自身のテンポラリメモリを解放するように修正します。

これは、上記のコミットで `src/libImaging/Geometry.c` に適用されます。

```diff
diff --git a/src/_imaging.c b/src/_imaging.c
index 7e9c0e391..ac1873292 100644
--- a/src/_imaging.c
+++ b/src/_imaging.c
@@ -2024,10 +2024,16 @@ _resize(ImagingObject *self, PyObject *args) {
a[5] = box[1];

imOut = ImagingNewDirty(imIn->mode, xsize, ysize);
+ if (!imOut) {
+ return NULL;
+ }

- imOut = ImagingTransform(
- imOut, imIn, IMAGING_TRANSFORM_AFFINE, 0, 0, xsize, ysize, a, filter, 1
- );
+ if (!ImagingTransform(
+ imOut, imIn, IMAGING_TRANSFORM_AFFINE, 0, 0, xsize, ysize, a, filter, 1
+ )) {
+ ImagingDelete(imOut);
+ return NULL;
+ }
} else {
imOut = ImagingResample(imIn, xsize, ysize, filter, box);
}
diff --git a/src/libImaging/Geometry.c b/src/libImaging/Geometry.c
index 1bf92e26e..2baf000a5 100644
--- a/src/libImaging/Geometry.c
+++ b/src/libImaging/Geometry.c
@@ -863,7 +863,7 @@ ImagingScaleAffine(
/* malloc check ok, uses calloc for overflow */
xintab = (int *)calloc(imOut->xsize, sizeof(int));
if (!xintab) {
- ImagingDelete(imOut);
+ /* imOut is owned by the caller; don't delete it here */
return (Imaging)ImagingError_MemoryError();
}
```

## 再現スクリプト

<details>
<summary>再現スクリプト — <code>repro</code>, 2,064 bytes (クリックして開きます)</summary>

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

```python
#!/usr/bin/env python3
# ImagingScaleAffine() 関数における UAF/ダブルフリー (src/libImaging/Geometry.c:866):
# calloc() が失敗した場合、ImagingDelete() が imOut を呼び出し、これは Image.transform() の場合、
# 実行中の Python の出力画像が所有する Imaging 構造体です。その画像の
# _dealloc メソッドが再び ImagingDelete() を呼び出します。標準の /src ビルドを使用し、再ビルドは行いません。
# 1 GB の RLIMIT_AS を設定すると、300 MB の出力画像は問題なく動作しますが、1.2 GB の
# 4*width のルックアップテーブルではエラーが発生します。期待される結果: MemoryError が発生し、プロセスは続行します。
import subprocess, sys, textwrap, signal

child = textwrap.dedent('''
import resource, gc, sys
from PIL import Image
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
resource.setrlimit(resource.RLIMIT_AS, (1_000_000_000,) * 2)
w = int(sys.argv[1])
src = Image.new("L", (8, 8))
try:
src.transform((w, 1), Image.Transform.EXTENT, (0, 0, 8, 8))
print("transform ok")
except MemoryError:
print("MemoryError caught")
gc.collect()
print("alive")
''')

def run(width):
p = subprocess.run([sys.executable, "-u", "-c", child, str(width)],
capture_output=True, text=True, timeout=50)
print(f"-- width={width}: rc={p.returncode}\n{p.stdout.rstrip()}")
return p
```

```
ctrl = run(50_000_000) # 50 MBの画像と200 MBのテーブルは制限内に収まる
att = run(300_000_000) # 300 MBの画像は収まるが、1.2 GBのテーブルは収まらない
bug = (ctrl.returncode == 0 and "MemoryError caught" in att.stdout
and "alive" not in att.stdout and att.returncode < 0)
if att.returncode < 0:
print("attack child killed by", signal.Signals(-att.returncode).name)
import shutil
if shutil.which("gdb"): # 証拠のみ; 判定はこれに依存しない
g = subprocess.run(["gdb", "-q", "-batch", "-ex", "run", "-ex", "bt 6", "--args",
sys.executable, "-c", child, "300000000"],
capture_output=True, text=True, timeout=120)
print("\n".join(l for l in g.stdout.splitlines()
if l.startswith("#") or "signal" in l))
print("BUG" if bug else "OK")
```

---

影響を受けるパッケージ:
- pip (Python) Pillow: 影響あり <= 12.3.0、13.0.0で修正済み
```