Skip to content

Instantly share code, notes, and snippets.

@terjanq
Last active August 16, 2025 19:15
Show Gist options
  • Save terjanq/fa6f19d46bcb85bb61c146747dec0758 to your computer and use it in GitHub Desktop.
Save terjanq/fa6f19d46bcb85bb61c146747dec0758 to your computer and use it in GitHub Desktop.
Positive Players | justCTF2025

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!

https://g.co/gemini/share/44bdfb8969aa

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? 🫣

Prototype Pollution

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!

Logging in as toString user

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');
}

Getting the flag

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}

Closing thoughts

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.

into

@BugAnnihilator
Copy link

Now I understand why CTF players have different mentality!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment