Intigriti — XSS Challenge 0621

2 years ago 144
BOOK THIS SPACE FOR AD
ARTICLE AD

XSS via WebAssembly

FHantke

While scrolling through my Twitter feed, I saw a new post from Intigriti — a fresh XSS Challenge. Since I had some free time, I decided to give it a try. In the following writeup, I go through my thinking process and explain my approach. At the time of writing this article, on the last day of the challenge, only 14 out of 22 submissions were accepted. Therefore I hope a lot of people appreciate this writeup and take something from it.

As for every Intigriti XSS challenge, the goal was to execute an alert(document.domain)on the given domain. No self-XSS or man in the middle is allowed, and it should work for the latest Firefox or Chrome version.

Looking at the challenge’s website, we can see that an iframe is embedded, loading its content from /passgen.php. Passgen contains a password generator mainly implemented in a web assembly (wasm) file called program.wasm. When the user generates a new password, the wasm file is called, and its output is passed on to the function showMessage. This function takes the output, sanitizes it, and displays it as a popup message.

On /passgen.php, the user can generate passwords.

The first part of the challenge was to understand what the wasm file does. Since there was no source code for the wasm file (only later Intigriti published a Pastebin), the two left options were either reversing the file or blackbox testing it.

The strategy that I used was to look at the input parameters of the file, play around with it and look at the outcome — Black-box testing. So let’s look at the code and its input parameters:

We can see that there are three input parameters: passwordLength, allowNumber, and allowSymbols. All three parameters are included in the JSON string, which is later handed over as option parameter to the function generate_password. This function is part of the wasm file. The output we get is the generated password and is contained in the parameter password.

First, we analyze the parameter passwordLength, since it seems more relevant than the other parameters. It is further used in malloc to allocate bytes for the generated password. However, it is already copied in the JSON before the check and manipulation in lines 11 to 14. This means the password length in the JSON (length-1) and the one later used (length-2) could differ. Let’s see how we can verify this.

The regex check in line 11 analysis whether the password length only consists of digits or not. Unfortunately, the check is invalid and can be bypassed by inserting a newline character. This is due to the multiline parameter in the regex string:

"123\nImNotADigit".match(/^\d+$/gm)
-> Array [ "123" ]
"123\nImNotADigit".match(/^\d+$/g)
-> null

If we now set inputFields.passwordLength.value to 123\nImNotADigit and call the generate function, we should theoretically see the check running through, resulting in differing variables length-1 and length-2.

Damn, not so fast… Every time we try to set the value, the newline character disappears because the HTML input field allows no newline by default.

The HTML input field removes every newline character

Even though a regular newline character is not allowed, we can use a simple fuzzing trick to find another working character:

The code snippet above tries 10000 characters to find weird Unicode characters that will break the check. Accordingly, we can see that character 8232, the Unicode Line Separator, is the character we need — that makes sense.

If we now try our “ImNotADigit” string, it works and goes through. However, since length-1 and length-2 differ, the generated password is empty; something is broken. Of course, the payload is broken because, with the not-digit characters in our JSON string, it is not a valid JSON anymore (see the two images below). Thus, let’s fix it and recheck the result. The fix is to put quotes around the value, so the input is not handled as a number anymore but as a string inside the JSON string.

Input “123” + String.fromCharCode(8232) + “ImNotADigit” breaks the JSON string

To test it yourself, you can use the snippet below:

let passwordLength = document.getElementById("password-length");
passwordLength.value = "\"hello" + String.fromCharCode(8232) + "123" + String.fromCharCode(8232) + "ImNotADigit\"";
generate();

Manipulating the JSON string works.

As we can see above, the fix works. Next, we could try to escape the JSON and include other keys. What can we do with this?

The first thing I tried was to change the seed parameter. As a result, the password is always the same. But besides that the password stays the same, it has no benefit.

inputFields.passwordLength.value = "\"" + String.fromCharCode(8232) + "10" + String.fromCharCode(8232) + "\", \"seed\": 1";
generate();

Then, I played around with various inputs and the password length and eventually recognized that some of the input was reflected back when the password length is big enough. This is probably due to an overflow in the wasm file; however, I did not reverse the wasm file, so I don’t know the exact cause.

The following code snippet sets the input length to 3000. Executed in the console of the website, the popup will display the Xs we set in allowedNumbers.

inputFields.passwordLength.value = "\"" + String.fromCharCode(8232) + "3000" + String.fromCharCode(8232) + "\", \"allowedNumbers\": \"xxxxxxxxxxxx\"";
generate();

As a result, we can control the output that is displayed in the popup message. Without delay, let's try to escalate this to an XSS.

The popup is generated by the function called showMessage that uses a sanitizer function sanitize.

On the first view, it looks like we can inject HTML, since <, > and / are not included in the unsafe characters. However, many standard payloads fail due to the sanitizer. For instance, the example below indeed injects HTML.

inputFields.passwordLength.value = "\"" + String.fromCharCode(8232) + "3000" + String.fromCharCode(73769) + "\", \"allowedNumbers\": \"<u>xxxxxxxx</u>\"";
generate();

However, if we put the basic <script>alert(1)</script> payload in the sanitizer, it returns <script>alert&#x28;1&#x29;</script>. Nevertheless, I found a working payload with which it is possible to bypass the sanitizer using <svg>, or better said, it is possible to execute javascript via <svg> even with the sanitized output.

Putting <svg><script>alert(1)</script></svg> in the sanitizer returns correctly sanitized output (<svg><script>alert&#x28;1&#x29;</script></svg>), nevertheless, the javascript is executed anyway.

If you wonder why the application behaves this way, take a look at the two DOM trees below. The left one shows the DOM without the SVG tag. As far as I understand it, please correct me if I’m wrong, everything is in the HTML context and the HTML entities are handled as we would expect it. On the right side instead, the part included in the SVG tag is not in the HTML context anymore but in SVG. This means the HTML parser converts the HTML entities back to characters since it's not the HTML context anymore. Hence, it is possible to put HTML encoded javascript inside the script tags and execute the payload using the showMessage popup.

To sum up, another excellent Intigriti is solved. Testing web assembly for the first time, I learned new tricks and improved my knowledge. The fuzzing trick to find the Unicode character and the exotic XSS payload are two things I will definitely keep in mind for the next time!

One extra note: I just realized during the writing of my report that my attack only works for Firefox and not for Chrome. I had no time yet to come up with another way, hence I’m very interested in how one of the 14 other hackers solved the challenge.

Read Entire Article