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! 🙋♂️
This is what we find when we access the challenge's page for the first time:
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:
For some reason, it replaced all the space characters with underscores. If we try to add HTML tags, the same happens with the
> characters. Curious, very curious! 🧐
After the window is generated, when we look at the challenge URL, there is a huge query string like this one:
or represented as JSON, for easier understanding:
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
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...
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!
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...
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:
Or my favorite approach, which is adding a
<base> tag pointing to
challenge-0422.intigriti.io again! 🤗
Now everything is gonna work perfe...
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:
After all this work, everything will be just normally loaded again! 🥳
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.
main() function is declared without expecting any parameters, right above the
And it's called right in the end of the
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...
If we look at the beginning of the script, there's something else we forgot about:
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 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 cve, we are going to find something interesting!
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:
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!
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:
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
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 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
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:
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:
It did work! 🥳
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:
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:
Can we somehow edit
devSettings? Yes! Oops, No! I don't know, just look:
qs.settings can be merged to
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!
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
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:
Now reloading the page and checking the variables inside of the function, this is what we are going to get:
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  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:
By using it and setting the same checkpoint that we've set before, these are the values the we will get now:
This means that
checkHost() will return...🥁
Yay! So now we can use
qs.settings as a way of changing
Now, before trying to overwrite something inside
devSettings.root, let's see what it has for us by setting a new breakpoint:
Hey, look at this cool attribute that we could just use as a way to get XSS!
So we just have to add
settings[root][innerHTML]=<h1>testing...</h1> to the previous payload and...
It didn't work! Why!? Let's just set a new a breakpoint right after the merge is done:
Look! It's here (sanitized, but here)! Why doesn't it work in the end?
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:
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
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:
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:
Assuming the case of we calling
devSettings, what will happen is that the code will only add something from
devSettings as the result of
sanitize(), so there is no way of just "ignoring" it. Let's look at how
sanitize() works too:
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
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:
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?
Nah, not what I was expecting 😂
What about an array? We would just need to add  to the previous body's innerHTML payload, resulting in something like
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?
<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:
What!? Look at what's just happening in
The payload disappeared! Is the fact that we have an alert being called a problem? Let's test by changing the payload to just
Okay...and what if we change it to just
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...
Yay, it did work! XSS Popping up! 🥳
So my final payload was
config[window-toolbar][constructor][prototype]=8080&settings[root][ownerDocument][body][innerHTML]=<style%20onload%3Dalert(document.domain)>, a little bit too big but that's okay 😎