Intigriti Challenge 0221 XSS Writeup

Sat Feb 20 2021


Hello! I recently saw the Intigriti 0221 XSS Challenge on Twitter, and decided to give it a go.

The vulnerability in the website is incorrect handling of Unicode characters, which can be used to inject HTML tags and run arbitrary code through the use of DOM clobbering.

Here's my proof of concept exploit:

Exploit

Here's my writeup of how I found the solution:

The title of the webpage as "Unicodeversity WACK system" obviously pointed out to me that the bug stemmed from something related to Unicode. The two main URL parameters "assignmentTitle" and "assignmentText" were reflected back onto the page, but they were escaped which made traditional XSS impossible. At this time, I only saw the first hint given on Twitter which said that "\u210B is ℋ". If this weird Unicode character is set as the parameter to the "assignmentTitle" variable, the web server misinterprets this Unicode character, and outputs it to the page as "!0b".

Quickly, I whipped up Python and ran the following code as a sanity check:

>>> hex(ord("!")) '0x21'

So, for the unicode character "\u210B", the server takes the first two hex bytes, "21", and converts it to the character represented by that hex value, which in this case is a "!". Then, it just outputs the next two bytes onto the page, which is why we see the "0b". Now, how can we use this vulnerability?

Well, although we can inject characters that are normally escaped like angle brackets, they have to be followed by two hex characters. So we can't just inject a <script> tag and call it a day. So, let's look for entrypoints in the script that comes with the page.

Looking at the source code of the challenge, I see the following code:

function passQuiz() { if (typeof result.questionAnswer !== "undefined") { return eval(result.questionAnswer.value + " == " + question); } return false; }

From this code, I saw that if we can control the value of result.questionAnswer.value or question, we can gain arbitrary code execution. question is set by the script at runtime, but result is undefined when the page is loaded! This means that we can use an attack named DOM Clobbering to set result.

DOM clobbering is an attack where you inject DOM objects into the page, and they change the execution flow of JavaScript? How? Well, for any HTML element on a page with an id, a corresponding variable is created on the page with that id. Something to note, this only holds if that variable name isn’t set before.

For example, if a page has the HTML <div id="output"></div>, checking the variable name output in the developer console shows:

So, if we create an HTML element with the id of result, we can change the control flow.

Now, how do we change result.questionAnswer? Well, we can exploit HTML collections. Following this article, if we do:

<a id="result"><a id="result" name="questionAnswer">yo</a></a> result.questionAnswer gets set to the inner a tag. How does this work? Well, if we check the value of result in the console, we see:

Multiple elements with the same id create an HTML collection where both their index and name get mapped to their HTML element. So now, if we had full DOM clobbering, we could control result.questionAnswer. But now, how do we manipulate that final property value?

Well, value is an attribute for several HTML tags already. For example, the text inside of <input> tags have the .value attribute set to their value. So, if we could inject an <input> tag, we could just use the value attribute.

But, now we have to deal with the limitations of our Unicode escaping. We can inject a character that is usually escaped by the page, like <, >, or ", but then two hex bytes are placed right after. This would ruin the HTML tag. So, is there any HTML element that begins with two hex bytes and has a value attribute? Well, we can check the Mozilla Docs...

Bingo. So, now, if we inject two data tags to fit DOM clobbering, we can change result.questionAnswer.value to be alert(origin). But we have two URL parameters, "assignmentTitle" and "assignmentText". Which one do we use?

Well, the passQuiz function only runs when the form is submitted. Thankfully, an autosubmit functionality exists if we provide the URL parameter "autosubmit".

const urlParams = new URLSearchParams(location.search); if (urlParams.has("autosubmit")) { startGrade(); }

Eventually, startGrade will lead to passQuiz, which will eval our code and gain us code execution as long as our result is set correctly. But, before passQuiz() is run, checkLength(text) is run with the parameter from "assignmentText".

function checkLength(text) { if (text.length > 50) { result = { message: "Thanks for your submission!" }; } }

So, if our "assignmentText" is larger than 50 characters, our result will be overriden and our payload will be replaced. So, we just inject into "assignmentTitle" instead, and fail the checkLength check so our result payload is not replaced.

If we inject to "assignmentTitle" something like:

"/><data id=result><data id=result name=questionAnswer value=alert(origin)></data></data>

result.questionAnswer.value will be set to alert(origin). But remember, each character that would normally need to be escaped neds to be followed by two hex characters.

So, I manipulate the payload some more, and write some Python to easily replace the characters with the Unicode characters that would replace them.

encode = 'aa"aa/>aa<data id=result>aa<data id=result name=questionAnswer value=alert(origin)>aa<data/>aa<data/>aa' encode = encode.replace('"aa', chr(0x2200 + 0xaa)) encode = encode.replace("<da", chr(0x3c00 + 0xda)) encode = encode.replace(">aa", chr(0x3e00 + 0xaa)) print(encode)

With this script, our payload is encoded with the correct unicode characters, giving us: aa⊪/㺪㳚ta id=result㺪㳚ta id=result name=questionAnswer value=alert(origin)㺪㳚ta/㺪㳚ta/㺪.

Putting this payload in the assignmentTitle parameter and adding the autosubmit parameter to run startGrade(), our payload in result.questionAnswer.value is executed, popping up an alert box with the origin as the text!

Thanks to Holme for an interesting challenge, and thanks to Intigriti for the 50€ merch voucher for having one of the best writeups. 😎