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つです
また、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)
+
+
+ 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で、問題ファイルが全て流出することがわかりました。したがって、次のような対策をする必要があります。
- 「IAMと管理/サービス アカウント」のページから「サービス アカウントの作成」をする
- Cloud Storageで、ファイルを保存しているバケットを選択→「権限」→「アクセスを許可」で1.のアカウントを選択
- ロールで「Storage レガシー オブジェクト読み取り」を選択
- Compute Engineで、使用するサーバーを指定。「Identity and API access」から1.で作成したサービスアカウントを選択
これにより、Cloud Storageでファイルにアクセスできるけど、バケットに保存されているファイルをリスト化できないようになります。ファイル名が推測困難ならばリスクは低いですが、完璧ではない対策でした。このことに気づいたのが開催1日前だったので、頼むからこれで対策十分であってくれ!と願っていました。
反省
結論として、この構成は間違いで次のような構成にするべきだと反省しました。
- 1問題1インスタンス
- 同じサーバーに異なる問題は入れない
理由は以下の通りです
- 一つの問題がDoSを受けたときに複数の問題が起動不可になる
- 実際、Adversarial Loginが過負荷になり、複数の問題に影響を与えました
- RCEを伴う問題から、万が一コンテナを脱出できた場合、他の問題のフラグが流出する可能性がある
- 他の問題のコードに修正が入る度に、誤って他のコードを書き換えてしまうリスクが生じてしまう
また、問題ファイルの管理は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でマウントすることもできる
- 形式は
{_}
-
SUBNETPOOL
– 割り当てられるサブネットのプールの範囲
-
MODE0
– 公開するポートの「モード」
-
Forward
– ポートを直接公開する
-
Proxy
– プロキシサーバーが起動し、Host
ヘッダーに応じてサーバーを振り分けられる
- 利点 – ポートスキャニングによる他のサーバーの覗き見ができなくなる可能性がある
- 欠点 – プロキシサーバーを挟むせいで動かなくなる問題や、問題の性質が変わってしまう
- 接続情報は
://0.:
-
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//environ
の読み込みなど、意図しない方法でフラグが取れてしまうことを防止できます。
- 配布ファイルでは
FLAG=fwectf{fake_flag} docker compose up
のように実行しないといけないのが、他の問題と違って面倒だという意見もあったので、より良い方法がないか模索します。
動的フラグを利用するかどうかは十分に検討した方が良いと思います。賞金がかかっているような大会ならチート防止のためできるだけやったほうが良いですが、時間をかけてフラグを入手することが許容されるような問題に関しては、インスタンス起動のたびにフラグが変わってしまうのは不都合になるかもしれません。
終わりに
インフラを担当するのはASUSN CTF 2に続き2回目でしたが、そのときに比べてかなり大規模になったため反省点がかなりありました。特に、GCPが使い慣れていなくて苦労した部分がありましたが、無事開催ができてよかったです。
参加していただいた皆様、本当にありがとうございました。