CVE-2026-108359: number-rows-repeated had no ceiling
An ODS file can tell a reader to repeat one row a few million times. PhpSpreadsheet did exactly that, one real cell object per repeat, with no upper bound — a ~3 KB file that exhausts a worker's memory. Two earlier CVEs had already taught the Xml and Xlsx readers to clamp the row index; the ODS reader, which carries the strongest multiplier of the three, had never been given the same ceiling.
Disclaimer. This is personal security research, carried out on my own equipment against a public open-source project. It has no connection to any current or former employer, and no client, employer or production environment was involved at any point. Every file in it was parsed by the library in a process on my own machine. The issue was reported privately through the project’s security advisory process; another researcher had reported the same issue earlier, mine was closed as a duplicate, and I hold analyst credit on the published advisory. It is written up here after the advisory, the fixed releases and the CVE assignment were public.
OpenDocument spreadsheets have a shorthand for a repeated row. Instead of writing a thousand identical empty rows, the file writes one row and an attribute, table:number-rows-repeated="1000", and the reader is expected to expand it. It is an ordinary and useful part of the format — a blank sheet is mostly repeated-empty rows, and the attribute keeps the file small.
The attribute is also a multiplier an attacker fills in. PhpSpreadsheet’s ODS reader took the number at face value and created one real Cell object for every repeat, with nothing checking how large the number was. A file of about three kilobytes could ask for five million rows and the reader would try to build all of them.
What makes this worth a write-up is not the single reader. It is that the project had already met this exact shape twice this year, in two sibling readers, and fixed it both times — and the one reader with the largest multiplier was the one the fix never reached.
The same bug, already fixed twice
A spreadsheet reader turns a small document into a large in-memory grid, so every reader is a potential amplifier, and PhpSpreadsheet has three that parse different formats into the same Worksheet. Two of them had been patched earlier in 2026:
- CVE-2026-40863 bounded the materialised row index in the SpreadsheetML (
Xml) reader. - CVE-2026-40902 bounded the row number in the
Xlsxreader.
Both fixes are a single comparison against the sheet’s physical maximum. In the Xml reader at the version I was looking at, it reads:
// src/PhpSpreadsheet/Reader/Xml.php:410
if ($rowID < 1 || $rowID > AddressRange::MAX_ROW) { // MAX_ROW = 1048576
A spreadsheet has a fixed number of rows — MAX_ROW is 1,048,576, the same ceiling the desktop applications enforce — so any row index past it cannot be real data and is refused before a cell is built. That is the whole defence, and it was in two readers.
The third reader, Ods, is the one with the strongest multiplier of the three, because number-rows-repeated lets a single element stand in for an arbitrary row count directly. grep -n MAX_ROW src/PhpSpreadsheet/Reader/Ods.php at that version returns nothing. The ceiling the other two readers had been given was never carried here.
The project’s own advisory states this plainly, calling it “a third, independent instance of the root cause behind CVE-2026-40863 and CVE-2026-40902.”
The loop with no ceiling
The repeat count is read with a plain integer cast and no bound:
// src/PhpSpreadsheet/Reader/Ods.php — the repeat count, uncapped
$rowRepeats = (int) $childNode->getAttributeNS($tableNs, 'number-rows-repeated');
Then, for a cell that carries a value, it is materialised once per repeat:
for ($rowAdjust = 0; $rowAdjust < $rowRepeats; ++$rowAdjust) { // unbounded
$rID = $rowID + $rowAdjust;
$cell = $spreadsheet->getActiveSheet()->getCell($columnID . $rID); // a real Cell
// ... setValueExplicit(...) ...
}
Two details decide what this is, and the report dwelt on both because they are what separates a vulnerability from ordinary large-file behaviour.
It needs a value cell. The materialisation only happens when the cell’s type is not NULL. An empty repeated cell — which is how the format pads a row out to a thousand columns, the common, legitimate use of the attribute — is skipped. So the payload is not a normal padded sheet. It is a single cell carrying a value, office:value-type="float", with a large repeat count. That one difference is the whole exploit, and it is trivial to write.
The columns were already safe, by accident. The column side of the same code could not run away, because Coordinate::columnIndexFromString throws once the column passes XFD (16,384). That ceiling existed. The row side had no equivalent. So within one reader, one axis was bounded and the other was not.
Reaching it needs nothing unusual. IOFactory::load($file) on a file auto-detected as ODS, with the default read filter, walks straight into this. No option, no flag, no configuration.
Before and after, on the published releases
I rebuilt the before-and-after today against the real packages rather than trusting the earlier run. A valid ODS written by PhpSpreadsheet’s own writer, with the data row’s number-rows-repeated set to each value, about 3.2 KB on disk, loaded on PHP 8.3 with a 512 MB limit:
number-rows-repeated |
5.9.0 (last vulnerable) | 5.10.0 (fixed) |
|---|---|---|
| 100 | loads, 6 MB | loads, 6 MB |
| 100,000 | loads, 60 MB | loads, 60 MB |
| 5,000,000 | fatal: memory exhausted | refused — “Invalid number-rows-repeated” |
1,048,577 (MAX_ROW + 1) |
— | refused |
The 100,000 row is the interesting one, because it loads on both versions and uses 60 MB on both. The fix does not make large allocations small. It refuses only the repeats that would run the row index past the sheet’s real maximum, the same line the two sibling readers draw. A file can still ask for a million legitimate rows and get a million cells; what it can no longer do is ask for five million, or ten billion, and watch the process die. That is the correct ceiling for this class — the one the format itself already implies — and it is exactly what the two earlier CVEs established.
The fix
The patch, 2de2938, adds the check the other two readers already had, in the form the ODS reader needs. Rather than one clamp it adds two small helpers and calls them wherever a repeat is consumed, on both the full-load path and the listWorksheetInfo path:
private static function checkRowsRepeated(int $rowID, int $rowRepeats): void
{
if ($rowRepeats < 1 || $rowID + $rowRepeats - 1 > AddressRange::MAX_ROW) {
throw new Exception("Invalid number-rows-repeated $rowRepeats following row $rowID");
}
}
The column helper is the same idea against MAX_COLUMN_INT, which closes the matching column-repeat case belt-and-braces even though the existing XFD throw already covered it. The commit also replaces the two separate per-CVE tests from the earlier fixes with a parametrised test that drives a bad row repeat and a bad column repeat through both the load() and the listWorksheetInfo() entry points, so the two paths can no longer drift apart.
The releases carrying it had actually shipped weeks before the record caught up: 5.10.0, 3.10.8, 2.4.8, 2.1.19 and 1.30.7 all went out on 17 September, across five supported branches. What was held was the advisory, not the code.
Two reports, and the record
I was not the first to report this. Another researcher, luantq0, had filed it earlier; when my report went in on 30 August it was accepted and then closed the same day as a duplicate of theirs. On the published advisory, GHSA-j2w7-387v-m2rq, luantq0 is credited as the reporter and I am credited as analyst. That is the honest order of things: they got there first, and I found and documented the same issue independently.
The identifier is where the story slows down, and it is a story about GitHub rather than the project. oleibman requested a CVE on 27 August, the day the primary was accepted. On 9 September he named the reason it had not arrived — “There is now a very severe lag at Github producing CVE’s” — and said he would ship the patch but hold the advisory until the number came. The patches shipped on 17 September. The number, CVE-2026-108359, was assigned on 12 October, forty-six days after the request, and the advisory was published three and a half hours later. As I write this the CVE record itself has not yet appeared at the CVE Program — the advisory is public and the ID is assigned, but cveawg still answers that the record does not exist. That is the same gap I saw on an earlier finding this autumn: the identifier is real and the advisory is live, and the record is simply somewhere in the queue behind them.
Advisory GHSA-j2w7-387v-m2rq · CVE CVE-2026-108359 · Fix 2de2938 · Affected <= 5.9.0 and the earlier branches, fixed in 5.10.0, 3.10.8, 2.4.8, 2.1.19, 1.30.7
Reported first by luantq0; my independent report sent 30 August and closed the same day as a duplicate, with analyst credit accepted. Patches released 17 September · CVE assigned and advisory published 12 October · CVE record not yet live at the CVE Program at the time of writing.
Eurico Nicacio — @h3llh0und