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 ;)
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
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:
$ 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?
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!
Response (note the Location
header, but still a body containing the flag):
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:
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:
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`);
}