ちょっと話題の記事

AWS Lambda + AWS WAF でDOS攻撃からの ✝守護者✝ を実装する

2017.01.16

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

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の段階でアクセスを遮断できる点です。

構成

構成図

  1. CloudFront と AWS WAF を導入します
  2. アプリケーションサーバにnginxとfluentdを導入し、アクセスログ(X-Forwarded-Forを含む)がS3バケットに連携されるようにします
  3. 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 と関連付けます。

01_webACL

Step2: Create conditions

Create conditions で IP match conditions を選択します。

02_webACL

03_limitaccess

IPアドレスは今は空でOKです。

Step3: Create rules, Step4: Create

次にRuleを作ります。

04_rule

05_rule2

これで完成です。この時点で仕組みはもう半分完成しています。

作成されたブラックリストとそのID

06_ip-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 を利用している環境では、以下のように設定しました。他のフレームワークを利用している場合でも、ポート番号を調整するのみで設定可能かと思います。

nginx.conf

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_formathttp_x_forwarded_for を出力している点に注目してください。これがnginxの大事な仕事です。

出力されるログは以下のようになります。

/var/log/nginx/access.log

"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バケットに送信します。

td-agent.conf.j2

<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_formatbuffer_chunk_limit を見てください。この設定で、「一時間経過したらS3バケットにパースしたログを転送する。もし、ログのサイズが512KBに達した場合、一時間を待たずに転送する」という動きになります。攻撃を受けている場合はログサイズが大きくなり後者が適用されるでしょうから、 AWS Lambda としては512KB中に含まれているログから攻撃とみなすIPアドレスを選別することになります。

上述設定内容によって、S3に連携されるログは以下のような形になります。AWS Lambda はこのファイルをパースし、IPアドレスを集計します。

access_0.log

{"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 のできあがりです。以後修正が入った場合も同じコマンドで上書きがかけられます。

20_lambda

コードの解説は長くなるので割愛しますが、やっている仕事は以下です:

  1. apexの機能を用いてjsonファイルから環境設定を読み出し、 AWS Lambda の環境変数にセットする
  2. S3にアクセスログがPUTされたら、 Lambda Function としては、まずセットされた環境変数を読み出す
  3. アクセスログの内容を読み込み、IPアドレス出現数で集計する
  4. 環境変数にセットされたしきい値を基準にブラックリスト判定するかどうか決める
  5. ブラックリスト判定対象のIPアドレスがあった場合、

* AWS WAF のAPIを呼び出してブラックリストにIPアドレスを追加する(この時点で防御完了) * SNSトピックARNに対してメッセージをPublishし、Subscriberにブロックしたことを通知する * Slackのincomming webhook urlにリクエストを送り、ブロックしたことを通知する

S3バケットのPUTをトリガーに起動する

以下のように設定します。

10

ためす

ベジータを使って同一のアクセス元から攻撃をかけてみます。

vegeta attack -rate=50 -duration=300s -targets=target.txt

通知が飛んできました!

↓Slack通知 30_slack

↓メール通知 40_gmail

ブラウザからアクセスしてみると、確かにCloudFrontのところでブロックされているようです。 50_blocked

運用においては

調整ポイントは以下2点です:

  • fluentdのログ転送タイミング
  • 攻撃とみなすIPアドレス出現回数

td-agent.conf.j2 設定箇所でも言及しましたが、time_slice_formattime_slice_formatbuffer_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からの防護機構を実装することができます。エンジニアとしてはエコシステムへ感謝するとともに、攻撃に怯えず夜はぐっすり眠ってよりよい開発ライフを送りましょう!

本稿は望月政夫かんあきひでの協力のもと実現しています。

参考

コード