Exploits Ep - 1: From Prototype Pollution to a 100% Discount
11 min read
Imagine this: You're browsing your favourite online shop, adding those must-have items to your cart, when suddenly, a hacker decides to crash your shopping party. But instead of stealing your credit card info or adding a thousand rubber ducks to your order – they pollute your shopping cart's prototype! Sounds like a weird sci-fi plot, right? Well, welcome to the world of prototype pollution, where hackers can turn your JavaScript objects into their personal playground. In this blog post, we're going to dive into prototype pollution, using a vulnerable e-commerce site as our guinea pig. So, grab your hacker hoodies, and let's explore how a simple shopping cart can turn into a hacker's paradise!
P.S.: After you've become a master at breaking JS, try out your skills on our repo, and maybe give us a star?
But before we HACK, here is a small refresher on javascript objects and prototypes.
What are Prototypes in Javascript
Almost everything in Javascript is an object and every object has a built-in property called prototype
. The prototype
itself is an object and serves as a fallback source for properties and methods. What that means is, when, javascript runtime doesn't find the property in the object, it checks for the property in the prototype of the object. If we want a shared behaviour between multiple objects we define that behaviour in the prototype of the constructor function instead of defining that behaviour in all the objects. Here is an example:
function Product(name, price) {
this.name = name;
this.price = price;
}
Product.prototype.display = function () {
console.log(`${this.name} -> ${this.price}`);
};
let product_one = new Product("headphones", 1000);
let product_two = new Product("Mic", 400);
product_one.display();
product_two.display();
In the above example, the objects product_one
and product_two
are derived from the function constructor Product
. When we call the display()
function on the objects the javascript runtime first checks the object if they contain the method called display
. When it doesn't find it in the object itself, the runtime checks for this method in the prototype (__proto__
) object. The __proto__
property of an object links it to the prototype
property of its function constructor. So Product.prototype === product_one.__proto__
.
This can also be checked by logging the products and checking their prototype object.
We see that the object product_one
itself doesn't contain a method called display
but its Prototype contains it. You may also notice that there is another Prototype nested inside the prototype of product_one
. This is where things get a little interesting and its precisely this feature that opens doors to potential exploits. Let's explore this next!
Prototype Chaining and Prototype Pollution
Inheritance in javascript is implemented using objects. Each object has an internal link to a prototype object which has a prototype of its own and so on until an object is reached with null
as its prototype.
The nested prototype you're seeing is actually the prototype of Object
, which sits at the top of the prototype chain for most JavaScript objects. This means that if a property isn't found on product_one
or its immediate prototype, JavaScript will continue searching up the chain, eventually reaching Object.prototype
.
For example, when we run product_one.hasOwnProperty("name"), JavaScript follows this lookup chain:
First, it looks for
hasOwnProperty
in the product_one object itself. It doesn't find it there.Next, it checks
product_one.__proto__
, which points toProduct.prototype
. ThehasOwnProperty
method is not defined here either.Then, it moves up the prototype chain to
Product.prototype.__proto__
, which is equivalent toObject.prototype
. Here, it finally finds thehasOwnProperty
method.
So if we were to somehow manipulate the behaviour of Object.prototype
we will be able to control the behaviour of all the objects linked to Object.prototype
in the chain. This is called Prototype Pollution. Lets see this with our previous example of Product
.
This code is a classic example of prototype pollution. The first line modifies the prototype chain by adding a new property new_property to the Object.prototype
. By setting new_property
on this high-level prototype, the change affects all objects that inherit from Object.prototype
. The second part of the code creates a new empty object obj
and then attempts to access new_property
on it. Despite obj not having this property directly defined, it still outputs "polluted" because the property is inherited from the polluted Object.prototype
. This showcases how prototype pollution can unexpectedly affect seemingly unrelated objects, potentially leading to security vulnerabilities or unintended behaviour in an application.
Alright, enough theory. Lets get hacking!
Our favourite E-commerce site has a special offer for us of 100% discount🤯. But unfortunately we don't have the coupon code🥹. Wouldn't it be great if we could somehow snag that code? Maybe it's hiding in plain sight, hardcoded in the website's source.
Approach 1: Naive Approach
We open our trusty developer tools in the browser and go on to inspecting the code for the page. We move to the scripts
section and check if we can find the coupon code.
We see that there is a DISCOUNT_COUPON_HASH
and a hashing function called hashValue
. If we scroll down further we find the applyCoupon
function. This function gets the value of the discount code text box, hashes it using the hashValue
function and compares it with DISCOUNT_COUPON_HASH
. Unfortunately, by the nature of hashing function the hash value is irreversible. So we can in no way get the value of coupon code that hashes to DISCOUNT_COUPON_HASH
.
Let's explore this site further as our previous approach didn't bear any fruit.
Approach 2: Trying to craft a malicious URL with __proto__
query parameter
The shop has another very handy feature to store the cart state in the URL. This way we can share our cart with others or save the URL to come back to our cart. Prototype pollution attacks usually exploit how these query parameters are being parsed by crafting malicious urls. Let's try to craft a malicious URL to pollute Object.Prototype
.
https://exploit-episode-1.middlewarehq.com/?cart={"items":{"2":3,"3":1}}&__proto__.hack=hacked
https://exploit-episode-1.middlewarehq.com/?cart={"items":{"2":3,"3":1}}&__proto__[hack]=hacked
Using the above URLs we try to inject the property hack:'hacked'
in to the global object's prototype with the assumption that the URL parsing done to restore the cart doesn't sanitise the input. But the above urls don't seem to work as expected:
We need to explore the code some more to understand how the URL parsing is being done.
Investigating the code some more
Upon reading through the code we find loadCartFromURL
function that is responsible for restoring the cart from URL on page load.
It creates a URLSearchParams
object from the query parameters, gets the 'cart' query parameter and parses it to json using JSON.parse
. After the object is parsed as json, the merge function merges the cart
object and the updateObj
recursively. Now this looks like something asking to be misused.
JSON.parse
treats every key in the object as an arbitrary string, include __proto__
. So now, instead of creating a new query parameter of __proto__
we will inject it into the cart object itself.
Approach 3: Injecting __proto__
into the cart
query parameter
Let's try to use the following url:
https://exploit-episode-1.middlewarehq.com/?cart={"items":{"2":3,"3":1},"__proto__":{"hack":"hacked"}}
And voila! We have successfully polluted the global object prototype. But what exactly happened? Why did this format magically work and not the others🤔.
Let's take a look under the hood to understand what exactly is happening.
Since JSON.parse
considers every key as an arbitrary string, JSON.parse(params.get("cart"))
will create an object like below:
const updateObj = {
"items": {
"2": 3,
"3": 1,
},
"__proto__": {
"hack": "hacked",
},
};
Here __proto__
is just an arbitrary string and it doesn't point to the prototype object Object.prototype
. We then move on to recursively merging cart
object and updateObj
.
At some point in the recursive merge, the function will assign target["__proto__"]["hack"] = "hacked"
. During this assignment the javascript runtime treats ["__proto__"]
as the getter for the prototype property of Object
. Hence, the assignment becomes equivalent to Object.prototype["hack"] = "hacked"
. Now every object created using Object()
constructor function will have access to the property hack
.
Injecting the hack
property is pretty useless to us, so let's try to find some more useful property that would help us get that sweet 100% discount😍.
More code exploration
We need to now look for some functionality or property that can be overwritten or injected using the above method, so that we can avail the discount.
We see that the calculateTotal
function checks if the discount
object has a "truthy" property called discountCodeValid
and applies the 100% discount. Aha! If we inject the property discountCodeValid
into the Object.prototype
we can buy all our favourite products for free!!!
Availing the 100% Discount 🥳
https://exploit-episode-1.middlewarehq.com/?cart={"items":{"2":3,"3":1},"__proto__":{"discountCodeValid":true}}
The above url causes the following javascript call:
cart["__proto__"]["discountCodeValid"] = true
This inserts the property discountCodeValid
into the Object.prototype
object. When the function calculateTotal
is called, the control goes to the if statement if (discount.discountCodeValid) {
. Javascript finds the property discountCodeValid
in the Object.prototype
by the principle of prototype chaining and the total cost is set to 0.
You can now see that the total cost shown in the cart is 0 and it also says Total: $0.00 (100% discount applied)
🎊.
Click on the buy button with the discount applied to get a special surprise😉.
Prototype pollution in the wild
In the recent years there have been numerous real-world vulnerabilities that have been caused by prototype pollution. Various javascript frameworks and libraries have been affected.
jQuery (CVE-2019-11358): In 2019, a prototype pollution vulnerability was discovered in jQuery, one of the most widely used JavaScript libraries. Versions prior to 3.4.0 were affected, potentially impacting millions of websites.
minimist (CVE-2020-7598): This widely-used argument-parsing library for Node.js was discovered to be vulnerable in early 2020, affecting countless Node.js applications and CLI tools.
object-path (CVE-2020-15256) : Later in 2020, the object-path library, used for accessing deep properties of objects, was found to be susceptible to prototype pollution attacks.
Lodash (CVE-2019-10744): In July 2019, a significant prototype pollution vulnerability was discovered in Lodash, one of the most widely-used JavaScript utility libraries. This vulnerability affected all versions prior to 4.17.12 and could potentially impact millions of projects.
How to write prototype pollution safe code
Just like we hacked the shopping site, the same can happen to our apps as well😥. We need to adopt some coding practices to prevent this from happening to our websites. Here are some key strategies we at Middleware use to write code that's resistant to prototype pollution:
Object.create(null)
: When you simply need to store objects from untrusted sources use. This creates an object with no prototype, eliminating the risk of pollution.
const safeObj = Object.create(null)
- Sanitising Keys: This is probably the most obvious way to prevent prototype pollution. But many a times, flawed sanitisation implementation allow attacker to still pollute the prototype using the constructor or changing the value of the key slightly to bypass sanitisation. Let's see how we can update the
merge
function used by the shopping site to prevent this type of attack:
function merge(target, source) {
for (let key in source) {
if (Object.hasOwn(source, key) && key !== '__proto__' && key !== 'constructor') {
if (typeof source[key] === 'object' && source[key] !== null) {
target[key] = safeMerge(target[key] || {}, source[key]);
} else {
target[key] = source[key];
}
}
}
return target;
}
Object.freeze()
: Another way of preventing change to theObject.Prototype
is to useObject.Freeze
. Freezing an object prevents extensions and makes existing properties non-writable and non-configurable. A frozen object can no longer be changed.
Object.freeze(Object.prototype)
obj = {}
obj.__proto__.evil = "evil"
"evil" in obj // false
Map()
: We can also objects likeMap
which provide built in protection. Although a map can still inherit malicious properties, they have a built-in get() method that only returns properties that are defined directly on the map itself.
Object.prototype.hacked = "polluted"
let safeObj = new Map()
safeObj.set("name", "John")
safeObj.hacked === "polluted" // true
safeObj.get("hacked") // undefined
safeObj.get("name") // John
- Dependency Security : We can take all the precautions while writing our code, but it takes only a single vulnerable library to break it all apart. So it is very important to only use secure libraries. Luckily,
npm
provides a built-in command callednpm-audit
which scans your project for known vulnerabilities.
npm audit
npm audit fix
Final thoughts
And there you have it folks! We have successfully turned a shopping cart into our own hacking playground. But remember, with great power comes great responsibility (and potentially some very confused developers).
So, whether you're building the next Amazon or just trying to keep your JavaScript objects in line, keep this in mind. After all, you wouldn't want your users getting a 100% discount on everything, would you? (Or maybe you would, in which case, can we be friends?)
Now that you're armed with this prototype pollution prowess, why not put your skills to the test? Think you're a hotshot hacker after this little adventure? Well, we've got a challenge for you!
⚡️ Try and break the Middleware repo!
Go ahead, give it your best shot 🚀.
Stay safe out there in the JavaScript world, and may our objects never get polluted! (Unless, of course, you're trying to break our app – in which case, bring it on! 😛)