DEV Community

Cover image for Intigriti 0422 - XSS Challenge Writeup
Breno Vitório
Breno Vitório

Posted on

Intigriti 0422 - XSS Challenge Writeup

Hi everyone, hope you are having an amazing day! 🤗

Today, I'm going to be showing how I solved Intigriti's 0422 XSS Challenge. It is really nostalgic, because reminds me of the initial contacts that I had, as a child, with computers.

I hope my thought process does make sense to you guys, but in case of any "unclear points", feel free to DM me! 🙋‍♂️

🏞️ Getting to Know The Challenge

This is what we find when we access the challenge's page for the first time:

Windows XP's interface

It's a really good clone of Windows XP's interface, I love it! The form just picks up our configuration and uses everything in order to make an amazing "Windows XP like" window, like the following one:

Window in which content we have "I love Intigriti"

For some reason, it replaced all the space characters with underscores. If we try to add HTML tags, the same happens with the < and > characters. Curious, very curious! 🧐

Harry potter reference to Olivanders

After the window is generated, when we look at the challenge URL, there is a huge query string like this one:

config%5Bwindow-name%5D=I%20love%20Intigriti&config%5Bwindow-content%5D=I%20love%20Intigriti&config%5Bwindow-toolbar%5D%5B0%5D=min&config%5Bwindow-toolbar%5D%5B1%5D=max&config%5Bwindow-toolbar%5D%5B2%5D=close&config%5Bwindow-statusbar%5D=true

or represented as JSON, for easier understanding:

config object represented in JSON

So this config object is being used to build the final window, and while searching for the keyword config in the page code, this can be found in the main() function:

Query string object

Apparently, qs is an object that represents everything we passed to the URL query string, and when qs has an attribute called config, it is merged to another object called appConfig with the merge() function. Cool, but it seems like really soon we are going to have tons of information to deal with, and when we try to see what's inside qs with the browser DevTools console...

qs is not defined

I would really like to be able to see the variables' values in each part of the code, so I'm gonna change it a little bit!

🙈 Trying to Help Myself (skippable)

So first of all, as the loaded page is just an HTML file, we may just download it with wget or copy/paste its' source inside of a local .html file.

We could (and will, in the final steps) just use the browser DevTools for debugging, which is actually easier in more general terms, but I like the idea of having things running locally and adapting the code so I don't need to set breakpoints everywhere. If you don't care about this process, you can just skip whatever sections which have (skippable) included in their titles.

Once we download the file, and load it into the browser, everything just works as expe...

a bunch of FILE NOT FOUND errors

😣 Solving Boring Setup Issues (skippable)

As we're not in challenge-0422.intigriti.io anymore, these files which are being referenced with relative paths won't really be found. So we have to change their relative paths to absolute ones, or maybe use the unpkg.com URLs that are in comments like this one down below:

URL poiting to unpkg.com

Or my favorite approach, which is adding a <base> tag pointing to challenge-0422.intigriti.io again! 🤗

Replacing base tag

Now everything is gonna work perfe...

CSP errors

Now it's time for the CSP to bother us 🤬. It happens because although the base tag references intigriti.io, we are not really there, but actually just loading a file locally.

We could also change its' definition in order to make challenge-0422.intigriti.io allowed, but since our goal is to just be able to check variables values, the CSP is not that important for now, and we can just delete or comment its' line in the file:

Commented CSP tag

After all this work, everything will be just normally loaded again! 🥳

🧐 Adapting The Code (skippable)

So, the first I would like to see is the qs variable, and it's inside the main() function. In general terms, variables that are declared inside of functions cannot be used out of them. In case you would like to know more about variables behaviour in JS, Tania Rascia has this amazing article.

The main() function is declared without expecting any parameters, right above the qs declaration:

main function declaration

And it's called right in the end of the <script> tag:

main function being called

So we may just remove both declaration and calling, and then we'll be able to see the value of everything inside main(), right? Let's check 🥳! And...

Not yet!

If we look at the beginning of the script, there's something else we forgot about:

All the code is inside a function expression

All the challenge's code is written inside of a function expression! Although it is not the same thing as the function declaration which we already erased (more about it here), the same rule about variables are applied here, in a way that after the code is executed, we cannot just check how the variables are in DevTools console.

So we just have to erase/comment the lines where it begins and ends, and after that, when we go back to DevTools console...🥳

Now it works and we can read the qs variable

