💨

Full Weak Engineer CTF 2025 インフラ覚書

に公開

2025.08.29 19:00 ~ 2025.08.31 19:00で開催されたFull Weak Engineer CTF 2025でのインフラを担当したので、やったことや発生した問題についてまとめます。

個々の問題のサーバーについては、作問者がやったり皆で協力して作成したりしたので、ここでは触れません。ソースコードは以下にありますので、参考にしてください。

https://github.com/full-weak-engineer/FWE_CTF_2025_public/tree/main

CTFd

sasakiy84 さんのブログが詳しく書いてあり、これをほとんど真似しただけですので詳細は触れません。

違う点は以下の3点

  • メールサーバーの連携はしなかった
    • パスワードを忘れた場合に、チケットによる対応になります。全体的に見ると、メール連携しない方が運営の時間対効果は良いと判断しましたが、チケット対応できない時間が多いと、ログインできなくてかわいそうな人が生まれる可能性が高いです。
    • パスワード忘れの問い合わせは全部で8回でした
  • ロードバランサーは建てず、CTFdサーバーは1台運用
  • Cloudflareのプロキシを利用

Cloudflareのプロキシ方法

前提: Cloudflareにドメインを登録済み

  • 「SSL/TLS」>「概要」のページから、「SSL/TLS 暗号化」を「フル (厳密)」に
  • 「SSL/TLS」>「オリジン サーバー」のページから、オリジン証明書で「証明書を作成」でPEMファイルを生成する。
  • 「オリジン証明書」を/etc/ssl/origin.crt、「プライベート キー」を/etc/ssl/origin.keyとして保存し以下を実行
sudo chmod 444 /etc/ssl/origin*
  • CTFd/conf/nginx/http.confを以下のように変更
CTFd/conf/nginx/http.conf
worker_processes 4;

events {

  worker_connections 1024;
}

http {

  # Configuration containing list of application servers
  upstream app_servers {

    server ctfd:8000;
  }

  server {

    listen 443 ssl;
    server_name ctf.fwectf.com;

    ssl_certificate     /etc/nginx/ssl/origin.crt;
    ssl_certificate_key /etc/nginx/ssl/origin.key;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;

    gzip on;

    client_max_body_size 4G;
    set_real_ip_from 173.245.48.0/20;
    set_real_ip_from 103.21.244.0/22;
    set_real_ip_from 103.22.200.0/22;
    set_real_ip_from 103.31.4.0/22;
    set_real_ip_from 141.101.64.0/18;
    set_real_ip_from 108.162.192.0/18;
    set_real_ip_from 190.93.240.0/20;
    set_real_ip_from 188.114.96.0/20;
    set_real_ip_from 197.234.240.0/22;
    set_real_ip_from 198.41.128.0/17;
    set_real_ip_from 162.158.0.0/15;
    set_real_ip_from 104.16.0.0/13;
    set_real_ip_from 104.24.0.0/14;
    set_real_ip_from 172.64.0.0/13;
    set_real_ip_from 131.0.72.0/22;
    set_real_ip_from 2400:cb00::/32;
    set_real_ip_from 2606:4700::/32;
    set_real_ip_from 2803:f800::/32;
    set_real_ip_from 2405:b500::/32;
    set_real_ip_from 2405:8100::/32;
    set_real_ip_from 2a06:98c0::/29;
    set_real_ip_from 2c0f:f248::/32;
    real_ip_header CF-Connecting-IP;

    # Handle Server Sent Events for Notifications
    location /events {

      proxy_pass http://app_servers;
      proxy_set_header Connection '';
      proxy_http_version 1.1;
      chunked_transfer_encoding off;
      proxy_buffering off;
      proxy_cache off;
      proxy_redirect off;
      proxy_set_header Host $host;
      proxy_set_header X-Real-IP $remote_addr;
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_set_header X-Forwarded-Host $server_name;
    }

    # Proxy connections to the application servers
    location / {

      proxy_pass http://app_servers;
      proxy_redirect off;
      proxy_set_header Host $host;
      proxy_set_header X-Real-IP $remote_addr;
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_set_header X-Forwarded-Host $server_name;
    }
  }
}

  • CTFd/docker-compose.ymlのnginxを以下のように変更
