Positive Players | Write-up by @terjanq
From justCTF2025
A super secure application generated by the overlords for our positive players. Don't overthink it—it's not too hard—but try to think outside the box!
Vibe coding is the future. Good luck and have fun!
After the initial couple of hours of the competition, all Web challenges remained unsolved, whereas other categories had challenges labeled as baby
. These challenges are meant to be approachable for beginners, and I was sad that we did not have even easy Web challenges this year.
A couple of years ago, I came up with a Prototype Pollution variation unknown to the public that bypasses common mitigation strategies of blocklisting __proto__
, constructor
, and prototype
properties. I figured that with enough guidance, it would make a great "baby challenge" for players just learning about Web security!
During the competition, I started thinking about a simple application that would be vulnerable and started chatting with Gemini to create a simple Express application that allows for theme customization. To my surprise, it created the perfect application for exploiting the vulnerability, even without me asking it to do so! 😂 (See the conversation at: https://g.co/gemini/share/44bdfb8969aa)
I applied only a few patches to the generated application (see the diff), made it work with our infrastructure, and released it to the players 7 hours after the competition started.
To my surprise, it did not receive any solves in the first 90 minutes, so I released an additional hint:
Is Prototype Pollution sufficiently patched? 🫣
The first part of the solution was to think of a way to find a Prototype Pollution vulnerability in the parseQueryParams
method.
// 7. A function to parse a query string with dot-notation keys.
const parseQueryParams = (queryString) => {
if (typeof queryString !== 'string') {
return {};
}
const cleanString = queryString.startsWith('?') ? queryString.substring(1) : queryString;
const params = new URLSearchParams(cleanString);
const result = {};
for (const [key, value] of params.entries()) {
const path = key.split('.');
let current = result;
for (let i = 0; i < path.length; i++) {
let part = path[i];
// Protect against Prototype Pollution vulnerability
if(['__proto__', 'prototype', 'constructor'].includes(part)){
part = '__unsafe$' + part;
}
if (i === path.length - 1) {
current[part] = value;
} else {
if (!current[part] || typeof current[part] !== 'object') {
current[part] = {};
}
current = current[part];
}
}
}
return result;
};
Initially, Gemini generated code vulnerable to Prototype Pollution, but I patched it in a way that is very common for real-world applications - by restricting prototype
, __proto__
and constructor
properties. With these restrictions, Object.prototype
cannot be polluted here, but what about Object.prototype.toString
? Maybe that would work?
For the query string toString.isAdmin=1
, the method would not cause Prototype Pollution because of the typeof current[part] !== 'object'
check. toString
is a function
type, not an object
, so the function returns the correct dictionary:
{
toString: {
isAdmin: 1
}
}
This object was further parsed by another method generated by Gemini, deepMerge
, which was also vulnerable to Prototype Pollution 😅
// 6. A function to recursively merge objects
const deepMerge = (target, source) => {
for (const key in source) {
if (source[key] instanceof Object && key in target) {
Object.assign(source[key], deepMerge(target[key], source[key]));
}
}
Object.assign(target || {}, source);
return target;
};
/* ... */
// Parse the query string into a nested object
const queryString = req.url.split('?')[1] || '';
const parsedUpdates = parseQueryParams(queryString);
// If there are updates, merge them into the existing config.
if (Object.keys(parsedUpdates).length > 0) {
// Merge the parsed updates into the user's theme config.
user.themeConfig = deepMerge(user.themeConfig, parsedUpdates);
}
After another round, we could successfully pollute Object.prototype.toString.isAdmin
!
If we tried to register as a toString
user, we would get an error that the user already exists. This is because of the following code.
// 4. In-memory user data store (simulating a database)
// In a real application, you would use a database like MongoDB or PostgreSQL.
// For this CTF scenario, passwords are stored in plaintext.
const users = {}; // Stores { username: { password, userThemeConfig, isAdmin } }
/* ... */
const { username, password } = req.body;
if (users[username]) {
req.session.errorMessage = 'User already exists!';
return res.redirect('/register');
}
users['toString']
indeed exists on the users
object. What would its password be? It would be undefined
. By sending a login request without the password
field, we would successfully log in as the toString
user because undefined === undefined
!
It was also possible to pollute password property via toString.isAdmin=1&toString.password=pwd
and simply log in as toString:pwd
but it was less cool :)
const { username, password } = req.body;
const user = users[username];
// Comparing the plaintext password for the CTF scenario.
// DO NOT do this in a real application!
if (user && user.password === password) {
req.session.userId = username;
res.redirect('/');
} else {
req.session.errorMessage = 'Invalid username or password';
res.redirect('/login');
}
After successfully logging in as the toString user, the player had to simply visit the /flag
endpoint that prints the flag.
if(users[req.session.userId].isAdmin == true){
return res.end(FLAG);
}
return res.end("Not admin :(");
justCTF{This_Prototype_Pollution_variant_actually_works_on_real_apps!_3rcwtirsieh}
This variation of Prototype Pollution is quite common in real-world applications. The challenging part is to find vulnerable paths that require another injection point, as opposed to the classic PP variant and common PP gadgets.
It was solved by 30 teams, and players seemed to have really enjoyed the challenge, even though some did not manage to solve it before the competition ended.
Now I understand why CTF players have different mentality!