Hey! Not your first time visiting our CTF Recaps? Go ahead and jump on down to the insights! If it is your first time, allow us to give a quick rundown of what these are. Wizer CTFs were launched to challenge developers to learn to think like a hacker in order to learn to code more securely. It's part of our new security awareness training we're designing focused on the dev team!
Once a challenge is retired the Wizer Wizard behind these creations - our very own CTO Itzik Spitzen - creates takeaways that provide clues into the challenge from the perspective of defending your script. Want to testdrive a CTF before reading the notes? Go ahead at wizer-ctf.com - it's free and there's something for all levels.
In this challenge, the developer identifies a Prototype Pollution vulnerability and exploits it to create an admin. Check out this CTF for yourself here.
Description of code
The code of the challenge allows users to create a user account as well an admin account, yet creating an admin should be possible only if a valid secret invite code is provided. The user object is sent as the request body in a JSON format. In order to make sure that the user gets a default picture upon creation, the code pre-defines an object which includes the default image name, then uses that object as the base for any new user created. The `Object.assign` arguments order in the code is reversed, so logically this wouldn't even work very well, and in fact, this swap is critical for the vulnerability to occur.
What’s wrong with that approach?
Since the user object is sent as a whole and is assigned and directly used to create a user in the system, without proper verification or selectively copying the relevant key values from it, a Prototype Pollution is possible, and with the help of Object.assign, it's actually triggered. To be more precise, it's a Prototype Poisoning in this case, which means that we're able to pollute a specific object and not all objects.
What would a successful Prototype Pollution Attack look like?
While the opening condition prevents simply sending `isAdmin: true` to create an admin without having access to inviteCode, an attacker could alter the user object prototype (using the `__proto__` key) and include `isAdmin: true`. By doing so, while the condition isn't met, the createAdmin routine will still be invoked since `newUser.isAdmin` will be set to true, due to the prototype alteration.
Prototype Pollution or Poisoning is definitely a dangerous vulnerability. In this case, the API endpoint allowed creating an admin user and bypassing the intended logic of the procedure, which could result in getting access to sensitive data on the system or performing malicious changes to data. With that being said, depending on the poisoned object role in the logic of the code, it could result in a very wide range of outcomes, starting from getting access to sensitive data, causing denial of service or even gaining full control over a system.
- Never *EVER* trust objects coming directly from the user input:
- Avoid loose logic, and always make your conditions flow logic clear and tight:
In this case, while the logic works ok for the happy path, our vulnerability exists also due to loose conditions logic, which doesn't fully separate between the creation of an admin and the creation of a simple user. The logic depends on the `isAdmin` key value within the object instead of the leading condition which clearly requires an `inviteCode` to create the admin user. A tight logic in this case, would mean that the `createAdmin()` method is only called if the leading validation condition is met. In our case, a tighter conditions flow could have been effective as well in protecting at least the entry point issue of invoking the wrong method (see an example of tighter conditions flow below):
- Localize variables and consts definitions into the minimal possible scope:
In this case, due to a combination of: (1) the "bug" of swapping the arguments sent to Object.assign and (2) the fact that `baseUser` is a global constant, once the object is polluted, it persists and won't reset until the app is restarted. To minimize such risks, we should always seek the minimal scope to place our cosnts and variables. In our case, since the `Object.assign` command is the only consumer of `baseUser` object, the proper scope of `baseUser` is locally within the method and not above. This way, even if the prototype is polluted, it doesn't persist for the lifetime of the app.
Wanna join us on our next challenge? Sign up for our mailing list at wizer-ctf.com.