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:
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 <
and >
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:
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:
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:
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...
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...
😣 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:
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! 🥳
🧐 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:
And it's called right in the end of the <script>
tag:
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...🥳
👻 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:
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!
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 qs
:
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:
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:
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! 🥳
🏁 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:
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:
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!
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:
Now reloading the page and checking the variables inside of the function, this is what we are going to get:
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:
This means that checkHost()
will return...🥁
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:
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 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:
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 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:
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()
:
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 [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, 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?
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:
What!? Look at what's just happening in qs
:
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>
:
Okay...and what if we change it to just <style alert(document.domain)>
?
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][1]=8080&settings[root][ownerDocument][body][innerHTML][0]=<style%20onload%3Dalert(document.domain)>
, a little bit too big but that's okay 😎
Top comments (0)