How to solve an XSS challenge from Intigriti in under 60 minutes
Twelve hours before the deadline, the latest XSS challenge from Intigriti was only solved by 14 people. Many people ask me how do I solve those challenges so quickly and the answer to that question is probably Experience. But is it only the experience gained from solving XSS challenges? I don’t think so. When attempting a problem, I try to crack them methodically, following a similar list of steps usually.
Let me walk you through my thought process in solving this particular challenge, draft the approximate timeline of each step, and share some lessons learned throughout my problem-solving career. Hope you enjoy it!
The timeline
3:47 pm: I notice a new tweet from Intigriti with one hint released already (but I haven’t read through it yet)
3:48 pm: I open the challenge’s page and can instantly notice that it has an embedded Password Generator
By looking at the source code of the page, I can see that it points to ./passgen.php
I check the source code again, but this time of the passgen.php
, and I can instantly notice that it is a single script page. Awesome, that’s my favorite type of challenge!
The content of the script is embedded below:
It takes me a few minutes to figure out what is the code doing and where the vulnerabilities are. Here are my first observations:
- There is some sort of sanitization in lines 1–9, but both < and > are allowed which hints that only arbitrary JavaScript is to be sanitized. I don’t look deeper into the RegExp yet.
- The only possible sink where the HTML data is reflected is the function showMessage at line 11. But it’s called only twice: in line 47 (static reflection) and in line 75 (dynamic reflection).
- It doesn’t seem that I can control any values reflected from calling showMessage in line 75, but I can see that wasm.instance is used to generate the password.
- Wasm instance is called in a very odd way — a JSON string is created from the user’s input in line 45. That means that most likely I can pass arbitrary fields in the JSON by providing malicious values.
- The vulnerability must be in the program.wasm that is dynamically executed!
3:55 pm: I don’t know yet if I will be able to exploit this, but I play around with trying to inject a new field into JSON via having passwordLength set as 3, "new_filed":1337
and it works. How did I test that? I created a custom function async function generate2() that inserts arbitrary values as passwordLength and then just verify that the application doesn’t crash.
let json = `{ “passwordLength”: ${myPasswordLength}, “seed”: ${crypto.getRandomValues(new Uint32Array(1))[0]}, “allowNumbers”: ${inputFields.allowNumbers.checked}, “allowSymbols”: ${inputFields.allowSymbols.checked} }`;
I think this is a crucial moment in my problem-solving methodology:
Let’s skip the actual exploitation until I collect all the missing pieces.
Skipping the exact way of how one could exploit a particular part of the code is a really useful technique but takes some self-determination because it’s very tempting to experiment with everything. Scheduling tasks for later prevents from being stuck in parts of challenges that are unintentional bugs but cannot be exploited to solve the challenge. Scratch the whole solution step by step first and then dive into the actual exploitation.
4:05 pm: I spend around 7 minutes looking into the binary of program.wasm, but I notice one very important thing: The last line of the binary most likely contains all the recognizable strings, which are: “true”, “false”, “seed”, “passwordLength”, “allowNumbers”, “allowSymbols”. That means that there are most likely no hidden fields in the JSON.
It might seem that it’s rather obvious that there won’t be any hidden functionalities, but the truth is that until you have proof of that fact, your brain will come back to the hesitation: “but maybe there are hidden functionalities?!” . That’s a very dangerous state of mind which should be avoided at all cost :)
4:12 pm: I ask myself the important question:
What for is the wasm module then?? What features it provides that weren’t possible in plain JavaScript?
And the answer seems straightforward: buffer overflow! If we poison the data maybe the binary will work in an unexpected way and reflect its own stack as the message? This looks like something the author might have thought of and I feel that I have all the missing pieces so IT’S TIME TO EXPLOIT! The scratch of the solution is:
- Insert arbitrary field in the JSON, e.g. “<u>123”: 123, via provided passwordLength
- Somehow poison the binary so it prints out its own stack and which contains the injected HTML code “<u>123”
4:13 pm: I start the exploitation phase. I use the modified generate2 function to try to poison the binary. My idea is to provide a very long password field and I try the following:
generate2('1000',"<u>123</u>") // the displayed password is empty because JSON error
generate2('2000',"<u>123</u>") // still empty
generate2('3000',"<u>123</u>") // WoWoWo I see some random stuff!
I spent around 10 minutes manually iterating through different numbers to see if I can see the underlined “123” but no luck. Actually no, I managed to get it working a few times with some modifications, but it was hard to reproduce. But here comes the important lesson again: Let’s skip the exploitation, for now, I already confirmed it’s working, try to see if I can pass anything I want as passwordLength in the first place.
4:23 pm: When trying to give the passwordLength as “10abc” you will get an instant error message that it's not a number. The code responsible for that is the following regular expression which can be found in line 46.
passwordLength.match(/^\d+$/gm)
Multiline flag in RegExp hints me that each line is most likely matched separately and indeed “100\n” will pass the check.
4:25 pm: I try to inject the string into passworldLength in the query parameter (challenge-0621.intigriti.io/passgen.php?passwordLength=3%0aabc), click on the Generate button and I get the Invalid number error. That’s because the value is inserted as the value of an <input> element and new line characters are ignored. But I know that /u2028 and /u2029 are also considered line terminators in JavaScript, so I call encodeURICompontent(‘\u2029’), get the encoded value, put it into the query, click on the Generate button, and voila, it works! challenge-0621.intigriti.io/passgen.php?passwordLength=3%E2%80%A9aabc
4:27 pm: I now try to reflect the HTML into the page. I realize that the offset in number was quite big, so maybe I need to fill the stack with more characters to have the HTML reflected. And indeed, that was the case. I appended 1000 a’s into the URL and I can see the underlined 123 (poc) after clicking on the Generate button.
4:30 pm: I see that I can inject a script and it will be executed.
<script>throw 123</script>
Once again, I look into the RegExp of which characters are allowed and I notice that “-” is also allowed. I knew that the goal is to craft an arbitrary XSS with a very limited charset and thankfully I already researched this area so I knew exactly what to do!
Calling “unsafeCharacters.length — “ a few times will shrink the array.
4:32 pm: We need to somehow trigger the script execution twice, i.e. click on the Generate button twice with the following payload:
1000\u2029<script>unsafeCharacters.length--;unsafeCharacters.length--;unsafeCharacters.length--;unsafeCharacters.length--;unsafeCharacters.length--;unsafeCharacters.length--;unsafeCharacters.length--;</script><script>alert()</script>
But the problem is, after the first click on Generate button, a popup with a generated password appears and when clicking on the close button it reloads the page, hence, our shrinking will be lost. Or will it? I tried to take advantage of back-forward cache in Firefox that upon going back in the history it will restore the JavaScript stack to have the page exactly how we left it. I spent around 10 minutes exploiting this approach and the only released hint under the tweet somehow convinced me that maybe it’s indeed it (yes, that’s around the time when I looked at the hint), because to exploit BF cache history.back() needs to be called :D
4:43 pm: I came to the conclusion that BF cache is probably not the intended way and it’s rather hard to exploit, if exploitable at all, so I looked for other ways. In no time I realized that document.body.lastElementChild.outerHTML — ; will just clear the popup. So the final payload was:
1000\u2029<script>unsafeCharacters.length--;unsafeCharacters.length--;unsafeCharacters.length--;unsafeCharacters.length--;unsafeCharacters.length--;unsafeCharacters.length--;unsafeCharacters.length--;document.body.lastElementChild.outerHTML--;</script><script>alert()</script>
and you need to click on the Generate button twice.
4:45pm: I created a PoC and quickly submitted it to the Intigrity platform.
Closing thoughts
The challenge was really cool, probably based on the challenge I created a few months back :) What was really nice about the challenge was that it was easy to understand but required certain problem-solving skills to solve. I believe that practicing those skills is useful in real life because the methodology is not very different from real-world problems and applying them can help you solve really hard problems.
So the answer to the question
How do you solve those challenges so quickly?
is that you have a huge advantage if you know that the solution exists. You can try to puzzle up everything, often going from both directions, and then prove that those puzzles can be solvable. In real life, you often don’t know whether there exists a solution to the problem, but once you assume that there is one, solving it becomes much easier!