AWS Lambda + AWS WAF でDOS攻撃からの ✝守護者✝ を実装する
はじめに
DOS攻撃からいかにシステムを守るか。オートスケールの仕組みが充実し、大量のアクセスでも耐えられるようになってきている昨今ですが、悪意ある急激なアクセス増加は誰も望まないはずです。防御する仕組みが欲しいところではありますが、開発の時間が限られている中で、防護機構の自動化まで持っていくのは骨が折れます。そこで今回は、AWSを使ったシステムを構築するシーンで、DOS攻撃から自動でWAFを設定する仕組みを作ります。重視した点は以下2点です。
- 安価であること。可能な限り新しいAWSリソースを使わない、使うとしても高価なものは避ける。
- ポータビリティが高いこと。別の環境に同様の手順で導入できること。
DOS攻撃防御の仕組み比較
Apache の mod_dosdetector
アプリケーションサーバ群の前段にApacheサーバを置き、そこにmod_dosdetectorを導入することを考えます。
長所
- ミドルウェアのモジュールとして実装されており、導入実績がある
- 設定ファイルさえ記載すればすぐに導入できる
考慮すべきこと
- Apacheサーバに負荷が集中し、AWSの Auto Scaling の思想と相性が悪い
- しきい値の修正には設定ファイルを修正したあとApacheのgracefulが必要となり、運用ハードルがやや高い
- Apacheに到達するまでは攻撃によりリソースを消費することになる。ネットワーク帯域の飽和などには注意が必要
Amazon KinesisとAWS WAFを利用する方法
弊社鈴木が作成した手法です。
長所
- Amazon Kinesis Streams を利用することによる圧倒的リアルタイム性
- Amazon Kinesis Firehose, Analytics, Streams の知見がたまる
- AWS WAFの自動設定だけでなく、分析結果から様々なアクションにつなげることができる
考慮すべきこと
- コスト面
- Amazon Kinesis Firehose, Analytics, Streams の知見が必要になる
本記事で実装する仕組み
長所
- 利用するAWSリソースは Amazon S3, AWS Lambda, AWS WAF だけであり、コスト面に優れる
- アクセスログがS3に出力できさえできれば、環境を問わない
考慮すべきこと
- ブロックするまでラグがある。具体的な程度は、fluentd の転送タイミングの設定による
- 途中、分析工程を挟まないので、S3に出力したログはDOS攻撃ブロック以外の用途として期待できない
ApacheをはじめとしたHTTPサーバの機能を用いるのでなく、 AWS WAF を使うメリットは、EC2インスタンス到達さえ許さず、CloudFrontの段階でアクセスを遮断できる点です。
構成
- CloudFront と AWS WAF を導入します
- アプリケーションサーバにnginxとfluentdを導入し、アクセスログ(X-Forwarded-Forを含む)がS3バケットに連携されるようにします
- Lambda Functionにおいて、 --> S3バケットへのアクセスログPUTを契機として Lambda Function が起動するようにします --> アクセスログ中の特定IPの数がしきい値を超えていた場合、AWS WAF のAPIをコールしてブラックリストIPを追加します --> IPをブロックしたことをSlackなどで担当者に通知します
順番に詳しく見ていきましょう。
CloudFront と AWS WAF を導入する
これらがないと始まりません。早速導入しましょう。
CloudFrontを作成する
特に難しいことはないと思います。Route53などを使ってもともとALB/ELBへアクセスを振り分けていた場合、CLoudFrontへ向くように修正してください。
AWS WAF を作成する
Step1: Name web ACL
web ACL を作成します。 AWS resource to associate
で先に作成した CloudFront と関連付けます。
Step2: Create conditions
Create conditions で IP match conditions を選択します。
IPアドレスは今は空でOKです。
Step3: Create rules, Step4: Create
次にRuleを作ります。
これで完成です。この時点で仕組みはもう半分完成しています。
作成されたブラックリストとそのID
いま作成した IP adresses に対してIPアドレスを追加すると、ブラックリスト判定され、CloudFrontにてアクセスがブロックされます。要はこの作業を手ではなく Lambda Funtion にやらせてやろう、というのがこれから先で述べる内容です。
アプリケーションサーバにnginxとfluentdを導入する
それでは Lambda Function を作っていきます。まずはINPUTとなる素材を用意してやる必要があります。アクセスログです。nginx+fluentdでいきます。nginxはApacheでも構いません(実際どちらでも動作を確認できました)。とにかくここでやりたいことは、
- HTTPサーバの機能を使って X-Forwarded-For を含むアクセスログを出力する
- fluentdを使って、アクセス元のIPが記録されたログをS3に転送する
これら2点です。ここで、動機を説明しなければならないことがふたつ。
アクセスログを出力する手段はアプリケーションフレームワークにまかせてもよいのでは?
その通りです。ただ、私は「ポータビリティ」を重視しました。フレームワークの機能を使う場合、導入その都度アクセスログの出力について調べなければなりません。アプリケーションとして利用する言語やフレームワークが変わったとしても、「HTTPサーバによるアクセスログ出力」できるレイヤがあることで、違いを吸収できるというメリットがあります。
なぜ X-Forwarded-For が必要なの? ALB/ELB のアクセスログでも良いのでは?
ALB/ELBのアクセスログは IP address の出力にしか対応しておらず、この値は、ALB/ELBの直前に居る CloudFrontのものになってしまうからです。CloudFront のアクセスを遮断してしまっても、意味がありません。よって、本当のアクセス元を知るために、EC2インスタンスのHTTPサーバで X-Forwarded-For が必要になります。
具体的な設定内容を見ていきます。
nginx
アプリケーションログを出力するための最小限の設定でOKです。アプリケーションサーバに PlayFramework を利用している環境では、以下のように設定しました。他のフレームワークを利用している場合でも、ポート番号を調整するのみで設定可能かと思います。
http { include /etc/nginx/mime.types; default_type application/octet-stream; log_format main '"$http_x_forwarded_for" - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent"'; access_log /var/log/nginx/access.log main; sendfile on; keepalive_timeout 65; upstream play { server 127.0.0.1:9000; } include /etc/nginx/conf.d/play.conf; }
log_format
で http_x_forwarded_for
を出力している点に注目してください。これがnginxの大事な仕事です。
出力されるログは以下のようになります。
"xxx.xxx.xxx.xxx, 54.239.196.134" - - [13/Jan/2017:08:21:19 +0000] "GET /healthcheck HTTP/1.1" 200 33 "-" "Go-http-client/1.1"
fluentd
先でnginxが出力したログをS3バケットに送信します。
<source> type tail path "/var/log/nginx/access.log" pos_file "/var/log/td-agent/nginx.log.pos" tag log.webapi.access format /^"(?<x-forwarded-for>[^\"]*)" [^ ]* (?<user>[^ ]*) \[(?<time>[^\]]*)\] "(?<method>\S+)(?: +(?<path>[^ ]*) +\S*)?" (?<code>[^ ]*) (?<size>[^ ]*)(?: "(?<referer>[^\"]*)" "(?<agent>[^\"]*)")?$/ time_format %d/%b/%Y:%H:%M:%S %z </source> <filter log.webapi.access> type record_transformer enable_ruby true renew_record true <record> host ${record["x-forwarded-for"].split(',')[0]} </record> </filter> <match log.webapi.access> type s3 s3_bucket "webapi-access-log" path "webapi/access/" s3_object_key_format %{path}%{time_slice}/access_%{index}.%{file_extension} buffer_path /var/log/td-agent/s3/webapi_access time_slice_format %Y/%m/%d/%H time_slice_wait 1m utc buffer_chunk_limit 512k format json include_time_key true time_key log_time </match>
- source: アクセスログをパースしています。
- filter: 今回に関しては、ここでアクセスログをスリム化し、IPブロックのための必要最小限の情報に切り捨てています。S3の利用領域節約と、AWS Lambda で利用するメモリが多くなりすぎないようにする配慮です。
- match: S3に転送しています。
time_slice_format
とbuffer_chunk_limit
を見てください。この設定で、「一時間経過したらS3バケットにパースしたログを転送する。もし、ログのサイズが512KBに達した場合、一時間を待たずに転送する」という動きになります。攻撃を受けている場合はログサイズが大きくなり後者が適用されるでしょうから、 AWS Lambda としては512KB中に含まれているログから攻撃とみなすIPアドレスを選別することになります。
上述設定内容によって、S3に連携されるログは以下のような形になります。AWS Lambda はこのファイルをパースし、IPアドレスを集計します。
{"host":"x.xx.105.49","log_time":"2017-01-16T00:07:40Z"} {"host":"xxx.xxx.215.137","log_time":"2017-01-16T00:07:42Z"} {"host":"x.xx.105.49","log_time":"2017-01-16T00:07:49Z"} {"host":"x.xx.105.49","log_time":"2017-01-16T00:07:50Z"} {"host":"xxx.xx.40.68","log_time":"2017-01-16T00:08:07Z"}
AWS Lambda
Lambda Function をデプロイする
コードは GitHub にコミットしました。
apexを用いてデプロイします。デプロイ権限のあるロールに切り替えた後、プロジェクトルートで以下のコマンドを実行します。
apex deploy attack-guardian-web --env stg
あっという間に Lambda Function のできあがりです。以後修正が入った場合も同じコマンドで上書きがかけられます。
コードの解説は長くなるので割愛しますが、やっている仕事は以下です:
- apexの機能を用いてjsonファイルから環境設定を読み出し、 AWS Lambda の環境変数にセットする
- S3にアクセスログがPUTされたら、 Lambda Function としては、まずセットされた環境変数を読み出す
- アクセスログの内容を読み込み、IPアドレス出現数で集計する
- 環境変数にセットされたしきい値を基準にブラックリスト判定するかどうか決める
- ブラックリスト判定対象のIPアドレスがあった場合、
* AWS WAF のAPIを呼び出してブラックリストにIPアドレスを追加する(この時点で防御完了) * SNSトピックARNに対してメッセージをPublishし、Subscriberにブロックしたことを通知する * Slackのincomming webhook urlにリクエストを送り、ブロックしたことを通知する
S3バケットのPUTをトリガーに起動する
以下のように設定します。
ためす
ベジータを使って同一のアクセス元から攻撃をかけてみます。
vegeta attack -rate=50 -duration=300s -targets=target.txt
通知が飛んできました!
↓Slack通知
↓メール通知
ブラウザからアクセスしてみると、確かにCloudFrontのところでブロックされているようです。
運用においては
調整ポイントは以下2点です:
- fluentdのログ転送タイミング
- 攻撃とみなすIPアドレス出現回数
td-agent.conf.j2
設定箇所でも言及しましたが、time_slice_format
と time_slice_format
と buffer_chunk_limit
、そして Lambda Function の環境変数 BLACK_LIST_THRESHOLD_COUNT
が調整ポイントです。これらは、実際のリクエスト数から調整するのが良いです。 buffer_chunk_limit
が小さいほど小刻みにS3へ転送され Lambda Function が起動しますが、ブラックリスト判定のしきい値によっては誤って正常なIPアドレスを設定してしまう可能性もあります。実際に検証環境で負荷ツールを使い、「攻撃」があった場合をシミュレーション、その結果から設定するのが確実です。以下は設定例です。おおまかな目安としてください。
項目 | 値 |
---|---|
普段のリクエスト数 | 10 - 20 req/min |
time_slice_format | 一時間単位 |
buffer_chunk_limit | 128KB |
ブラックリスト判定しきい値 | 500 |
おわりに
導入を検討する場合、以下の点にご注意ください。
- AWS WAF は CloudFront に対して適用されるものであるため、CloudFront の導入が必須です。
- 残念ながら ALB/ELB のアクセスログは利用できません。IP Address の出力しか対応しておらず、この値は必ず CloudFront の値になってしまうためです。アクセス元のIPアドレスを出力するために、アプリケーションレイヤのログ出力機能を使って X-Forwarded-For を出力する必要があります。
- CloudFrontのログをアクセスログとして利用することでもおそらく同等のことが実現できます(未検証)。本稿で利用していない理由は、CloudFrontのログ出力タイミングが不定――1時間に最大で数回――である点と、ELB/ALBだけでなくCDNやキャッシュへのアクセスまでカウントすることになってしまい、攻撃を判定する材料としてややミスマッチかと考えたためです。逆にこれらの点が許容できるのであればCloudFrontのログも活用できると思います。
条件を満たせば、安価で手軽にDOSからの防護機構を実装することができます。エンジニアとしてはエコシステムへ感謝するとともに、攻撃に怯えず夜はぐっすり眠ってよりよい開発ライフを送りましょう!
参考
- How to Configure Rate-Based Blacklisting with AWS WAF and AWS Lambda | AWS Security Blog
- アクセスログ - Amazon CloudFront
- apex/apex: Build, deploy, and manage AWS Lambda functions with ease (with Go support!).