🔐

CTFdを(必要以上にいろいろ使いながら)AWSで立てる

2024/08/28に公開

モチベーション

仕事でCTFdをAWSに建てたのをきっかけに、もっとお金と時間をかけたらどういう構成にするだろうか?というのをブログにしました。業務でもCTFdをECSで使ったりしているのは変わっていないので、メイン部はそんなに差分はなかったりします。
環境変数周りとかはdocker composeで立てている人も参考になる部分は多少はあるかもしれません。
図表少なめ、文字多めになってます。(もう少し簡略化したもので何か出せればいいかなぁぐらいの気持ちです)

同じくAWSにCTFdを立てている以下が参考になります。
https://github.com/1nval1dctf/terraform-aws-ctfd

よく触っていたのはCTFdのバージョンが3.7.0(2024年2月頃)のときなので、少し変わっているかも。

概要図

IAMなど省略しているサービスはあります。
構成図

だいたいユーザから近いほうから概要を羅列します。

  • CloudFront
    • Challengeの添付ファイルなどが格納されるので、それがキャッシュできる
    • 添付ファイルが格納されているS3をオリジンに追加している
  • WAF
    • 長期運用してるわけでもないしとりあえずお気持ちで。IP制限などもここでかけている
  • ECS
    • (docker composeではなく)CTFdのコンテナをFargateで動かしている
    • 環境変数でDBなどのアクセス情報を渡している
    • アウトバウンド通信もだいたい制限
  • Aurora Serverless v2
    • できるだけ考えることを少なくしたいのでAurora Serverless v2でオートスケーリングしている
      実際にはインスタンス数の管理などは必要
  • ElastiCache for Redis
    • スケーリングとかはしてない
    • Serverlessは採用しなかった
  • S3
    • 問題の添付ファイルやCTFdに設定したロゴなどはS3に保存される
  • SES
    • アカウントリセット用のメールなどをSESから送信している
  • CDK

細かい話

Route53

ドメイン

SESなどのメールを送る機能が不要であれば、CloudFrontやALBに付与されたDNS名など利用するなどもできるが、ドメインだけは事前に用意することを前提としている。

以降、ここで設定しているホストゾーンをexample.comと表記する。

CloudFront

キャッシュ