👻 Let The Games Begin! 👾

Now that any debugging checks will be easier, we can really dive into the code!

It's possible to see a bunch of m() being called in the entire code, and there's also a file named mithril.js being loaded before the challenge script:

Mithril JS being used

Based on these facts, I might assume that the whole challenge has an issue related to Mithril, which is a JavaScript framework meant for building SPAs. If we quickly make a Google search like mithril.js cve, we are going to find something interesting!

Prototype Pollution in Mithril JS

So Mithril may have a prototype pollution issue! When we click on it and see the content of the page, it shows a sample code snippet of a supposed merge() function that may be vulnerable to this issue:

Vulnerable code example

Basically, it is vulnerable because it applies no filtering to whatever is coming as parameters, in a way that the user input may have a prototype definition included! 🤯

I have a blog about the importance of filtering user input, which can be found here, but remember that this merge() function can be found in the code, because we already have seen it being called before, so let's see how it was defined!

The merge function is defined in a similar way, but a little bit more hardened

In the case of this month's challenge, the merge function is being defined in a similar way, but it has a blacklist filtering based on this array:

Array of protected keys

So whenever one of these protected keys are present, they will not be added to the final merged object. __proto__ is there, probably as a method of avoiding prototype pollution, but __proto__ is not the only way to get to a data type's prototype. We can also refer to constructor.protoype, which keywords are not in the protected keys array 🧐

If we try to apply it, using a payload like config[constructor][prototype][test]=polluted in the URL, check what happens to appConfig after it's being merged with qs:

there is no prototype

The console shows no real prototype! 🤬

Why does this happen? Well, if we go back to appConfig's declaration, here's what we're going to find:

appConfig is created with no prototype

Because appConfig was declared with Object.create(null), it has no prototype, and it sort of breaks the prototype chain (see Object.create()). We cannot mess with the prototype of appConfig, but it has an interesting attribute called window-toolbar which is an array and was simply defined as ["close"].

Some may think that arrays can only have numeric indexes, but in JS that's not the case. We can handle arrays in a way that's very similar to generic objects, since arrays are implemented as objects too, they're just of a more specific type. See this example:

Array being treated as an object

So what if we try to change the prototype of appConfig["window-toolbar"]? With a payload like config[window-toolbar][constructor][prototype][test]=polluted, this is what we will find:

The prototype was polluted!

It did work! 🥳

🏁 From Proto Pollution to XSS

Okay, we could perform an array prototype pollution. Now what? It's still not an XSS! 🤡

If we look for where the content of the page is generated...it's in this piece of code:

mount function

Regardless of whether appConfig["customMode"] is true or not, those beautiful windows will always be mounted inside this element called devSettings.root, which is declared in the code section down below:

root element being declared

Can we somehow edit devSettings? Yes! Oops, No! I don't know, just look:

dev settings can be merge if being accessed from localhost

So qs.settings can be merged to devSettings if checkHost() returns true. Just by the fact that it involves a variable called "dev settings", we might assume that checkHost only returns true in cases of local accesses, but let's check it!

check host declaration

So yes, it picks up location.host and checks if the access is coming from localhost or the port is equal to 8080. Since I was loading just a file in the browser, location.host will be undefined, so I think it's time to go back to challenge-0422.intigriti.io. 😭

Once back in the original challenge page, we can still debug stuff by going to the DevTools Sources tab and defining a breakpoint inside of checkHost(), just by clicking on the desidered line number, the result would look like this:

Breakpoint defined before the function returns

Now reloading the page and checking the variables inside of the function, this is what we are going to get:

variable values

The temp array has just one index, since challenge-0422.intigriti.io has no explicit port definition, and because of that, the value of port becomes 443.

We cannot mess up directly with location.host, because it would redirect the page. But hey, we just discovered a way of polluting array prototypes! Let's just use it! 🤠

temp doesn't have a [1] index, so we can define it by ourselves! We just need to pick up the previous test payload and replace the test attribute with 1, and also setting 8080 as its' value instead of "polluted", resulting in:

config[window-toolbar][constructor][prototype][1]=8080

By using it and setting the same checkpoint that we've set before, these are the values the we will get now:

The port is now 8080

This means that checkHost() will return...🥁

true

Yay! So now we can use qs.settings as a way of changing devSettings!

Now, before trying to overwrite something inside devSettings.root, let's see what it has for us by setting a new breakpoint:

breakpoint at line 216

Hey, look at this cool attribute that we could just use as a way to get XSS!

inner HTML attribute

So we just have to add settings[root][innerHTML]=<h1>testing...</h1> to the previous payload and...

it didn't work

It didn't work! Why!? Let's just set a new a breakpoint right after the merge is done:

breakpoint at line 221

Look! It's here (sanitized, but here)! Why doesn't it work in the end?

innerHTML with sanitized h1 tag

Do you guys remember that Mithril receives devSettings.root as an argument for building the page's content? Yeah, in the end, when m.mount() is called, it overwrites what we defined for devSettings.root.innerHTML, so I could not use it as a direct way of injecting content to the page.

But there's still hope! Look at how devSettings.root is added to the page:

the root is appended to the body

It's appended to the page's body, no overwrites involved. So we can use the devSettings.root element as a way to get to the page body. There is more than one way of doing it, but since body is an attribute of the page's document, we may get there by using the devSettings.root ownerDocument attribute, which is a reference to the page's document, and then we will get to the body.

An example of working payload for that would be to add settings[root][ownerDocument][body][innerHTML]=<h1>testing...</h1> to that working prototype pollution, which would result in this:

h1 tag added to the page, but still sanitzed

It was added to the page, which is progress in a sense, but it is still being sanitized, because merge() calls a sanitize() function in some situations. Let's take a look again:

isPrimitive and sanitize are two important functions

Assuming the case of we calling merge() for devSettings, what will happen is that the code will only add something from qs.settings to devSettings as the result of sanitize(), so there is no way of just "ignoring" it. Let's look at how sanitize() works too:

Function with regex, but only apply it to strings

It has a pretty strong regex defined there, in order to avoid XSS cases, but it doesn't always apply this replacing method, only for strings.

This approach does make sense, since numbers and boolean values cannot carry an XSS payload inside them. But are these the only possible cases? Let's just check ìsPrimitive():

function that checks if value is primitive

Everything seems to be fine here, because it really just checks for primitives, but let's go back to the merge() function, and try to look at it with different eyes:

merge function with adaptations

I changed the name of the variables so they relate more to what we are trying to do, and also ignored the protectedKeys stuff because it's not a real problem to us anymore.

Pay attention to how isPrimitive() is being called. It receives devSettings[key] as a parameter, but if there is no attribute with this key being previously declared, then isPrimitive() will return true, because devSettings[key] will be undefined!

In other words, if our input contains something that still doesn't exist, it will also be considered as a primitive. 🧐

Taking into account that sanitize() skips the replacing method when the argument is not a string, we just have to find a different data type/strucutre that can also be printed by the browser in the page. Objects, maybe?

didn't work

Nah, not what I was expecting 😂

What about an array? We would just need to add [0] to the previous body's innerHTML payload, resulting in something like config[window-toolbar][constructor][prototype][1]=8080&settings[root][ownerDocument][body][innerHTML][0]=<h1>testing...</h1>:

it did work

It did work, yay! 🥳 So no we just have to replace this <h1> tag with an XSS payload such as <style onload=alert(document.domain)> and it will work, right?

No XSS happening

Okay, the <style> disappeared, but there's no extra prevention method for XSS, so what happened!? 😭

Let's add a new checkpoint to the line in the end of the main function, in order to see what's going on:

Checkpoint at line 239

What!? Look at what's just happening in qs:

The payload disappeared

The payload disappeared! Is the fact that we have an alert being called a problem? Let's test by changing the payload to just <style onload>:

Style tag appeared again

Okay...and what if we change it to just <style alert(document.domain)>?

Style tag still appears

So what's the problem when we try to bring everything together? Well, remember that everything is being parsed by a query string parser, and that the = sign is part of the query string specification, so probably Mithril is getting a little bit lost when trying to parse our payload. 🧐

As a way of helping Mithril, we could just URL encode the = sign, so our XSS payload becomes <style onload%3Dalert(document.domain)>. When we give it a try...

It worked! XSS Popping up!

Yay, it did work! XSS Popping up! 🥳

So my final payload was config[window-toolbar][constructor][prototype][1]=8080&settings[root][ownerDocument][body][innerHTML][0]=<style%20onload%3Dalert(document.domain)>, a little bit too big but that's okay 😎

Thank's for taking your time! Hope you got the process and learned something new! 🤗

Discussion (0)