社内CTF大会用の複数の脆弱性からなる問題を作成してみた

はじめに

本記事では、社内CTF大会向けに「複数の脆弱性を連鎖させて解く」形式のWeb問題を企画し、実装し、運用環境へ載せるまでの流れを問題の作成者の立場から解説します。単に脆弱性を作り込むのではなく、学習効果と競技体験を両立させるための設計・難易度調整に気を付けることで、解いていて楽しい問題をめざしました。

  • 本記事は2026年2月時点の情報に基づきます。
  • 掲載したソースコードの転用は禁止させていただきます。
  • 本記事で紹介しているアプリケーションのような、脆弱なソフトウェアをインターネットに公開することはおやめください。
  • 社内、社外限らず、CTF大会を実施する際には競技環境のセキュリティの安全確保に努めてください。

対象読者

本記事が想定する対象読者は以下の通りです。社内CTFの運営・問題作成に関わるセキュリティ担当者、教育担当者、または「CTFに出る側」から一歩進んで「作る側」に回りたいセキュリティエンジニアです。Webアプリの基本(ログイン、データベース、API)と、クラウド上でサービスを動かす基本(Linux、ポート開放、デプロイ)をうっすら理解していると読み進めやすいと思います。

目次

1. 背景・目的

私自身はこれまでCTF競技への参加経験はあるものの、問題を作る側の経験は多くありませんでした。一方で、日々さまざまなセキュリティ教材を学習する中で、インプットした知識や手法を「業務以外で試し、形にして残せるアウトプットの場」が欲しいと感じるようになりました。そこで、自分の理解を一段深める手段として、CTFの問題の作成に挑戦することにしました。

また当時、ペネトレーションテスト系資格の学習も進めており、知識が点ではなく線としてつながっていく感覚を得ていました。ペネトレーションテストでは、限られた手掛かりから段階的に侵入経路を組み立て、権限や到達範囲を広げていくことが多くあります。この侵入を積み上げていくような体験を、CTFでも無理なく再現できないかと考えたのが、今回の問題設計の出発点です。

その結果として、単一の脆弱性を解くだけで終わらず、複数の脆弱性を組み合わせて段階的にゴールへ近づく形式の問題の作成を意識しました。

2. CTFとは

2-1. CTFの概要

CTF(Capture The Flag)は、用意されたシステムやプログラムに潜む弱点を見つけ、条件を満たして「フラグ」と呼ばれる文字列を見つけ出す競技です。

2-2. 問題の種類

CTFの問題は、扱う技術領域ごとに複数のカテゴリがあります。代表例は次の通りです。

  • Web(認証、セッション、アクセス制御、入力検証など)
  • Pwn(メモリ破壊、サンドボックス回避など)
  • Crypto(暗号・署名・乱数の設計不備など)
  • Forensics(ログ解析、ファイル復元、メモリ解析など)
  • Reversing(バイナリ解析、難読化解除など)
  • Misc(プロトコル、OSINT、自動化など)

2-3. 国内/海外の大会の例

海外では大規模なオンライン大会が年間を通じて多数開催され、難易度や形式も幅広いです。国内でも学生・社会人向けを含む大会や、コミュニティ主催のイベントが定期的に行われています。

  • SECCON:国内で代表的なCTFで、オンライン予選とオンサイト決勝があるのが特徴です。
  • picoCTF:学生・初心者にも学びやすいオンラインCTF(常設)として有名です。

2-4. 社内CTF大会の概要

社内CTF大会は、社員のセキュリティ技術の底上げと、これまでセキュリティに馴染みのなかった層にも興味を持ってもらうことを目的に開催しています。​

本大会の特徴は、問題の企画・作成から競技環境の構築、当日の運営までをすべて社員が内製で行っている点です。今回はその大会に出題する問題を1問作成したので、どのようにテーマを決め、設計し、実装したのかを紹介します。

3. CTF問題作成について

3-1. 脆弱性の検討

CTF問題に ペネトレーションテストのようなストーリー性を取り入れる場合、最初に押さえるべき考慮ポイントの一つが「参加者がVPN接続できる前提かどうか」です。リバースシェルを張るような攻撃シナリオを軸にすると、どうしても到達性やネットワーク制約の都合でVPN環境が必要になりがちだからです。​

今回はVPNを利用しないCTF環境において、ネットワーク前提に依存しない形で「段階的に侵入していく体験」を成立させる方針にしました。具体的には、1つのWebサイトに複数の脆弱性を用意し、それらを順に攻略していくことで、フロント(Web)からバックエンド(データベース)へと到達していくシナリオです。

