Understanding Client-Side Prototype Pollution

This was a deep dive into prototype pollution and a solve for the Intigriti July 2023 XSS challenge (https://twitter.com/intigriti/status/1670737265973182466). You can find the challenge here https://challenge-0623.intigriti.io/challenge/index.html. This is the solution I found:


And this is the prototype chain laid out a bit nicer:


Let’s break down how we got here, step by step.

0 – .setHTML and the Chrome Sanitizer

The challenge has a normal looking XSS – writing user supplied data to the DOM… but what are these setHTML / Sanitizer variables?

As a Firefox user, the Chrome Sanitizer API was pretty new to me. It’s basically a browser feature replacement for DOMPurify, sanitizing anything deemed as ‘dangerous’ but allowing benign HTML to work. It’s completely client side sanitization, because context is critical with XSS, and they key feature is that it’s able to avoid being a string-based system like any other DOM sanitization libraries. See more at the MDN web docs or dive even deeper with the spec

It can be used exactly like the challenge uses it:

modalContent.setHTML(name + " 👋", {sanitizer: new Sanitizer({})});

This uses the default sanitization options, which you can see in the appendix of the specification. Looks like the comment is right and there’s not XSS here, but understanding the sanitizer turned out to be quite important for the solution.

1 – Identifying Prototype Pollution

Prototype Pollution is a JavaScript vulnerability where it’s possible for an attacker to control the value of Prototypes in JavaScript. It is caused by JavaScript weirdness, specifically in the declaration and setting of variable names.

Basically, if an attacker has control over a and value below, they can set a to be __proto__ or proto and value to whatever they want, which lets them fuck around with some of the ways the JavaScript might execute.

my_object[a][b] = value

Three common JavaScript patterns that lead to this are merging, cloning and path assignment operations.


var tar = {a:1}
var sou = {a:3, d:2}

// Merge code snippet
var merge = function (target, source) {
    for (var attr in source) {
        if(typeof(target[attr]) === "object" && typeof(source[attr]) === "object") {
            merge(target[attr], source[attr]);
        } else {
            target[attr] = source[attr];
    return target;
var c = merge (tar, sou);


function clone(a){
    return merge({},a)

var tar = {a:1}
var sou = {a:3, d:2}

// Merge code snippet
var merge = function (target, source) {
    for (var attr in source) {
        if(typeof(target[attr]) === "object" && typeof(source[attr]) === "object") {
            merge(target[attr], source[attr]);
        } else {
            target[attr] = source[attr];
    return target;
var a = JSON.parse('{"a":1,"\_\_proto\_\_": {"test": "pass"}}');
var b = close(a);

Path assignment:

function setvalue(objname, objprop, propvalue){
    objname[objprop] = propvalue

var test = JSON.parse('{"a": 1, "b" : 2}')
setvalue(test, "a", 3);

(These examples were stolen from https://payatu.com/blog/prototype-pollution/)

We can see in jQuery-deparam that it lets us assign arbitrary key:value pairs through the parameters in a URL:

if ( Object.prototype.toString.call( obj[key] ) === '[object Array]' ) {
        // val is already an array, so push on the next value.
        obj[key].push( val );

    } else if ( {}.hasOwnProperty.call(obj, key) ) {
        // val isn't an array, but since a second value has been specified,
        // convert val into an array.
        obj[key] = [ obj[key], val ];
    } else {
        // val is a scalar.
        obj[key] = val;

It’s also pretty well established that this package has this vulnerabiliy from a quick google – the client-size-prototype-pollution repo even includes it, so this isn’t unknown!

So by using __proto__ as the name of the key, we can add stuff to the object’s prototype, which could then be used to affect other parts of the code. Cool!

2 – Exploiting Prototype Pollution

Exploitation of prototype pollution is dependant on the rest of the code used – to get XSS we’ll need to find a way to modify the code in unexpected ways to let us do things we otherwise couldn’t. These are commonly referred to as ‘Gadgets’. This is why the outdated jQuery exploit worked so flawlessly – prototypes are usually just polluted by URL parameter key:value pairs, and the gadget works without needing to write any jQuery.

There’s another relevant gadget, though – Google reCaptcha. The challenge code actually pulls in the reCaptcha script if we’re on localhost:

if (document.domain === 'challenge-0623.intigriti.io') {
    window.recaptcha = false
if (document.domain === 'localhost') {
    window.recaptcha = true
// ...
if (window.recaptcha) {
    const script = document.createElement('script');
    script.src = 'https://www.google.com/recaptcha/api.js';
    script.async = true;
    script.defer = true;


So if we were able to somehow change our victim’s document.domain to localhost, we could get them to pull in the Google reCaptcha script and use its gadget to get a complete XSS chain! But how could we do that?

3 – When document.domain is not document.domain

My first thought was DOM Clobbering – surprisingly, if you can write HTML to the DOM, you can actually control and change values of items in the document or window objects. Given that we’re writing to the DOM with setHTML(), I thought it could be possible to simply create some HTML with the ID/name of recaptcha and that would set the value of window.recaptcha to true!

But after some head scratching, I couldn’t get this to work… because it’s doing the DOM clobbering at the very end of code execution, and it’s not possible to run the code again. I looked into whether it would be possible to put an iFrame and share the window/document objects, but nothing seemed to work…

But why use DOM clobbering when we have Prototype Pollution? If we could make document.domain not localhost or challenge.intigriti.io, then we could use Prototype Pollution to set the now uninitializedwindow.recaptcha object to anything and pass the condition that pulls in the recaptcha script. But how can we make the browser take us to the same application but with a different document.domain?

My first thought was finding the origin IP address, and if it accepted any host header then we would technically have XSS on the target, even if the domain isn’t the same. But the origin IP just gave an Nginx 404 – so what about specifying the port number (443)? No luck. I tried capitalisations and a few other techniques until I tried a single . at the end of the domain name, which worked! Now document.domain was set to challenge-0623.intigriti.io., technically a different value! We can now pollute the window prototype with the new value for recaptcha, and reCaptcha will be added to the page:


But unfortunately there’s a pre-requisite for this gadget – we need the HTML <div class="g-recaptcha" data-sitekey="your-site-key"/> to be on the page! Can we not just write that to the DOM as our name?

4 – Return of the Sanitizer

Turns out the answer to that was no. When adding that HTML, it sanitizes our div!

But remember the default options for the Sanitizer? Well, when initializing an object like {sanitizer: new Sanitizer({})}, it actually inherits from the prototype! Which means we can change the configuration settings for the Sanitizer and potentially get our div through.

Unfortunately the sanitizer has two types of settings – the baseline, which can’t be modified (and prevents the good things like script tags or iFrames), and configuration (which can be controlled by the JS developer).

Interestingly, but ultimately putting me down a rabbithole – there was a prototype pollution bypass for the Sanitizer where it allowed the SVG tag with use, which could execute JavaScript. But that has been patched, and wasn’t possible here… and I couldn’t find any other bypasses! (https://bugs.chromium.org/p/chromium/issues/detail?id=1306450)

In my rabbithole, I thought that I’d need to use elements that the sanitizer would allow as part of the baseline but not as part the default configuration. So I diffed the baseline and defaults to see which elements we could allow with a different configuration in the Chrome sanitizer:


For the the attributes there was only one difference:


So this wasn’t helpful… But what about non-normative attributes, like data-*? These can be allowed by setting allowUnknownMarkup in the sanitizer configuration to true.

After a fair amount of trial and error, I created the right payloads to correctly modify the configuration for our Sanitizer:


This let us use attributes the sanitizer hadn’t seen before, and allowed the use of div with these attributes. The result – the Google recaptcha gadget is now fully loaded!

5 – Putting it all Together

Now it was simply a matter of adding the reCaptcha prototype pollution gadget from the client-sie-prototype-pollution repo into our chain:


Combining this with the other parts of the chain, we get this payload and an alert popup with document.cookie. Very satisfying!


Super Easy Solution

Jquery versions below 3 are Prototype Pollution gadgets that work ‘out of the box’:



Because the deparam library allows for Prototype Pollution, this gadget just works like magic. Boring!