If you follow the reports of researchers who participate in bug bounty programs, you probably know about the category of JavaScript prototype pollution vulnerabilities. And if you do not follow and see this phrase for the first time, then I suggest you to close this gap because this vulnerability can lead to a complete compromise of the server and the client. Chances are that at least one of products you use or develop runs on JavaScript: the client part of the web application, desktop (Electron), server (NodeJS) or mobile application.
This article will help you dive into the topic of prototype pollution. In the sections JavaScript features and What is prototype pollution? you will learn how JavaScript objects and prototypes work and how the specifics of their functioning can lead to vulnerabilities. In the sections Client-side prototype pollution and Server-side prototype pollution you will learn how to search for and exploit this vulnerability in real-world cases. Finally, you will learn how to protect your applications and why the most common method of protection can be easily circumvented.
Before proceeding to the next sections, I suggest that you open the developer tools and try out the examples given with your own hands in the course of the article, in order to gain some practical experience and a deeper understanding of the material.
The prototype pollution vulnerability is unique to the JavaScript language. Therefore, before dealing with the vulnerability itself, we need to understand the features of JavaScript that lead to it.
How do objects exist in JavaScript? Open the developer tools and create a simple object containing two properties.
We can access the properties of an object in two main ways.
What happens if we try to access a nonexistent property?
We got the value undefined
, which means the property is missing. So far so good.
In JavaScript, functions can be treated like normal variables (for more information, refer to first-class functions article), so object methods are defined as properties, and in fact they are. Add the foo()
method to the o
object and call it.
Let’s call toString()
method.
Suddenly, the toString()
method is executed, even though the o
object doesn't have a toString()
method! We can check this using the Object.getOwnPropertyNames()
function.
Indeed, there are only three properties: name
, surname
, and foo
. Where did the toString()
method come from?
JavaScript is minimalistic in terms of the number of entities. Almost any entity is an object that includes arrays, functions, and even class definition! We’ll stop here more detailed in the classes.
In JavaScript, there are no classes in the common understanding of the most programmers. If you have not previously encountered classes in JavaScript but have experience using classes in other languages, then the first thing I suggest is to forget everything you know about classes.
So, imagine that you have two entities: an object and a primitive (number, string, null
, etc.). How can you use them to implement such a convenient feature of the classes as inheritance? You can select a special property that each object will have. It will contain a reference to the parent. Let's call this property [[Prototype]]
. Okay, what if we don't want to inherit all the properties and methods from the parent? Let's select a special property from the parent from which the properties and methods will be inherited and call it prototype
!
There are several ways to find out the prototype of an object, for example, by using the Object.getPrototypeOf()
method.
We returned nothing more than Object.prototype
, which is the prototype of almost all objects in JavaScript. Making sure that this is an Object.prototype
is easy enough.
When you access an object property via o.name
or o['name']
actually does the following:
name
property in the o
object.o
object is taken and the property is searched for in it!So it turns out that the toString()
method is actually defined in Object.prototype
, but since when creating an object, its prototype is implicitly assigned to Object.prototype
, we can call the toString()
method for almost everything.
The parent, in turn, can also have a prototype, the parent of the parent, too, and so on. Such a sequence of prototypes from an object to null
is called a prototype chain or prototype chain. In this regard, a small remark: when accessing a property, the property is searched for in the entire chain of prototypes.
In the case of object o
, the prototype chain is relatively short, with only one prototype.
The same cannot be said about the window
object.
By the way, the word “prototype” in JavaScript can refer to at least three different things, depending on the context:
[[Prototype]]
. It is called internal because it lives in the "guts" of the JavaScript engine, we only get access to it through the special functions __proto__
, Object.getPrototypeOf()
, and others.__proto__
property. A rare and not quite correct use, because technically __proto__
is a getter / setter and only gets a reference to the prototype of the object and returns it.The term prototype pollution refers to the situation when the prototype
property of fundamental objects is changed.
After executing this code, almost any object will have an age
property with the value 42
. The exception is two cases:
age
property is defined on the object, it will override the same property of the prototype.Object.prototype
.What can prototype pollution look like in the code? Consider the program pp.js
.
If an attacker controls the parameters a
and v
, they can set a to '__proto__ '
and v
to an arbitrary string value, thus adding the test
property to Object.prototype
.
Congratulations, we just found prototype pollution! “But who in their right mind would use such constructions?” — you may ask. Indeed, this example is rarely found in real life. However, there are seemingly harmless constructs that, under certain circumstances, allow us to add or change the properties of Object.prototype
. Specific examples will be discussed in the following sections.
The client prototype pollution began to be actively explored in mid-2020. At the moment, the vector is well researched when the payload is in the request parameters (after ?
) or in a fragment (after #
). This vulnerability is most often escalated to Reflected XSS.
It is quite possible that the payload can be not only passed in the request parameters or fragment, but also saved on the server. Thus, the payload will work every time and for every user who visits a certain page, regardless of whether he visited a malicious link.
Let’s try to find prototype pollution on a vulnerable site https://ctf.nikitastupin.com/pp/known.html
. The easiest way to do this is to install the PPScan extension for Google Chrome and visit the vulnerable page.
We can see that the counter on the extension equals two now. This means that one of the payloads worked well. If we click on the extension icon, we will see payloads demonstrating the presence of a vulnerability.
Let’s try one of the payloads with our hands: click on the link https://ctf.nikitastupin.com/pp/known.html?__proto__[polluted]=test
, open the developer tools and check the result.
Great, the payload worked! Unfortunately, the client prototype pollution itself does not pose a serious danger. You can at best use it to make a client DoS, which is treated by updating the page.
On the client-side, the escalation to XSS is the most interesting. The JavaScript code that can be used to escalate prototype pollution to other vulnerability is called a gadget. Generally we have either a well-known gadget, or we have to search for gadgets on our own. Searching for new gadgets takes quite much time.
First of all, it makes sense to check the existing gadgets in the BlackFan/client-side-prototype-pollution repository or in the Cross-site scripting (XSS) cheat sheet.
There are at least two ways to check known gadgets:
Let’s use the second method, but first we’ll understand how it works. Usually, the gadget will define specific variables in the global context, by the presence of which you can determine the presence of the gadget. For example, if you use Twitter Ads, you will probably use the Twitter Universal Website Tag, which will define the twq
variable. The fingerprint.js mostly checks for specific variables in the global context. I borrowed the gadgets and their corresponding variables from BlackFan/client-side-prototype-pollution.
Copy the script and execute it in the context of the vulnerable page.
It looks like the page has a Twitter Universal Website Tag gadget. We find a description of the gadget in BlackFan/client-side-prototype-pollution, most of all we are interested in the PoC section with a ready-made payload. Trying out a payload on a vulnerable site https://ctf.nikitastupin.com/pp/known.html?__proto__[hif][]=javascript:alert(document.domain)
.
After a couple of seconds, the coveted alert ()
appears, great!
What should we do when there is no gadgets? Let’s go to https://ctf.nikitastupin.com/pp/unknown.html
and make sure it's vulnerable to prototype pollution https://ctf.nikitastupin.com/pp/unknown.html?__proto__[polluted]=31337
.
However, this time the fingerprint.js didn’t find the gadgets.
Despite the fact that Wappalyzer reports the presence of jQuery, this is a false positive due to the jquery-deparam library that is used on the site https://ctf.nikitastupin.com/pp/unknown.html
.
There are several approaches to finding new gadgets:
main
and old
. We will use old
because it is simpler than main
. This plugin was originally developed for DOM XSS search, details can be found in the video Finding DOMXSS with DevTools | Untrusted Types Chrome Extension.Let’s use the first approach. Install the plugin, open the console and go to https://ctf.nikitastupin.com/pp/unknown.html
. By and large, the filedescriptor/untrusted-types extension simply logs all API calls that can lead to DOM XSS.
In our situation, there are only two cases. Now we need to check each case manually and see if we can use the prototype pollution to change any variable to achieve XSS.
The first is eval
with the this
argument, which we skip. In the second case, we see that the src
attribute of some HTML element is assigned the value https://ctf.nikitastupin.com/pp/hello.js
. Go to the stack trace, go to loadContent @ unknown.html:17
and we see the following code.
This code loads the s
script. The script source is set by the scriptSource
variable. The scriptSource
variable, in turn, takes the already existing window.scriptSource
value, or the default value "https://ctf.nikitastupin.com/pp/hello.js"
.
This is where our gadget lies. With prototype pollution, we can define an arbitrary property on Object.prototype
, which of course is a window
prototype. We try to add the value Object.prototype.scriptSource =
, to do this, go to https://ctf.nikitastupin.com/pp/unknown.html?__proto__[scriptSource]=https://ctf.nikitastupin.com/pp/alert.js
.
And here it is our alert()
! We just found a new gadget for a specific site.
You may say that this is an artificial example and you will not find this in the real world. However, in practice, such cases occur because the construction var v = v || "default"
is quite common in JavaScript. For example, the gadget for the leizongmin/js-xss library, which is described in the "XSS" section of the article Prototype pollution - and bypassing client-side HTML sanitizers, just uses this construction.
In addition to the usual vectors __proto__[polluted]=1337
and __proto__.polluted=31337
, once I came across a strange case. It was on a one big site. Unfortunately, the report has not been disclosed yet, so no names. My private search plugin prototype pollution reported a vulnerability, but it was not possible to reproduce it using normal vectors. I sat down to sort out what was going on. The vulnerability has already been fixed, but we have a duplicate.
Navigate to https://ctf.nikitastupin.com/pp/bypass.html?__proto__[polluted]=1337&__proto__.polluted=31337
. Open the developer tools and check whether the vulnerability has worked.
It looks like the vulnerability didn’t work, but let’s look a little deeper into the source code.
The already familiar function deparam
is called with the argument location.search
. Let's look at the function definition.
We immediately understand that we are dealing with minified code, so it will be more difficult. Next, we notice the familiar lines "__proto__"
, "constructor"
and "prototype"
. Most likely, this is a black list of parameters, which means that the developers have already tried to fix the vulnerability. But why did the plugin find a vulnerability? We understand further.
Further understanding of minified source code in statics is extremely difficult, so we put a breakpoint on the line h = h[a] = u < p ? h[a] || (l[u + 1] && isNaN(l[u + 1]) ? {} : []) : o
. Set the breakpoint on the line shown below. Why exactly on it? The fact is that the plugin noticed prototype pollution on it, that's why to start with it seems to be most logically. Reload the page and get into the debugger.
Now we see a construction that can lead to a vulnerability: h = {}; a = "__PROTO__"; h = h[a] = ...
. Why the vulnerability doesn’t work? The fact is that __PROTO__
and __proto__
are different identifiers. The next idea was to figure out exactly how the blacklist is applied and try to find a workaround. After a few hours of working with the debugger, I understood the internal logic of the function: toUpperCase()
is applied to words from the blacklist, and tried to bypass this operation, but the attempts were unsuccessful.
I decided to look at the bigger picture to deal with the code that I haven’t seen yet. Among anything that could help with the crawl, only one line remained.
At first glance, this string handles arrays (for example, a[0]=31&a[1]=337
is parsed to a = [31, 337]
). If you look closer then ordinary objects (for example, b=42
) are also processed by this line. Despite the fact that this code does not lead to prototype pollution directly, it does not use a blacklist, which means that this is a hope for circumvention!
I remember a case where prototype pollution was fixed in a similar way (blacklist __proto__
, constructor
, prototype
), and another researcher bypassed this and was able to change the properties of the toString
type, eventually DoS. My first idea was to change the includes()
method to return false
. But then I realized that I can only add a string, and when includes
is a string and we make a call ()
on it, an exception occurs ( includes is not a function
) and the script does not work further.
After that, I remembered that arrays in JavaScript are ordinary objects, and therefore array elements can be accessed through square brackets.
Following this, I got the idea that you can first put __proto__
in an array element, and then access this element through the index, thus bypassing the blacklist.
Setting a breakpoint on the line aaa.utils.isArray(i[a]) ...
. Trying out the payload https://ctf.nikitastupin.com/pp/bypass.html?v=1337
, get into the debugger, click "Step over next function call". As a result, i[a] = o
is executed, we check the value of i
.
What happens if you specify __proto__
instead of v
? Trying out payload https://ctf.nikitastupin.com/pp/bypass.html?__proto__=1337
, this time i[a] = [i[a], o]
is executed and we check the value of i
.
Whoa! The result is a very fancy object, but the most important thing is that this object will be used when parsing the following parameters! How will this help us, you may ask? The answer is literally one step away.
Remove the previous breakpoint and add a breakpoint on the line h = h[a]
, on a potentially vulnerable construct. We will also add another parameter to the payload https://ctf.nikitastupin.com/pp/bypass.html?__proto__=1337&o[k]=leet
. We get into the debugger and check the value of h[0]
.
Suddenly, we have access to Object.prototype
! To understand why this happened, let's remember that (1) array elements in JavaScript can be accessed by using square brackets, and the index can be a string, (2) if the property is not found on the object, the search continues in the prototype chain. So it turns out that when we execute h["0"]
, the property "0"
, which is not present on the object h
, is taken from the prototype h.__proto__
and its value is Object.prototype
.
So if we change o
to 0
, then we can add a property to Object.prototype
? Disable breakpoints, try https://ctf.nikitastupin.com/pp/bypass.html?__proto__=1337&0[k]=leet
and check the result.
I think you’ve already figured it out for yourself.
It all started with the Olivier Arteau — Prototype pollution attacks in NodeJS applications , prototype-pollution-nsec18. Oliver discovered the prototype pollution vulnerability in several npm packages, including one of the most popular lodash packages ( CVE-2018–3721). The lodash package is used in many applications and packages of the JavaScript ecosystem. In particular, it is used in the popular Ghost CMS, which, because of this, was vulnerable to remote code execution, no authentication was required to exploit the vulnerability.
Without source code, this class of vulnerabilities is quite difficult to detect and to exploit. The exception is when you have a CVE and a ready-made payload. But let’s say we have the source code. What places in the code should you pay attention to? Where is this vulnerability most common?
What language constructs are prone to the vulnerability?
Most often, prototype pollution is found in the following constructs / operations:
.toml
or .ini
configuration files to a JavaScript object (for example, npm/ini)We can trace a pattern: those operations that take a complex data structure (for example, .toml
) as input and convert it into a JavaScript object are vulnerable.
Dynamic analysis
Let’s start with dynamic, as it is easier to understand and apply. The algorithm is quite simple and is already implemented in find-vuln:
The only drawback of find-vuln.js is that it doesn’t check constructor.prototype
and therefore misses some of the vulnerabilities, but this gap is easy enough to fix.
Using a similar algorithm, I discovered CVE-2020–28460 and a vulnerability in the merge-deep package. I reported both vulnerabilities via Snyk. With the first one, everything went smoothly, but with the second one, a funny situation came out. After sending the report, the maintainer did not get in touch for a long time, and as a result, GitHub Security Lab found the same vulnerability, managed to reach the maintainer earlier and registered it ( GHSL-2020–160).
In general, making small changes to find-vuln.js even now you can find vulnerabilities in npm packages.
Static analysis
This type of vulnerability is difficult to find with a simple grep, but it can be very successfully searched with CodeQL. Existing CodeQL queries actually find prototype pollution in real packages, although at the moment not all variants of this vulnerability are covered.
Let’s say we found a library that is vulnerable to prototype pollution. How much damage can this vulnerability cause to the system?
In a NodeJS environment, this is almost always a guaranteed DoS, because you can overwrite a basic function (for example, Object.prototype.toString()
) and all calls to this function will return an exception. Let's look at the example of the popular expressjs/express server.
Install the dependencies and start the server.
And in another tab of the terminal, we send the payload.
As you can see, after sending the payload, the server loses the ability to process even simple GET requests, because express internally uses Object.keys()
, which we successfully turned from a function to a number.
In a web application, often you can spin up to remote code execution. Normally, this is done with the template engines. The details of the operation can be found in the articles below.
There are different ways to fix this vulnerability, let’s start with the most popular option.
Most often, developers simply add __proto__
to the blacklist and do not copy this field. Even experienced developers do this (for example, the npm/ini case).
This fix is easily circumvented by using constructor.prototype
instead of __proto__
.
On the one hand, this method is easy to implement and often enough to fix the vulnerability, on the other hand, it does not eliminate the problem because there is still the possibility of changing Object.prototype and other prototypes.
Object.create(null)
You can use an object without a prototype, then modifying the prototype will not be possible.
The disadvantage is that this object can break some of the functionality further. For example, someone might want to call toString()
on this object and get o.toString is not a function
in response.
Object.freeze()
Another option is to freeze Object.prototype
using the Object.freeze()
function. After that, the Object. prototype cannot be modified.
However, there are a few pitfalls:
Object.prototype
may break.Array.prototype
and other objects.You can validate the input data against a predefined JSON schema and discard all other parameters. For example, you can do this using the avj library with the additionalProperties = false
parameter.
JavaScript prototype pollution is an extremely dangerous vulnerability, it needs to be studied more both from the point of view of finding new vectors, and from the point of view of finding new gadgets (exploitation). On the client, the vector is not developed at all when the payload is saved on the server, so there is room for further research.
In addition, JavaScript has many other interesting features that can be used for new vulnerabilities, such as DEF CON Safe Mode — Feng Xiao — Discovering Hidden Properties to Attack Node js Ecosystem. Surely there are other subtleties of JavaScript that can lead to equally serious or more serious consequences for the security of applications.
First of all, I would like to thank Olivier, Michał Bentkowski, Sergey Bobrov, s1r1us, po6ix, William Bowling for their articles, reports and programs on the topic of prototype pollution, which they shared with everyone. Without them, the study would hardly have begun :)
Sergey Bobrov and Mikhail Egorov for collaboration in the search of vulnerabilities.
For proofreading, feedback and other assistance on the article, thank you to Alyona Manannikova, Anatoly Katyushin, Alexander Barabanov, Denis Makrushin and Dmitry Zheregelya.
Examples:
Misc: