注目の記事

Norikra+FluentdでDoS攻撃をブロックする仕組みを作ってみた

2014.04.01

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

Norikraとは

Norikraとはリアルタイム集計プロダクトです。イベントストリームに対してSQLライクな言語で処理を書くことが出来ます。

例えば、ApacheのアクセスログをNorikraに流し込み、1分あたりのアクセス数やレスポンスタイムの最大値をリアルタイムに集計することが出来ます。

Norikraの利用例は作者であるtagomorisさんのブログで紹介があります。

今回は、Norikraを使ってDoS攻撃をブロックする仕組みを作ってみました。

DoS攻撃ブロックの仕組み

アクセス元はApacheのアクセスログから取得し、ログの受け渡しにはFluentdを利用しました。
ブロックの手順は以下のようになります。

  1. アクセスログをFluentdのin_tailプラグインで取得。
  2. Fluentdのout_norikraプラグインで、アクセスログをNorikraに流し込み。
  3. Norikraのクエリで、単位時間あたりのアクセス数が多いIPアドレスを抽出。
    結果をin_norikraプラグインでFluentdに出力。
  4. IPアドレスのリストをout_exec_filterプラグインで、独自スクリプトに渡す。
    独自スクリプト内の処理で、AWSのNetwork ACLの設定を変更し、問題のIPアドレスからのinboundを拒否する。
    ブロック結果はそのまま、Fluentdに戻される。
  5. out_snsプラグインで、ブロック結果をSNSのトピックに出力。
    トピックのサブスクライバであるメールアドレス等に通知が飛ぶ。

Norikra

上図の③にあるSQL風のクエリに注目してください。
重要なのはFROM句にある「m分ごと」の部分で、ここで指定した期間のログに対してクエリを実行することが出来ます。
これを使って、例えば「1分間で1000回以上アクセスのあるIPアドレス」といった抽出が可能になります。

では、実際に構築していきます。

前提

VPC内のEC2上に構築していきます。
AWSリソースへのアクセスが必要なので、IAM Role付きで立ち上げてください。

AMI Amazon Linux AMI 2014.03
セキュリティグループ 22/TCP, 80/TCP, 26578/TCPを許可
IAM Role Power User Access

作業は基本的にec2-userで行います。

Norikraのインストール

NorikraはJRubyで動いているので、まずJrubyをインストールします。 *1

まず、rbenvをインストールし、

$ sudo yum install -y git gcc-c++
$ git clone https://github.com/sstephenson/rbenv.git ~/.rbenv
$ echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bash_profile
$ echo 'eval "$(rbenv init -)"' >> ~/.bash_profile
$ exec $SHELL -l

$ rbenv --version
rbenv 0.4.0-95-gf71e227

バージョンが確認できたら、JRubyをインストールします。

$ git clone https://github.com/sstephenson/ruby-build.git ~/.rbenv/plugins/ruby-build
$ rbenv install -l|grep jruby
  jruby-1.5.6
    (snip)
  jruby-1.7.9
  jruby-9000-dev
  jruby-9000+graal-dev

$ rbenv install  jruby-1.7.9
$ rbenv shell jruby-1.7.9
$ ruby -v
jruby 1.7.9 (1.9.3p392) 2013-12-06 87b108a on OpenJDK 64-Bit Server VM 1.7.0_51-mockbuild_2014_03_13_04_35-b00 [linux-amd64]

現時点の最新版、1.7.9をインストールしました。
普段使うRubyがJRubyになってしまうと辛いので、rbenv shellでログインシェルのみJRubyを利用するようにしています。

GemでNorikraをインストールします。

$ gem install norikra --no-ri --no-rdoc
$ rbenv rehash
$ which norikra
~/.rbenv/shims/norikra
$ gem list --local | grep norikra
norikra (0.1.5 java)
norikra-client-jruby (0.1.5 java)

Norikraがインストールできました。

Norikraの起動と動作確認

起動する前に、ログや設定ファイルを設置するディレクトリを作成します。

$ sudo mkdir /{etc,var/log,var/run}/norikra
$ sudo chown ec2-user:ec2-user /{etc,var/log,var/run}/norikra/

起動時に設定できるパラメータはいろいろあるのですが、とりあえず最低限のパラメータで起動してみましょう。

$ norikra start --stats=/etc/norikra/norikra.json -l /var/log/norikra --daemonize

パラメータの詳しい説明は公式ドキュメントを参照してください。

起動が確認できたら、NorikraのウェブUIにアクセスしてみましょう。
http://hostname:26578/
でアクセスできます。

スクリーンショット 2014-03-31 14.23.10

Norikraが利用するメモリや、現在受付中のターゲット、設定済みのクエリ、処理されたイベント数が確認できます。
クエリの設定もこのUIから出来ます。

ウェブUIへのアクセスが確認できたところで、早速ターゲットを作ってみましょう。
ターゲットとはRDBで言うところのテーブルのようなものです。

$ norikra-client target open test

これで、「test」という名前のターゲットが作られました。
ウェブUIで確認してみましょう。

スクリーンショット 2014-03-31 14.24.07

「Fields」項目が「lazy target」となっています。フィールドとはRDBで言うところのカラムのようなものです。ターゲット作成時にフィールドタイプを指定することも出来ますが、指定しなくても、イベント登録時に自動で判別してくれます。

では、testターゲットにイベントを流してみましょう。

$ echo '{"name":"spike", "age":27}' | norikra-client event send test

ウェブUIで確認すると、フィールドタイプが確定したことが確認できます。

スクリーンショット 2014-03-31 14.38.58

ただ、クエリを登録していないため、いま流したイベントは処理されることなく流れてしまってます。

ウェブUIからクエリを登録してみましょう。
最初なので、全イベントを取得するクエリを「get_all」という名前で登録してみます。

スクリーンショット 2014-03-31 14.39.41

「SELECT フィールド名 FROM ターゲット名」と、SQLと同じようにかけます。
フィールド名にはワイルドカードも使えます。

クエリを登録した状態で、イベントを流してみましょう。

$ echo '{"name":"jet", "age":36}' | norikra-client event send test
$ echo '{"name":"faye", "age":77}' | norikra-client event send test

ウェブUIで確認すると、今度はイベントがget_allクエリで処理されたことが確認できます。

スクリーンショット 2014-03-31 14.44.34

最初に流したイベントが出力されていないことに注意してください。
Norikraはデータベースではないため、受け取ったイベントを保存することはありません。
クエリに当てはまるイベントが処理され、その処理結果が出力されます。

Norikraクライアントで結果を取得しましょう。

$ norikra-client event fetch get_all
{"time":"2014/03/31 05:40:58","name":"jet","age":36}
{"time":"2014/03/31 05:44:09","name":"faye","age":77}

Norikraクライアントの使い方は公式ドキュメントを参照してください。

続いて、FluentdからNorikraにイベントを流すようにしましょう。

Fluentdのインストールと設定

Fluentdの前に、ログを出力するApacheをインストール、設定しておきます。

$ sudo yum install -y httpd24

$ echo ok | sudo tee /var/www/html/index.html
$ sudo service httpd start
$ sudo chkconfig httpd on
$ curl localhost
ok

Fluentdと各種プラグインをインストールします。

$ curl -L http://toolbelt.treasuredata.com/sh/install-redhat.sh | sh
$ sudo /usr/lib64/fluent/ruby/bin/fluent-gem install fluent-plugin-norikra --no-ri --no-rdoc

Fluentdの設定ファイルを編集し、ApacheのアクセスログをNorikraに流し込むようにします。
最初に説明した手順①、②の部分の設定です。

$ sudo chmod a+rx /var/log/httpd
$ sudo mkdir /etc/td-agent/pos
$ sudo chown td-agent:td-agent /etc/td-agent/pos
$ cat /etc/td-agent/td-agent.conf
<source>
  type tail
  format /^(?<host>[^ ]*) [^ ]* (?<user>[^ ]*) \[(?<time>[^\]]*)\] "(?<method>\S+)(?: +(?<path>[^ ]*) +\S*)?" (?<code>[^ ]*) (?<size>[^ ]*)(?: "(?<referer>[^\"]*)" "(?<agent>[^\"]*)")?$/
  time_format %d/%b/%Y:%H:%M:%S %z
  path /var/log/httpd/access_log
  tag apache.access
  pos_file /etc/td-agent/pos/apache.access_log
</source>

<match apache.access>
  type    norikra
  norikra localhost:26571
  buffer_queue_limit 1
  retry_limit 0
  remove_tag_prefix apache
  target_map_tag    true
</match>

最初に、Fluentdのin_tailプラグイン用にディレクトリの作成や権限変更を行っています。
Fluentd設定の後半で、Norikraへのログの流し込みを設定しています。Fluetndのタグ名がNorikraのターゲット名になるようにしつつ、タグプレフィックス「apache」を削除するようにしているので、「access」というターゲットがNorikraに作られるようになるはずです。
これで、アクセスログをNorikraに流し込む準備ができたので、100回くらいアクセスして動作を見てみましょう。

$ ab -c 10 -n 100 http://`curl ifconfig.me`/index.html

ウェブUIを見ると、新たに「access」というターゲットが出来ていることが確認できます。

スクリーンショット 2014-04-01 12.02.41

IPアドレス毎の1分間のアクセス数を取得するクエリを追加してみます。

スクリーンショット 2014-04-01 12.07.50

SELECT
    host,
    COUNT(*)
  FROM access.win:time_batch(1 min)
  GROUP BY host

というクエリを登録しています。

ほぼSQLの集約クエリと同じですが、ターゲット名「access」に続くデータウィンドウ「.win:time_batch(1 min)」の部分で、1分毎の集計を行うように指定しています。

データウィンドウの一覧はEsperのドキュメントで確認できます。EsperとはNorikraが利用しているイベント処理エンジンです。

クエリの登録がすんだら、何回かアクセスして、結果を見てみましょう。

$ date
Tue Apr  1 05:33:24 UTC 2014
$ ab -c 10 -n 100 http://`curl ifconfig.me`/index.html

$ date
Tue Apr  1 05:34:44 UTC 2014
$ ab -c 10 -n 200 http://`curl ifconfig.me`/index.html

最初に100アクセス、1分後に200アクセスしてみました。出力を確認するとと、

$ norikra-client event fetch access_count_per_1min
{"time":"2014/04/01 05:34:01","host":"54.199.254.150","count(*)":100}
{"time":"2014/04/01 05:35:01","host":"54.199.254.150","count(*)":200}
{"time":"2014/04/01 05:36:01","host":"54.199.254.150","count(*)":0}

期待通りの結果が得られました。

DoS攻撃検知用クエリの設定

それでは、手順③、NorikraでDoS攻撃を検知する設定に進みます。

今回は「同じIPアドレスから1分間に1,000リクエストあった場合」攻撃と見なすようしました。

Norikraのクエリは次のようになります。

block.access_over_1000_per_1min

SELECT
    host,
    COUNT(*) as requests
  FROM access.win:time_batch(1 min)
  GROUP BY host
  HAVING COUNT(*) >= 1000

スクリーンショット 2014-04-01 19.14.17

最初に500アクセス、少し時間をおいて1000アクセスしてみます。

$ date
Tue Apr  1 05:59:16 UTC 2014
$ ab -c 100 -n 500 http://`curl ifconfig.me`/index.html

$ date
Tue Apr  1 06:01:22 UTC 2014
$ ab -c 100 -n 1000 http://`curl ifconfig.me`/index.html

結果を取得してみましょう。

$ norikra-client event fetch block.access_over_1000_per_1min
{"time":"2014/04/01 06:02:19","requests":1000,"host":"54.199.254.150"}

ちゃんと1000アクセスした場合のみ取得できています。念のため、アクセス数で絞り込みしてないクエリの結果も見てみましょう。

$ norikra-client event fetch access_count_per_1min
{"time":"2014/04/01 06:00:01","host":"54.199.254.150","count(*)":500}
{"time":"2014/04/01 06:01:01","host":"54.199.254.150","count(*)":0}
{"time":"2014/04/01 06:02:01","host":"::1","count(*)":1}
{"time":"2014/04/01 06:02:01","host":"54.199.254.150","count(*)":1000}
{"time":"2014/04/01 06:03:01","host":"::1","count(*)":0}
{"time":"2014/04/01 06:03:01","host":"54.199.254.150","count(*)":0}

こちらでは500リクエストの場合も取得できています。期待通りです。

DoS攻撃ブロック設定

DoS攻撃を検知できるようになったので、次に検知されたIPアドレスをブロックする仕組みを作ります。手順④の部分です。

まず、Fluentdの設定に下記ディレクティブを追記し、Norikraから結果を取得し、その結果を外部スクリプトに渡すようにします。

<source>
  type    norikra
  norikra localhost:26571
  <fetch>
    method     sweep
    tag        query_name
    tag_prefix norikra.query
    interval 60s
  </fetch>
</source>

<match norikra.query.block.*>
  type       exec_filter
  command /usr/local/bin/blocker ec2.ap-northeast-1.amazonaws.com acl-77527b1f block
  in_format  json
  out_format msgpack
  tag        access.blocked
</match>