正直そんなに詳しいわけではないので、雰囲気で設定している。あんまりCDNを活かせてる感じはない。
以下のような考えで作った。

  • 前提としてそもそもキャッシュを欲張らない。
  • キャッシュしても問題なさそうなパスを探す
    • themes/*files/*
  • 怪我しそうな部分は明示的にキャッシュしないかつ、ほぼすべてのオリジンへのリクエストを通す
    • api/*admin/*などが対象
  • 上記でひっかからなかった場合にはデフォルトでキャッシュしない。

CTFdは自分で追加のページを作成できるので、それをキャッシュ対象に設定してもいいかもしれない。

CTFdのバージョン3.7.1で以下のような変更が追加されているので、挙動が変わってるかも

Static assets (theme files, static files) will now return a Cache-Control header with a max-age of 3600

オリジン

CloudFrontから見るオリジンは基本はALBとなりますが、S3のパスだけはオリジンにS3を設定しています。
S3に関連する設定なのでS3の項目で説明します。

レコード

CloudFrontのディストリビューションをエイリアスのターゲットにして、ctf.example.comにAレコードとAAAAレコードと設定している。

basic認証

CloudFront functionsで認証をかけるようなものを作ってみたが、CTFdでAuthorizationヘッダーをすでに利用しているらしく避けたほうが無難。

https://github.com/CTFd/CTFd/blob/master/tests/api/test_tokens.py#L62
https://github.com/CTFd/CTFd/blob/master/CTFd/utils/initialization/init.py#L284

WAF

IaC化で一番めんどくさかった部分。そもそもあんまり向いてない気がする。
ルール設定などが効果に対して時間がかかりがちなので、必要かどうかは開催形態に依存しそう。

ルール

基本はAWSが提供するマネージドルールを利用しているが、IPマッチなどの一部ルールを個別に作っている。
以下のようなルールを設定している。AWSManagedRulesから始まるルールがAWSが提供するルール
ルール名が結構雑。この後書き直したのでGitHub上は違うルール名になっている。
Rules

だいたいこんな感じ

個々のルールについて(長いのでトグルした)

TrustedIp

唯一のAllowルール
基本は利用しない想定で、特定ユーザがアクセスできないなどの緊急時に特定IPアドレスからの通信をすべて許可するルール(マッチすると以降のルールを評価しない)

LimitRequests

Rateベースのルール
同一送信元IPから閾値以上のリクエストがあった場合にはBlockする
組織内で開催とかにすると同じIPでマッチすることになるので、カウント対象をXFFにするか、そもそも無効にするとかにしたほうがいい。

ルール
{
  "Name": "LimitRequests",
  "Priority": 0,
  "Statement": {
    "RateBasedStatement": {
      "Limit": 1000,
      "EvaluationWindowSec": 300,
      "AggregateKeyType": "IP"
    }
  },
  "Action": {
    "Block": {}
  },
  "VisibilityConfig": {
    "SampledRequestsEnabled": true,
    "CloudWatchMetricsEnabled": true,
    "MetricName": "LimitRequests"
  }
}

SizeRestriction & AdminIp

後段のAWSManagedRulesCommonRuleSetSizeRestrictions_BODYは8KB以上でブロックのルールになるが、CloudFrontでのWAFの評価ではデフォルトで16KBまでが評価対象になるため、このルールで定義している。

なお、例外として以下の2点の両方にマッチする場合にはルールの評価を行わない。また特定IPアドレスが空の場合には例外は特定パスだけで判断される。

  • ロゴイメージや問題の添付ファイルのアップロードが想定されるパス(/api//setup)では評価しない
  • 設定ファイルに記載した特定IPアドレスにマッチする場合には評価しない
ルール
{
  "Name": "SizeRestriction",
  "Priority": 1,
  "Statement": {
    "AndStatement": {
      "Statements": [
        {
          "SizeConstraintStatement": {
            "FieldToMatch": {
              "Body": {}
            },
            "ComparisonOperator": "GT",
            "Size": 16384,
            "TextTransformations": [
              {
                "Priority": 0,
                "Type": "NONE"
              }
            ]
          }
        },
        {
          "NotStatement": {
            "Statement": {
              "AndStatement": {
                "Statements": [
                  {
                    "OrStatement": {
                      "Statements": [
                        {
                          "ByteMatchStatement": {
                            "SearchString": "/api/",
                            "FieldToMatch": {
                              "UriPath": {}
                            },
                            "TextTransformations": [
                              {
                                "Priority": 0,
                                "Type": "NONE"
                              }
                            ],
                            "PositionalConstraint": "STARTS_WITH"
                          }
                        },
                        {
                          "ByteMatchStatement": {
                            "SearchString": "/setup",
                            "FieldToMatch": {
                              "UriPath": {}
                            },
                            "TextTransformations": [
                              {
                                "Priority": 0,
                                "Type": "NONE"
                              }
                            ],
                            "PositionalConstraint": "EXACTLY"
                          }
                        }
                      ]
                    }
                  },
                  {
                    "IPSetReferenceStatement": {
                      "ARN": "arn:aws:wafv2:us-east-1:123456789012:global/ipset/AdminIpv6Set/f170302a-1aad-4593-b1d7-14d4o0sd6443"
                    }
                  }
                ]
              }
            }
          }
        }
      ]
    }
  },
  "Action": {
    "Block": {}
  },
  "VisibilityConfig": {
    "SampledRequestsEnabled": true,
    "CloudWatchMetricsEnabled": true,
    "MetricName": "SizeRestriction"
  }
}

AllowedIp

組織内でのCTFの開催などで、特定のIPアドレスからの利用がわかっている場合に設定する。
これが設定される場合には、特定IPアドレス外からのアクセスはブロックされる。
TrustedIPルールと異なり、特定のIPからの通信であっても以降のWAFルールに評価される。

ルール
{
  "Name": "AllowedIp",
  "Priority": 2,
  "Statement": {
    "NotStatement": {
      "Statement": {
        "IPSetReferenceStatement": {
          "ARN": "arn:aws:wafv2:us-east-1:123456789012:global/ipset/SpecificIpv6Set/f170302a-1aad-4593-b1d7-14d4o0sd6443"
        }
      }
    }
  },
  "Action": {
    "Block": {}
  },
  "VisibilityConfig": {
    "SampledRequestsEnabled": true,
    "CloudWatchMetricsEnabled": true,
    "MetricName": "AllowedIp"
  }
}

GeoMatch

名前の通り、GeoIP情報をもとに特定の国だけを通すようにする。
あんまり必要そうな場面は思いつかなけど一応

ルール
{
  "Name": "GeoMatch",
  "Priority": 3,
  "Statement": {
    "NotStatement": {
      "Statement": {
        "GeoMatchStatement": {
          "CountryCodes": [
            "JP"
          ]
        }
      }
    }
  },
  "Action": {
    "Block": {}
  },
  "VisibilityConfig": {
    "SampledRequestsEnabled": true,
    "CloudWatchMetricsEnabled": true,
    "MetricName": "GeoMatch"
  }
}

AWSManagedRulesCommonRuleSet

AWSが提供するマネージドルール
SizeRestrictions_BODYは前述のSizeRestrictionルールで16KB制限をかけているのでここではカウントにしている。
CrossSiteScripting_BODYは実際に使ってみて、このルールでブロックされたのでカウントにしている。
AWSManagedRulesCommonRuleSet

AWSManagedRulesAmazonIpReputationList

AWSが提供するマネージドルール
マネージドルールグループをそのまま適用している
このルールグループ使うとき、AWSManagedIPDDoSListがデフォルトでカウントになってていつも気になっている。

AWSManagedRulesKnownBadInputsRuleSet

AWSが提供するマネージドルール
マネージドルールグループをそのまま適用している

AWSManagedRulesAnonymousIpList

AWSが提供するマネージドルール
マネージドルールグループをそのまま適用している

AWSManagedRulesLinuxRuleSet

AWSが提供するマネージドルール
マネージドルールグループをそのまま適用している

AWSManagedRulesSQLiRuleSet

AWSが提供するマネージドルール
マネージドルールグループをそのまま適用している

XssLabelMatch

AWSManagedRulesCommonRuleSetでカウントにしているCrossSiteScripting_BODYをカバーするルール
AWS WAFが検出した際に付与されるラベルを用いて、該当ラベルを持つかつ特定のパス(/api/や/setup)でないときにブロックする。
SizeRestrictionの時のように合わせてできないは、マネージドルールの場合、複雑なルールを書くことができないためです。(ネストしたルールが書けない)
そのため、このようなラベルを使ったマッチやスコープダウンステートメントを合わせることで回避します。

ルール
{
  "Name": "XssLabelMatch",
  "Priority": 10,
  "Statement": {
    "AndStatement": {
      "Statements": [
        {
          "LabelMatchStatement": {
            "Scope": "LABEL",
            "Key": "awswaf:managed:aws:core-rule-set:CrossSiteScripting_Body"
          }
        },
        {
          "NotStatement": {
            "Statement": {
              "AndStatement": {
                "Statements": [
                  {
                    "OrStatement": {
                      "Statements": [
                        {
                          "ByteMatchStatement": {
                            "SearchString": "/api/",
                            "FieldToMatch": {
                              "UriPath": {}
                            },
                            "TextTransformations": [
                              {
                                "Priority": 0,
                                "Type": "NONE"
                              }
                            ],
                            "PositionalConstraint": "STARTS_WITH"
                          }
                        },
                        {
                          "ByteMatchStatement": {
                            "SearchString": "/setup",
                            "FieldToMatch": {
                              "UriPath": {}
                            },
                            "TextTransformations": [
                              {
                                "Priority": 0,
                                "Type": "NONE"
                              }
                            ],
                            "PositionalConstraint": "EXACTLY"
                          }
                        }
                      ]
                    }
                  },
                  {
                    "IPSetReferenceStatement": {
                      "ARN": "arn:aws:wafv2:us-east-1:0123456789012:global/ipset/AdminIpv6Set/f170302a-1aad-4593-b1d7-14d4o0sd6443"
                    }
                  }
                ]
              }
            }
          }
        }
      ]
    }
  },
  "Action": {
    "Block": {}
  },
  "VisibilityConfig": {
    "SampledRequestsEnabled": true,
    "CloudWatchMetricsEnabled": true,
    "MetricName": "XssLabelMatch"
  }
}

ALB

ECSのタスク(CTFd)をインターネットに公開している部分。
セキュリティグループでマネージドプレフィックスリストを使ってCloudFrontからのアクセスに制限している。

リスナー

HTTPS:443でのみ受け付けて、CTFdに転送しています。このような理由のためにALBにもctf-alb.example.comのようなドメインを割り当てます。
またHTTP:80へのリダイレクトなどはCloudFrontで行うようにしています。

ターゲット

ECSのタスク(CTFd)をターゲットにしている。この管理はECSが行っているため、CTFdコンテナが増減してもECSがターゲットの登録や削除を行う。

ヘルスチェック

ALBからCTFdに対して定期的にヘルスチェックを行っている。
CTFdには/healthcheckが用意されているので、そこに対してステータスコード200が返ってくることを確認している。200以外の場合、ALBは該当コンテナをターゲットグループから削除し、ECSは新しいコンテナをターゲットグループに登録する。
https://github.com/CTFd/CTFd/blob/master/CTFd/views.py#L551

ECS

ECSを知らない人向けに簡単に説明すると、コンテナのオーケストレーションを行うサービスです。よくKubernetes(AWSでのマネジメントサービスではEKS)と比較されるものと理解すればイメージしやすいと思います。

タスク

ECSにおけるタスクとは、1つ以上のコンテナをどう動かすかを定義するものです。
例えば、container imageはどこからpullするのか、CPUやメモリはどの程度割り当てるのか等です。2つ以上コンテナを1つのタスクとして定義することもできますが、今回は単純にCTFdのコンテナだけを動かすタスクを作成しています。

Fargate

ECSを使う上で、コンテナを動かす場所としてEC2とFargate(あとECS Anywhereも)から選ぶことになりますが、今回はFargateを選択しています。考えるのを楽にしたいからです。
安さを目指すのであれば、EC2のほうが余地はありますが、その場合ホストのスペックとコンテナのスペックを考える必要があるので、私は基本的にFargateを選択してしまいます。

Fargateを選択すると、ネットワークモードをawsvpcを選ぶことになります。これはコンテナのポートマッピングに影響する設定で、awsvpcの場合は単純にコンテナで使いたいポートを設定すればよいです。
CTFdの場合はEXPOSE 8000なので、8000番のポートを指定しています。
https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/bestpracticesguide/networking-networkmode.html

コンテナ

CTFdのイメージは手元にgithubをcloneして、buildしたものをリポジトリ(ECR)にpushして利用しています。
Docker Hub上のイメージをECRにpull through cacheするのもいいかもしれません。

CTFdは環境変数で設定することができます。(環境変数でしか指定できない設定や逆に環境変数では触れないものもあります。)
ECSでは直接値を与えてる方法(value)とSecrets ManagerやParameter Storeなどに格納した値を取得する方法(valueFrom)があります。
私はパスワードなどの機微情報はSecrets Managerで渡して、他の情報は直接渡すようにしています。
Secrets Managerを使っているのはCDKを使っている都合が大きいので、手動で使う場合にはParameter Storeを使ったと思います。

環境変数
環境変数の細かい説明はCTFdの項目でやります。

CPU/メモリー

ある程度高性能のものを一つ用意するよりは、小さいのをたくさん動かすほうがECSを使う上で向いていると思っているので、スペックは小さめです。とはいえもう少し大きくしてもいいかもしれません。(とりあえず1.0vCPUから始めちゃう気がします。)
実際にアクセスがあると要調整の部分かもしれませんが、ある程度はオートスケールでなんとかなるんじゃないかと思っています。

概要 設定値
CPU 0.5vCPU
memory 1GB
Arch ARM

ストレージ

永続ストレージは、S3やRDSがあるので他のサービスは利用していません。
CTFdはRedisを利用していることもあって、できるだけコンテナ自体はステートレスにしておきたいためです。

サービス

サービスはタスクをどう動かすかを定義しています。
例えば、タスクの必要数を3と決めれば、ECSはタスク数3を維持するように動きます。仮に一つのタスクが異常停止してしまっても、タスク数が3になるように新たにタスクを立ち上げます。
また、必要なタスク数をオートスケーリングさせることもできます。

オートスケーリング

以下のように設定しています。ちゃんと検証してるわけではないため不備はあるかもしれません。
異常時に1分程度の断を許容するならタスク数1でもいいと思います。
またある程度アクセス数があるなら最大タスク数は大きめにしておいたほうがいいです。

概要 設定値
必要なタスク数 2
最小タスク数 1
最大タスク数 5
ポリシー1 平均CPU使用率が3分間75%を超過する場合、平均CPU使用率が75%になるまでタスク数を増減させる。スケールインクールダウンは10分。
ポリシー2 平均メモリー使用率が3分間75%を超過する場合、平均メモリー使用率が75%になるまでタスク数を増減させる。スケールインクールダウンは10分。
ポリシー3 CTFdのWorker数*1000の70%になるようにタスク数を増減

オートスケーリングにはターゲット追跡するポリシーを利用しています。特定のメトリクスを目標値にして、サービスが実行するタスク数を増減させます。

ポリシー3はworker_connectionsデフォルト値が1000になっているため、環境変数で設定されたWorker数*1000が1つのターゲットが同時に処理できるリクエスト数になります。雑に70%を目標値に設定していますが、突発的なトラフィックの変化にはついていけないのでお気持ち程度かもしれません。
もし指定したいならこの辺りに追加すれば設定できると思います。

RDS

RDS Aurora Serverless v2を利用しています。
RDSにはざっくりエンジンの種類とインスタンスの種類が選択でき、今回は(MySQL互換の)AuroraエンジンのServerless v2インスタンスを使っています。
認証はクライアントであるCTFdがIAMを使ったアクセスはできないので、ユーザ・パスワードでの接続です。

Aurora MySQL

利用するエンジンはCTFdのドキュメントdocker-compose.ymlを参考にしています。
ドキュメントではMySQL、MariaDB、SQLiteでテストされ、MySQLが推奨とされています。
docker-sompose.ymlではimage: mariadb:10.4.12を利用しています。

Serverless v2

Serverlessといいつつ、インスタンスの数やACUをユーザ側で決める必要があります。
0.5ACUから32ACUの間でスケールするようにしていますが、まぁ大丈夫じゃないですかね。。
読み取り専用のReaderインスタンスが作成可能です。とはいえCTFdでは読み取りだけにアクセスするような機能は使えませんが、Writerインスタンスが故障した場合にReaderインスタンスが昇格することができ、可用性の向上に期待できます。

環境変数

CTFdに設定するRDSに関する環境変数

環境変数名 設定内容
DATABASE_USER RDSにアクセスするためのユーザ名
DATABASE_PASSWORD RDSにアクセスするためのパスワード
DATABASE_HOST RDS(writerインスタンス)のホスト名
DATABASE_PORT ポート番号

ElastiCache for Redis

2023年のre:inventで出たServerlessではなく、通常のサーバーがあるタイプのものを利用しています。Serverlessはどうも高そうなのであんまり向いてないかもしれないと思っています。

ユーザ・パスワードでの認証など、だいたいRDSでやっていることと同じようなことをしています。
docker-compose.ymlでは4系を使っていますが、私は7系のRedisを使っています。ドキュメント見ててもRedisが推奨以上の情報が読み取れなかったので利用できる高いのを使っています。
またARM系のインスタンスを使おうとすると5.0.6以上になるので、あんまりバージョンにはこだわらずに動けばいいや、で決めています。

cache.t4g.microでprimaryとreplicaの2ノードで構成しており、クラスターモードは無効にしています。
全体の中でここが一番貧弱ですが、それでも冗長構成だし、負荷もt4g.microで十分かと思っています。実際のトラフィックを見つつ、適宜調整で諦めです。

Redisにも一応パスワードでアクセスするようにした。CTFdのGithubにあるdocker-compose.ymlにはなかったのでつけてない人は多い気はします。
パスワードは特殊文字なしでSecrets Managerの自動生成し、ElastiCacheに設定しています。
CTFdには環境変数で渡しています。
なお、TLSを必須にする上で、REDIS_PROTOCOLredissを明示的に指定する必要があります。(これに気づかずに時間をつぶした)

環境変数

CTFdに設定するElastiCache for Redisに関する環境変数

環境変数名 設定内容
REDIS_PASSWORD ElastiCache for Redisにアクセスするためのパスワード
REDIS_PROTOCOL ElastiCache for Redisにアクセスするプロトコル(デフォルトだとredisになるため、redissにする)
REDIS_HOST ElastiCache for Redis(primary)のホスト名

S3

添付ファイルやロゴの画像ファイルなどはS3に保存されるよう設定しています。
アップロードはCTFdを通して行うので、WAFの特定パス(/api)においてSizeRestrictionルールの緩和が必要です。

有効にするためには、環境変数でS3バケット名やIAMアクセスキーをCTFdに渡す必要があります。S3バケットとIAMユーザを作成し、IAMユーザにS3にアクセスさせる権限を付与します。
なお、ECSのタスクロールに権限を渡す方法は対応していないので使えないようです。参考1 参考2

S3を利用している場合にファイルダウンロードが発生すると、CTFdはS3の署名付きURLを発行し、ユーザにリダイレクトさせることでファイルをダウンロードさせます。
署名付きURL

環境変数

CTFdに設定するS3に関する環境変数

環境変数名 設定内容
UPLOAD_PROVIDER ファイルアップロードの設定(s3を設定する)
AWS_S3_BUCKET S3バケット名
AWS_S3_REGION S3バケットがあるリージョン(ap-northeast-1など)
AWS_S3_CUSTOM_DOMAIN ctf.example.com
AWS_S3_CUSTOM_PREFIX S3オブジェクトのプレフィックス
AWS_ACCESS_KEY_ID IAMユーザのアクセスキー
AWS_SECRET_ACCESS_KEY IAMユーザのシークレットアクセスキー

AWS_S3_CUSTOM_DOMAINはAWS以外のS3互換サービスを利用する際に使えるように設定できるようになっている。

CloudFrontのオリジンとしてのS3

CloudFrontの項目でも触れていますが、オリジンをS3にするにはCTFdも併せて設定する必要があります。
オリジンにS3を使わない場合も含めて3種類のパターンを紹介します。オリジンに関連するAWS_S3_CUSTOM_DOMAINAWS_S3_CUSTOM_PREFIXのみ説明しますが、どのパターンに限らず他のS3の環境変数も必要になります。

そもそも設定するとめちゃくちゃいいことがあるかというと大量アクセスでもない限りそんなこともないので、好みでいいと思います。

オリジンにS3を設定しない(CTFdにS3を設定する場合の一般的な構成)

S3が発行する署名付きURLでアクセスされます。ただCloudFrontがあるだけでS3オブジェクトはCloudFrontを経由しません。

環境変数名 設定内容
AWS_S3_CUSTOM_DOMAIN 設定しない
AWS_S3_CUSTOM_PREFIX 設定しない

CF_S3

オリジンにS3を設定する(プラグインで対応)

CloudFrontにS3をオリジンに設定する場合、パスパターンに合致した場合にS3にアクセスがいくようにする必要があります。また、そのパスパターンは既存のCTFdのパス(/adminや/challengesなど)と重複しないようにしてください。
CTFdが応答するS3オブジェクトの署名付きURLはS3のものではなくCloudFrontのものにする必要があります。この部分はCTFの挙動を変える必要があるので、プラグインで対応します。またCloudFrontとCTFdに信頼されたキーグループを設定します。
これでS3へのアクセスがCloudFrontを経由するので、キャッシュが可能になります。

特にAWS_S3_CUSTOM_PREFIXで指定したパスをパスパターンに設定する必要があります。files/を設定すると署名付きURLへのリダイレクトがキャッシュされるので注意。

環境変数名 設定内容
AWS_S3_CUSTOM_DOMAIN ctfd.example.com
AWS_S3_CUSTOM_PREFIX S3オブジェクトのプレフィックス(図の場合はs3/

CF_S3_Origin

オリジンにS3を設定する(トリッキー版)

オリジンに設定するパスにfiles/を設定すると、(そのリクエストがCTFdに届かずに)S3が応答します。
S3へのアクセスがCloudFrontを経由するので、キャッシュが可能になります。
CTFdの署名付きURLが無効になるので、ログインしなくてもパスがわかればオブジェクトにアクセス可能になってしまうので注意。
もちろんCTFd的には想定していない挙動なので不具合や将来的に使えなくなったりするかもしれない。

環境変数名 設定内容
AWS_S3_CUSTOM_DOMAIN ctfd.example.com
AWS_S3_CUSTOM_PREFIX files/

CF_S3_Direct

SES

ユーザの初期登録やパスワードリセット用のメールをSMTPサーバを指定することで送信することができます。

ドメインの検証

まず送信元となる自身のドメインを検証します。
Route53のホストゾーンに登録したexample.comと同じドメインであれば、簡単にドメインの検証までが終わります。

設定セット

SESでメールを送る際に設定セット(ConfigurationSet)を指定することができます。
TLSの必須や仕様するIPアドレス帯などを設定セットに従ってメールを送ることができます。
これは必須ではなく、メールのバウンス率などをCloudWatchで取得しやすくするために使用しています。
設定セット

SMTPエンドポイント

一般的にAWSのAPIを利用する際にはHTTPSで行います。SESも同様にAPI経由でメールを送る機能も持っていますが、CTFdがAPIをたたけるわけではないのでSMTPプロトコルでメールを送るようにしています。
SMTPの認証情報はこのような手順で生成可能です。
私はIaCで発行する都合上、いったんIAMユーザとそのアクセスキーを取得してから変換して認証情報を作成しています。S3アクセス用のIAMロールとも別にIAMユーザを作成しました。
認証情報やSMTPのエンドポイントも環境変数で渡しています。

環境変数

CTFdに設定するSESに関する環境変数

環境変数名 設定内容
MAIL_SERVER email-smtp.{AWSリージョン}.amazonaws.com
MAIL_PORT 587
MAIL_USEAUTH true
MAIL_TLS true
MAIL_USERNAME IAMユーザのアクセスキー
MAIL_PASSWORD IAMユーザのシークレットアクセスキーを基に生成したSMTP用認証情報
MAILFROM_ADDR ctfd@example.com

VPC

アウトバウンド通信の制限

セキュリティグループをマネジメントコンソールから作ると、インバウンドは何も許可されていないかつ、アウトバウンドは全許可になっているものが作成される。
インターネット向け(VPC外向け)通信が必要ならともかく、そうでないならアウトバウンドの全許可をほとんど消すようにしている。
IaCで作るならセキュリティグループのアウトバウンドの制御もできるだろ、ぐらいの考え。

今回の場合、AWSのエンドポイント以外にアウトバウンドの通信が必要ではない。ECSやコンテナを使うメリットはこういう資材を別環境で作れるところにあると思う。
ちゃんとやるならDenyでの制限もしたほうがいいと思うけど、めんどくさくなりそうだったのでやっていない。

VPCエンドポイント

VPCエンドポイントによってVPC内にAWSのエンドポイントを置くことで、インターネットに出なくてもS3やSESなんかと通信ができるようになる。

コストカットを狙うなら真っ先に削ると思う。
今回はコストは過剰でなければかけていいぐらい気持ちなので、VPCエンドポイントたくさん使ってアウトバウンドの制御を優先した。
VPCエンドポイントを使わないのであれば、NAT Gatewayを使うか、CTFdのコンテナにパブリックIPを持たせる。

利用しているVPCエンドポイント一覧

エンドポイント 利用目的
S3 CTFdからS3にアクセス。ゲートウェイ型を利用
ECS ECSがコンテナを配置するために必要
ECR ECSがコンテナを配置するために必要
CloudWatch Logs コンテナからのログ用
SSM コンテナにECS Execするとき用
SSM Messages コンテナにECS Execするとき用
SecretsManager 環境変数の取得
SMTP メール用。このエンドポイントのみポート番号がHTTPSでない

CloudWatch

コンテナのCPUメトリクスやメールのバウンス率などを確認するダッシュボードや、閾値超過時にアラート通知などを行っている。

ダッシュボード

なんとなくそれっぽいメトリクスを並べています。
実運用ではここを見るより、適切にアラームにするのがいいかと思います。
また、ECSやALBなどはCloudWatchが自動作成するダッシュボードなどもあるので、そちらを使ってもいいかもしれないです。
メトリクス以外にもログ中にあるINFOCRITICALを拾って通知しています。Pythonのloggingででるだろうと思って設定していますが、ちゃんと確認していないです。

ダッシュボード1
ダッシュボード2

アラーム

閾値を超える場合に、SNS経由で通知しています。
SNSのTopicを4種類(Cretical,Warn,Notice,Info)を用意しておいて、アラームのアクションで宛先を選んで通知しています。
TopicのサブスクリプションはSlackやメールを宛先にする想定です。

ECSのタスク数が最大値になった場合などに通知が飛ぶようにしてます。

アラーム

CTFd

上記で説明しているとおり、設定のほとんどを環境変数で与えています。
ドキュメントは以下ですがあまりメンテナンスされていないので、github上のconfig.iniconfig.pyも併せて確認するといいと思います。
例えば、データベースに関連する環境変数は、ドキュメント上だとDATABASE_URLしか書いてないですが、config.iniではDATABASE_HOSTDATABASE_PASSWORDなどが用意されていることがわかります。
https://docs.ctfd.io/docs/deployment/configuration#server-level-configuration

環境変数

AWSに関連しない、CTFd自体の設定を行う環境変数です。
ほとんどがgunicornの設定です。

環境変数名 設定内容
SECRET_KEY セッション作成などに用いられます。WORKERSやECSタスクを複数に持つときには必須で固定値を持つ必要があります。
WORKERS gunicornのworker数です。何も指定しないと1になります。
ACCESS_LOG -を指定すると標準出力にログを出力します。参考
ERROR_LOG -を指定すると標準出力にログを出力します。参考
REVERSE_PROXY true以外にも,区切りの文字列も受け取る

SECRET_KEY

SECRET_KEYは何も設定しない場合でもローカルで生成してくれるようです。もちろんほかのコンテナと共有することはないので、なんでもいいので設定しておいたほうがいいと思います。

WORKERS

workersの適正値は僕もわからないのですが、参考リンクを置いておきます。
(2 x $num_cores) + 1と記載されています。
CPU使用率見て決めちゃっていい気がします。
https://docs.gunicorn.org/en/stable/design.html#how-many-workers

REVERSE_PROXY

ALBもあるので(安易に)trueでいいか。。。と思っていたら、REVERSE_PROXYには,区切りで複数の値でも受け取るらしい。
このあたりREVERSE_PROXYに対する扱いを確認できる。

もしALBの背後にNginxがあり、それぞれX-Forwarded-Forを付与する場合には、x-forが2になるようにする必要がある。(と理解した)
具体的な値は以下がわかりやすい。
https://github.com/CTFd/CTFd/blob/master/tests/test_config.py#L8-L45

他の構成

いくつかパターンはあると思うけど、動作確認していないので実は動かないなどの罠はあるかもしれない。

簡易な構成

簡易にするならこんな感じ

  • CloudFrontとWAFは設定がややこしくなるので削る
  • インターフェス型のVPCエンドポイントをやめてNAT Gatewayにする。もし通信料が高いものがあればそれは別途VPCエンドポイントを考える。
  • S3用のゲートウェイ型VPCエンドポイントは課金されないのでそのまま利用する。

簡易な構成

(そこそこ)安い構成

安いといっても最安ではなく、それなりにリソースを使っている。
EC2かLightsailで1台だけ立ててdocker composeするほうがさらに安い

  • ECSおよびタスクにパブリックIPを付与して、NAT Gatewayも削除する
  • FargateをやめてEC2の上にコンテナを動かす
  • ElastCacheもやめてRedisをタスクで動かす(落ちてもセッションが落ちるだけと割り切る。永続ボリュームも持たない)
  • RDSも適当に安いインスタンス2台ぐらいで立てたほうが見積もりはしやすい
  • SpotFleetでspot instanceの割合を増やすなど検討する

安い構成

App Runnerを使う構成

利用状況次第では現実的に一番安くなるかもしれないと思っている。
起動ぐらいしかしたことないので実態の構成図とは異なったり、そもそも何か不都合があるかもしれない。
(なんで使ってないかというとECSのほうが設定が慣れてるから。)
Route53が図から消えてるけどドメインの設定もできる。

  • 課金体型がEC2などと異なる部分があるため、より使った分だけ課金されるイメージに近い
  • ECSやALBが担っていた部分はApp Runnerが行う

App Runnerを使う構成

Lambdaを使う構成(実験的)

Lambda Web Adapterを使ってCTFdをLambdaで動かす構成。
App Runnerと違って動くかどうか不安だったけど、動作確認までできたので紹介。

  • ログイン、問題作成、フラグ解答までは実行することを確認した。
  • リロードすれば治るけども、たまに描画が崩れたりする。
  • Lambdaの関数URLを使っているので、CloudFrontは必須ではない。CloudFrontを使うなら関数URLに直接アクセスさせないようにする必要がありそうだけども調べていない。
  • めんどうだったのでNAT Gatewayを使っている

Lambdaを使う構成

おわりに

AWSでCTFdを立ててみました。割といろいろなAWSサービスを使った気がします。たくさん使ったほうがいいというわけではないですが。
いまいち誰向けなのかよくわからない(AWSを知っている人にはそりゃそうだろ、という内容だし、CTFを開催したい人にはたいていはdocker composeで用が足りると思う)記事になってしまった気がしますが、自己満足なので良しとします。

ここまで大げさな構成にする必要はあるのか?みたいなことを思った人もいるかもしれないけども、個々のパーツで見ればそんなに大変なことはしてないのと、個人的にはIaCのファイルが大きくなることは良くて、それ以外の手順を省きたいという感じ。
(デプロイコマンド一発で済んでいるうちはまぁいいかなぐらいの感覚。どうせ立てては捨てるを繰り返すようなものだし。)

正直、CTFに特段強い思い入れがあるわけではないのだけど、僕でも解答できるCTFがたくさん開催されると嬉しい。

Discussion