CTFd/docker-compose.yml
  nginx:
    image: nginx:stable
    restart: always
    volumes:
      - ./conf/nginx/http.conf:/etc/nginx/nginx.conf
      - /etc/ssl/origin.crt:/etc/nginx/ssl/origin.crt
      - /etc/ssl/origin.key:/etc/nginx/ssl/origin.key
    ports:
      - 443:443
    depends_on:
      - ctfd
  • 「DNS」>「レコード」から
    • 「タイプ」はA
    • 「名前」をサブドメイン(https://ctf.fwectf.com/でアクセスするならば、ctf)
    • 「コンテンツ」をCTFdサーバーのIPアドレス
    • 「プロキシステータス」を「プロキシ済み」に
  • CTFdの解放ポートを443だけにする

Cloudflareのキャッシュヒット率

12.18 GB中4.52 GBがキャッシュとしてエンドサーバーから提供されたので、一定の効果があったと考えています。

CTFdのプラグイン

CTFdのプラグインとして利用したのは以下の3つです

  • ctfd-discord-webhook-plugin
    • CTFd/docker-compose.ymlに以下を追加
      - DISCORD_WEBHOOK_URL=<DiscordのWebhookのURL>
      - DISCORD_WEBHOOK_LIMIT=0
      - DISCORD_WEBHOOK_FSTRING=1
      - 'DISCORD_WEBHOOK_MESSAGE={["🥇 ", "🥈 ", "🥉 "][data.solves-1] if 1<=data.solves<=3 else ""}Congratulations to team {data.team} for the {data.fsolves} solve on challenge {data.challenge}!'
      
    • DiscordのWebhookのURLの取得方法
    • 数が多すぎたので、DISCORD_WEBHOOK_LIMITは3の方が良かったかもしれない
  • CTFd Geo Challenges Plugin
    • fork元だとDynamic Scoringが適用されないので注意
  • CTFd-Instance-Challenge-Plugin
    • インスタンサーの章で解説

また、dynamic_challengesのプラグインを直接変更して、独自の点数減衰関数を適用しました。また、geo_challengesも同様に変更する必要があります。

CTFd/plugins/dynamic_challenges/__init__.py
index 04cae524..533d383d 100644
--- a/CTFd/plugins/dynamic_challenges/__init__.py
+++ b/CTFd/plugins/dynamic_challenges/__init__.py
@@ -1,14 +1,29 @@
+import math
 from flask import Blueprint
 
 from CTFd.exceptions.challenges import (
     ChallengeCreateException,
     ChallengeUpdateException,
 )
-from CTFd.models import Challenges, db
+from CTFd.models import Challenges, Solves, db
 from CTFd.plugins import register_plugin_assets_directory
 from CTFd.plugins.challenges import CHALLENGE_CLASSES, BaseChallenge
-from CTFd.plugins.dynamic_challenges.decay import DECAY_FUNCTIONS, logarithmic
 from CTFd.plugins.migrations import upgrade
+from CTFd.utils.modes import get_model
+
+def get_solve_count(challenge):
+    Model = get_model()
+
+    solve_count = (
+        Solves.query.join(Model, Solves.account_id == Model.id)
+        .filter(
+            Solves.challenge_id == challenge.id,
+            Model.hidden == False,
+            Model.banned == False,
+        )
+        .count()
+    )
+    return solve_count
 
 
 class DynamicChallenge(Challenges):
@@ -57,8 +72,11 @@ class DynamicValueChallenge(BaseChallenge):
 
     @classmethod
     def calculate_value(cls, challenge):
-        f = DECAY_FUNCTIONS.get(challenge.function, logarithmic)
-        value = f(challenge)
+        # f = DECAY_FUNCTIONS.get(challenge.function, logarithmic)
+        # value = f(challenge)
+        solve_count_sub_1 = max(get_solve_count(challenge)-1, 0)
+        value = 19 + (481/(1+(solve_count_sub_1/75)**1.11))
+        value = max(100, math.ceil(value))
 
         challenge.value = value
         db.session.commit()
@@ -120,3 +138,4 @@ def load(app):
     register_plugin_assets_directory(
         app, base_path="/plugins/dynamic_challenges/assets/"
     )

CTFdのサーバースペックと利用率

  • CTF開催中
    • サーバー: e2-standard-4
    • データベース: 2 vCPU, 8 GB
  • CTF開催前後
    • サーバー: e2-small
    • データベース: 1 vCPU, 3.75 GB

サーバーのメトリクス

データベースのメトリクス

やっていた感触として、あまり問題画面でラグを感じた時間はありませんでしたが、スコアボードの計算がかなり遅く感じたような気がします。統計を行う過程のどこかがボトルネックになっているような気がするので、要調査です。

問題サーバー

概要

  • サーバーは3台構成で、複数の問題が同じサーバーで稼働している。
  • すべての問題はdockerで管理されており、docker compose up <問題名>で起動できるようにしてある
  • Cloud Storageにすべての問題を含んだzipをアップロードし、それをダウンロード・解凍してアップデートする形式にした
  • アップデート手順(OSはContainer Optimized OSであることに注意)
    ACCESS_TOKEN=$(curl -s -H "Metadata-Flavor: Google" "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token"| jq -r .access_token)
    curl -H "Authorization: Bearer $ACCESS_TOKEN" https://storage.googleapis.com/ctfd-backup/<ファイル名> -o remote.zip
    python3 -m zipfile -e remote.zip ./remote
    
  • bash server1_wave1.shのように実行できるファイルを用意して、どのサーバーでどのタイミングで起動するべきかを記述した。

SSRF対策

Cloud Storageに問題ファイルをアップロードすると、https://storage.googleapis.com/に対するSSRFで、問題ファイルが全て流出することがわかりました。したがって、次のような対策をする必要があります。

  1. 「IAMと管理/サービス アカウント」のページから「サービス アカウントの作成」をする
    • ロールやプリンシパルはなにも設定しなくてOK
  2. Cloud Storageで、ファイルを保存しているバケットを選択→「権限」→「アクセスを許可」で1.のアカウントを選択
  3. ロールで「Storage レガシー オブジェクト読み取り」を選択
  4. Compute Engineで、使用するサーバーを指定。「Identity and API access」から1.で作成したサービスアカウントを選択

これにより、Cloud Storageでファイルにアクセスできるけど、バケットに保存されているファイルをリスト化できないようになります。ファイル名が推測困難ならばリスクは低いですが、完璧ではない対策でした。このことに気づいたのが開催1日前だったので、頼むからこれで対策十分であってくれ!と願っていました。

反省

結論として、この構成は間違いで次のような構成にするべきだと反省しました。

  1. 1問題1インスタンス
  2. 同じサーバーに異なる問題は入れない

理由は以下の通りです

  1. 一つの問題がDoSを受けたときに複数の問題が起動不可になる
    • 実際、Adversarial Loginが過負荷になり、複数の問題に影響を与えました
  2. RCEを伴う問題から、万が一コンテナを脱出できた場合、他の問題のフラグが流出する可能性がある
  3. 他の問題のコードに修正が入る度に、誤って他のコードを書き換えてしまうリスクが生じてしまう

また、問題ファイルの管理はGitで行うべきでした。こういうところ横着するのよくない。

サーバースペックと利用率

サーバーはすべてe2-standard-4を利用しました。

サーバー1

サーバー2

サーバー3

  • サーバー1ではAdversarial Loginを実行していましたが、これに対して短時間に大量のリクエストが送られたため、大きなスパイクが見られます
    • このように、DoSに弱い問題はPoWを設定するといったような対策をする必要がありました
  • サーバー3でも一時的なスパイクが見られましたが、サーバーが落ちるといったことがなかったため詳細は調査していないので不明です
  • 全体として、利用率は平均して数%ですが、瞬間的に負荷のかかるリクエストや、予想していない負荷のかかる解法が利用される可能性もあるため、余裕のあるスペックでサーバーを動かすのが良さそうです

インスタンサー

CTF-InstancerそのCTFdプラグインを利用しました。

利用方法としては、CTF-Instancerにgit cloneし、compose.ymlに次のように記述しました。また、Personal Websiteの問題のコード自体はpersonal_website/chalにあります。

compose.yml
services:
  personal_website:
    build: ./CTF-Instancer
    volumes:
    - ./personal_website/chal:/app/chal:ro
    - ./images:/app/images
    privileged: true
    environment:
    - PORT=8000
    - SESSIONNAME=session
    - SERVICEMODE=api
    - TOKEN=this_is_test_token
    - TITLE=personal_website
    - MINPORT=20000
    - MAXPORT=20999
    - VALIDITY=3m
    - FLAGPREFIX=fwectf
    - FLAGMSG=__m3R6e_H4_MAj1_Kik3N_
    - SUBNETPOOL=172.30.0.0/16
    - CHALDIR=chal
    - BASESCHEME=http
    - BASEHOST=chal2.fwectf.com
    - CAPTCHA_SITE_KEY=
    - CAPTCHA_SECRET_KEY=
    - CAPTCHA_SRC=
    - CAPTCHA_CLASS=
    - CAPTCHA_BACKEND=
    - CAPTCHA_RESPONSE_NAME=
    - CTFDURL=https://ctf.fwectf.com/
    - MODE0=Proxy
    ports:
    - 8006:8000
    restart: unless-stopped
    networks:
    - personal_website_net
  • SERVICEMODE - apiにするとCTFdのプラグインと連携される。webとすることで、テスト用にCTFdなしで実行できる。
  • TOKEN - CTFdのプラグインを導入すると、「API Key」というフィールドが指定できるので、ここに同じ文字列を入力する
  • MINPORT, MAXPORT - インスタンスが割り当てられるポートの最小値と最大値
  • FLAGPREFIX, FLAGMSG - 動的フラグ(インスタンスごとに異なるフラグ)を指定できる。これを利用する場合、問題ページの「Create Flag」で「instance_dynamic」を指定する必要がある。
    • フラグは、ビルド時にFLAGという環境変数で参照できる
    • あるいは、/tmp/${ID}/flagが生成されるので、これをvolumesでマウントすることもできる
    • 形式は<FLAGPREFIX>{<FLAGMSG>_<ランダムなhex>}
  • SUBNETPOOL - 割り当てられるサブネットのプールの範囲
  • MODE0 - 公開するポートの「モード」
    • Forward - ポートを直接公開する
      • 接続情報は<BASESCHEME>://<BASEHOST>:<個別のポート>
    • Proxy - プロキシサーバーが起動し、Hostヘッダーに応じてサーバーを振り分けられる
      • 利点 - ポートスキャニングによる他のサーバーの覗き見ができなくなる可能性がある
      • 欠点 - プロキシサーバーを挟むせいで動かなくなる問題や、問題の性質が変わってしまう
      • 接続情報は<BASESCHEME>://<ID>0.<BASEHOST>:<個別のポート>
    • Command - (試していないから詳細はわからないが)Forwardと似ているが、COMMAND0環境変数から接続情報の文字列をテンプレートで表示できる
    • 複数のポートを公開する場合、MODE1, MODE2のように複数指定することもできる

そして、personal_website/chal/docker-compose.ymlは次のように記載します。

personal_website/chal/docker-compose.yml
secrets:
  flag:
    environment: FLAG
services:
  app:
    image: fwectf/private_website
    build:
      context: ./src
    ports:
    - ${PORT0}:8080
    environment:
    - PORT=8080
    secrets:
    - source: flag
      target: /flag.txt
      uid: '0'
      gid: '0'
      mode: 0o400
    networks:
      default: {}
networks:
  default:
    ipam:
      config:
      - subnet: ${SUBNET0}

注意点:

  • ${PORT0}で指定したポートと${SUBNET0}で指定したサブネットは、MODE0で指定した方式でユーザーに公開されます
  • ${PORT0}${SUBNET0}は一見環境変数のように見えますが、実際は利用するポートやサブネットに直接置換されてからdocker compose upされます。したがって、${PORT0:-8080}のようにデフォルト値を指定することはできません。
  • image名は必ず指定する必要があります
  • argsで動的フラグを読み込むことはできません。argsはイメージビルド時に参照されますが、動的フラグはビルド時には参照不可で、実行時の環境変数でしか参照できないからです。
  • RCEを目的とする場合、私は上記のようにsecretsという仕組みを利用し、rootのみが読み込める/flag.txtを作成し、setuidされた/readflag実行ファイルで実行する、といった形式にしました。
    • 環境変数を経由しないため、/proc/<PID>/environの読み込みなど、意図しない方法でフラグが取れてしまうことを防止できます。
    • 配布ファイルではFLAG=fwectf{fake_flag} docker compose upのように実行しないといけないのが、他の問題と違って面倒だという意見もあったので、より良い方法がないか模索します。

動的フラグを利用するかどうかは十分に検討した方が良いと思います。賞金がかかっているような大会ならチート防止のためできるだけやったほうが良いですが、時間をかけてフラグを入手することが許容されるような問題に関しては、インスタンス起動のたびにフラグが変わってしまうのは不都合になるかもしれません。

終わりに

インフラを担当するのはASUSN CTF 2に続き2回目でしたが、そのときに比べてかなり大規模になったため反省点がかなりありました。特に、GCPが使い慣れていなくて苦労した部分がありましたが、無事開催ができてよかったです。

参加していただいた皆様、本当にありがとうございました。

Discussion