前半のsourceディレクティブはNorikraから結果を取得する部分です。60秒に1回、全結果を取得し、「norikra.query」というタグプレフィックスがつくようにしています。タグにはNorikraのクエリ名もつくので、実際のタグは「norikra.query.count_access」や「norikra.query.block.access_over_1000_per_1min」の様になります。

後半のディレクティブはNorikraから受け取った結果イベントを外部スクリプトに渡す部分です。
「/usr/local/bin/blocker」というスクリプト内の処理でAWSのNetwork ACLの設定を変更し、受け取ったIPドレスからの接続を拒否するようにしています。引数として、

  • 第1引数(必須):EC2のエンドポイント
  • 第2引数(必須):Network ACLのID
  • 第3引数(任意):ブロックフラグ(ブロックする場合「block」と指定)
    • を取ります。

      外部スクリプトからの結果には、「access.blocked」というタグがつけられます。

      外部スクリプトの内容は次のようになります。

      /usr/local/bin/blocker

      #!/usr/lib64/fluent/ruby/bin/ruby
      
      require 'aws-sdk'
      require 'json'
      require 'msgpack'
      
      $stdout.sync = true
      
      endpoint = ARGV[0]
      acl_id = ARGV[1]
      block = ARGV[2] == 'block'
      
      ec2 = AWS::EC2.new(ec2_endpoint: endpoint)
      acl = ec2.network_acls[acl_id]
      
      while input = STDIN.gets
        begin
          attacker = JSON.parse(input)
        rescue
          next
        end
      
        if block
          begin
            allow_any_rule = acl.entries.
                         select {|r|
                           r.ingress &&
                           r.action == :allow &&
                           r.protocol == -1 &&
                           r.cidr_block == "0.0.0.0/0"
                         }.
                         sort_by {|r| r.rule_number}.
                         first
      
            deny_rules = acl.entries.
                           select {|r|
                             r.ingress &&
                             r.action == :deny &&
                             r.rule_number < allow_any_rule.rule_number
                           }.
                           sort_by {|r| r.rule_number}
      
            next_rule_number = deny_rules.empty? ? 1 : deny_rules.last.rule_number + 1
      
            unless deny_rules.any? {|r| r.cidr_block == "#{attacker['host']}/32"}
              acl.create_entry(
                rule_number: next_rule_number,
                action: 'deny',
                protocol: -1,
                cidr_block: "#{attacker['host']}/32",
                egress: false,
              )
            end
      
            attacker[:status] = 'blocked'
          rescue
            attacker[:status] = 'failed to block'
          end
        else
          attacker[:status] = 'detected'
        end
      
        attacker[:proccessed_at] = Time.now.to_s
      
        print MessagePack.pack(attacker)
      end

      実行権限を付与しておきます。

      $ sudo chmod a+x /usr/local/bin/blocker

      AWSのNetwork ACLはマネジメントコンソールから確認できます。

      スクリーンショット 2014-04-01 16.38.24

      現在は、どこからのアクセスも許可している状態です。

      では、実際、ブロックされるか確認してみます。
      設定を反映させるため、Fluentdを再起動し、

      $ sudo service td-agent restart

      1000回アクセスしてみます。

      $ curl ifconfig.me
      54.199.254.150
      $ ab -c 100 -n 1000 http://`curl ifconfig.me`/index.html

      1分ほどたってから、マネジメントコンソールを再読み込みしてみると、アクセス元である「54.199.254.150」がブロックされているのが確認できます。

      スクリーンショット 2014-04-01 16.48.00

      Network ACLのルールはルール番号の小さい方から適応されます。ですので、全許可ルール(100番)の1つ手前まで99個の拒否ルールを作ることが可能です。
      より多くのIPアドレスを拒否したい場合は、全許可ルールの番号を大きくするとよいでしょう。

      念のため、cURLコマンドでアクセスできるか試してみます。

      $ curl http://`curl ifconfig.me`/index.html
        % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                       Dload  Upload   Total   Spent    Left  Speed
      100    15    0    15    0     0    280      0 --:--:-- --:--:-- --:--:--   283
      curl: (7) Failed to connect to 54.199.254.150 port 80: Connection timed out

      ちゃんとブロックされてますね。

      ブロック結果の通知設定

      最後に、ブロックの結果をSNSで通知するようにしましょう。手順の⑤にあたる部分です。

      Fluentdのプラグインをインストールします。

      $ sudo /usr/lib64/fluent/ruby/bin/fluent-gem install fluent-plugin-sns --no-ri --no-rdoc

      Fluentdの設定ファイルにSNS用のディレクティブを追加します。
      あらかじめSNSトピックを作り自分のメールアドレスをサブスクライバに登録しておいてください。

      /etc/td-agent/td-agent.conf

      <match access.blocked>
        type sns
        sns_topic_name test
        sns_endpoint sns.ap-northeast-1.amazonaws.com
        sns_subject block_host #constant subject
      </match>

      「test」という名前のSNSトピックに「block_host」というタイトルで通知が飛ぶようにしました。

      先ほど、Network ACLに追加されたDenyルールを削除し、Fluentdを再起動した後、もう一度動作確認します。

      $ ab -c 100 -n 1000 http://`curl ifconfig.me`/index.html

      しばらくすると、SNSのサブスクライバに登録したメールアドレス宛に次のようなメールが届きました。

      {"requests":1000,"host":"54.199.254.150","status":"blocked","proccessed_at":"2014-04-01 08:15:50 +0000","time":"2014-04-01 08:15:50 +0000"}
      
      --
      If you wish to stop receiving notifications from this topic, please click or visit the link below to unsubscribe:
      https://sns.ap-northeast-1.amazonaws.com/unsubscribe.html?SubscriptionArn=xxxxxxxxxx
      Please do not reply directly to this e-mail. If you have any questions or comments regarding this email, please contact us at sns-question@amazon.com

      Fluentdの設定ファイルをまとめると次のようになります。

      <source>
        type tail
        format /^(?<host>[^ ]*) [^ ]* (?<user>[^ ]*) \[(?<time>[^\]]*)\] "(?<method>\S+)(?: +(?<path>[^ ]*) +\S*)?" (?<code>[^ ]*) (?<size>[^ ]*)(?: "(?<referer>[^\"]*)" "(?<agent>[^\"]*)")?$/
        time_format %d/%b/%Y:%H:%M:%S %z
        path /var/log/httpd/access_log
        tag apache.access
        pos_file /etc/td-agent/pos/apache.access_log
      </source>
      
      <match apache.access>
        type    norikra
        norikra localhost:26571
        buffer_queue_limit 1
        retry_limit 0
        remove_tag_prefix apache
        target_map_tag    true
      </match>
      
      <source>
        type    norikra
        norikra localhost:26571
        <fetch>
          method     sweep
          tag        query_name
          tag_prefix norikra.query
          interval 60s
        </fetch>
      </source>
      
      <match norikra.query.block.*>
        type       exec_filter
        command /usr/local/bin/blocker ec2.ap-northeast-1.amazonaws.com <<Network ACL ID>> block
        in_format  json
        out_format msgpack
        tag        access.blocked
      </match>
      
      <match access.blocked>
        type sns
        sns_topic_name <<SNS Topic Name>>
        sns_endpoint sns.ap-northeast-1.amazonaws.com
        sns_subject block_host #constant subject
      </match>
      
      <match norikra.query.detect.*>
        type       exec_filter
        command /usr/local/bin/blocker ec2.ap-northeast-1.amazonaws.com <<Network ACL ID>>
        in_format  json
        out_format msgpack
        tag        access.detected
      </match>
      
      <match access.detected>
        type sns
        sns_topic_name <<SNS Topic Name>>
        sns_endpoint sns.ap-northeast-1.amazonaws.com
        sns_subject detect_host #constant subject
      </match>

      まとめ

      DoS攻撃を検知し、ブロックする仕組みを作ってみました。

      Norikuraを使うのは今回が初めてですが、簡単にクエリを追加、削除できるのが楽しいですね。
      SQLと似ているので、何となくで書いても大体動きます。

      フィールドタイプの判別も自動でやってくれるので、フィールドが増減した場合も特に対応は必要無さそうです。

      例えば、ブルートフォース攻撃を防ぎたいと思った場合、

      1. ログイン失敗をアクセスログに出力するようにする
      2. Fluentdのin_tailディレクティブのformatを修正
      3. Norikraでクエリ追加

      だけで対応できます。
      クエリはこんな感じでしょうか。

      block.login_failed_over_60_per_1min

      SELECT
          host,
          COUNT(*) AS login_failed
        FROM access.win:time_batch(1 min)
        WHERE auth = 'failed'
        GROUP BY host
        HAVING COUNT(*) >= 60

      Norikraのインストールは10分もあればできるので、皆さんもぜひ使ってみてください。

      脚注

      1. Norikraのインストールについてはharukasanさんの記事が分かりやすかったです。