採用した脆弱性は、参加者にとって親しみのあるWebアプリケーション領域に寄せることとし、

  1. 不備のあるJWT(JSON Web Token)認証を持つAPIエンドポイント
  2. API上のSQLインジェクション
  3. 認証情報を平文で保存していることによるリスク

という3段階で構成しました。これらの脆弱性を選定した主な理由は、後述する、私が過去に参加した「Webアプリケーション開発研修」で開発したアプリケーションへ比較的容易に組み込めるためです。

3-2. 難易度の考慮

CTFでは、問題ごとに点数が設定され、その点数設計に合わせて難易度が調整されるのが一般的です。 今回は「600点問題」という大会内で高難度に位置づけられた600点枠の作成が求められており、参加者も“普段セキュリティに馴染みがない層”から“実務でセキュリティに携わる層”まで幅広い前提でした。そこで、全チームが解ける難易度ではなく、最終的に1〜2チームが解ければ十分というラインを目標に設定しました。​

また、複数脆弱性を段階的に攻略する形式は、入口でつまずくとその後の攻略が止まってしまうため、特に最初の脆弱性に気づくための足掛かりについては、問題文中にヒントとなるキーワードを意識的に含めました。参加者はここでAPIの存在に気づき、次にJWTの構造を調べることを期待しました。

APIエンドポイントがあることを誘うキーワード

難易度は問題レビューアーと綿密にすり合わせを行い、ヒント量と探索難度のバランスが崩れないように何度か調整しました。実際の競技では、多数のチームが参加する中で1チームが正解に到達し、狙い通りの難易度にできたと感じています。

4. Webアプリケーションの作成

4-1. ベースアプリケーションの流用

問題の作成にあたっては、別途受講していた「Webアプリケーション開発研修」で開発したアプリケーションを土台として利用しました。ゼロから新規開発するよりも、既存の画面・データベース・ログイン機能が揃っているため、CTF用の改修点(API追加や脆弱性の仕込み)に集中できると判断したためです。また、研修時には脆弱性ができないように開発をしていましたが、逆に脆弱性のあるアプリケーションに改修することで、セキュアなアプリケーション開発に対する理解を深めることができるのではと考えました。

4-2. 技術スタックと既存機能

アプリケーションは Python で実装しており、Webフレームワークに Flask、テンプレートに Jinja2 を利用しています(FlaskはJinja2をテンプレートエンジンとして利用する構成が標準です)。

バックエンドデータベースには MariaDB を採用し、ユーザー登録・ユーザーログインといった基本機能がすでに実装されている状態でした(MariaDBは一般的なオープンソースのRDBMS(Relational Database Management System)です)。

4-3. API機能の追加

「段階的に侵入していく体験」を作るため、既存のWeb画面に加えてAPI機能を追加し、さらにAPIを利用する認証機構も実装しました。新たに追加したエンドポイントは以下の2つです。

  • /api/v1/auth:Web画面で登録したユーザー名・パスワードで認証し、成功したらJWTトークンを発行
  • /api/v1/search:JWTトークンによる認証を前提に、検索機能を提供

ここでの狙いは「Web画面(ブラウザ操作)とAPI(プログラム的操作)」を行き来する導線を作り、CTFとしての探索の幅を広げることでした。

4-3-1. /api/v1/auth:JWT認証の実装と脆弱性

/api/v1/auth では、ユーザー名・パスワードの検証が成功するとJWTを返すようにしました。JWTは「トークンが改ざんされていないこと(署名検証)」と「権限情報(roleなど)の扱い」をどう設計するかが肝になります。

このトークン検証箇所に意図した脆弱性を埋め込み、次の段階へ進むための足掛かりにしました。 実際に脆弱なコードを実装する際、該当する脆弱なパラメータは標準設定では無効化されていたため、そのままでは再現できず苦労しました。JWTトークンを扱う関数の挙動や設定項目をひとつひとつ調査したうえで、意図した脆弱性を成立させるために、明示的に脆弱なオプションを指定する必要がありました。

※以下のコードはCTF教材用であり、実運用に転用しないでください。

def verify_admin_token(token):
    try:
        # トークンのヘッダーからアルゴリズムを取得
        header = jwt.get_unverified_header(token)
        algorithm = header.get('alg')
        
        # アルゴリズムに応じてキーを選択
        if algorithm == "none":
            payload = jwt.decode(token, None, algorithms=["none"], options={
                    "verify_signature": False,  # 署名検証無効化
                    "verify_exp": False,        # 有効期限検証無効化
                    "verify_iat": False         # 発行時刻検証無効化
                })
        elif algorithm == "HS256":
            payload = jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
        if payload.get('role') == 'admin':
            return True, payload
        else:
            return False, "Insufficient privileges"
    except jwt.ExpiredSignatureError:
        return False, "Token expired"
    except jwt.InvalidTokenError as e:
        return False, f"Invalid token: {str(e)}"

4-3-2. /api/v1/search:検索APIとSQLインジェクション

 /api/v1/search では、データベース検索を行うAPIとして実装し、ここにSQLインジェクションの脆弱性を仕込みました。600点(最難関)とはいえ「複数脆弱性を組み合わせる問題」であり、参加者層も幅広い想定だったため、ここを過度に難しくしすぎない方針にしました。

また、参加者が「脆弱性の存在に気づける」ことも重要だったため、エラーをあえてレスポンスに含める(=探索の手掛かりを残す)設計にしています。もちろん実運用のアプリでは推奨されない挙動ですが、CTF教材としては「気づき」のコストを下げるためそのような実装にしました。

※以下のコードはCTF教材用であり、実運用に転用しないでください。

try:
    # 脆弱性: SQLインジェクション
    sql = f"SELECT username, category, ioc, score FROM history WHERE ioc LIKE '%{ioc_value}%'"

    # 脆弱なSQL実行
    results = database.execute_sql_select_raw(sql)

    return jsonify({
        "status": "success",
        "ioc": ioc_value,
        "results": results,
        "count": len(results),
        "message": f"Found {len(results)} results in flags database"
    })

except Exception as e:
    # エラー情報の漏洩
    return jsonify({
        "status": "error",
        "message": f"Database error: {str(e)}",
        "query": sql if 'sql' in locals() else "N/A",
        "ioc": ioc_value
    }), 500

※参考として、本来は「プレースホルダ(パラメータ化)を使い、SQL文字列を組み立てない」実装を行うことがセキュアなアプリケーションとして求められます。 ​

sql = "SELECT username, category, ioc, score FROM history WHERE ioc LIKE %s"
params = (f"%{ioc_value}%",)
results = database.execute_sql_select(sql, params)

4-3-3. 平文の認証情報を発見させる

最後の段階では、SQLインジェクションで取得できる情報の中に、管理者アカウントに関する「認証情報が危険な形で保存されている」状態を用意しました。具体的には、CTF用のダミーデータとして管理者のユーザー名とパスワードを平文で格納しておき、参加者がそれを発見したら、ブラウザでWebアプリのログイン画面へ戻って管理者としてログインし、フラグが表示される——という一連の流れです。

この構成にした理由は、単に「SQLインジェクションできた」で終わらせず、情報の保存形式が最終的な侵害に直結する、という実務寄りの学びまでつなげたかったからです。

本来のアプリケーション実装において、PythonであればBcryptなどを用いたハッシュ化を行い、ハッシュ値をデータベースに格納することで、仮にデータベースの内容が流出したとしても平文のパスワードは守られるという状態にしておかなければなりません。

5. 解法(WriteUp)の作成

CTFでは、問題の解き方をまとめた資料を一般にWriteUp(write-up)と呼びます。 今回のWriteUpは、わかりやすさを最優先し、「実施する操作内容」「使用するツール名」「実行結果の例」を淡々と並べるシンプルなテキスト形式で作成しました。​

一方で、セキュリティ学習という観点では、なぜその操作に至るのか(観察ポイント、判断の根拠、つまずきやすい点、安全な実装のためのヒントなど)まで含めて整理したほうが、参加者にとっても後から読む自分にとっても価値が高いWriteUpになります。

そのため、この点は反省として残っており、次回は手順の列挙に加えて「どの挙動がヒントだったか」「本来の安全な実装ならどうなるか」「修正版ではどこを直すか」といった要素についても取り入れ、改善していくつもりです。

作成したWriteUp(一部)

6. まとめ

本記事では、社内CTF向けに「複数の脆弱性を連鎖させて解く」Web問題を1問作成した過程(設計・難易度調整・実装)を紹介しました。​

複数脆弱性問題では、前提(VPN有無)と導線(入口・中間・ゴール)を先に固めるのが重要だと分かりました。WriteUpは手順中心で簡素だったため、次回は観察ポイントや安全な実装のためのヒントまで書いて教材としての価値を上げたいと思います。

執筆者

鴨下 将成(NTT西日本 セキュリティ&トラスト部所属)
社内セキュリティ業務に携わっています。
好きな食べ物はラーメンです。
OSCP、CISSP、GPEN、GREM、GCFE、CEH、情報処理安全確保支援士

商標

  • Python は Python Software Foundation の登録商標です。
  • MariaDB は MariaDB Corporation Ab の登録商標です。
  • その他、記載されている会社名・製品名は各社の商標または登録商標です。

© NTT WEST, Inc.