Ruby
Rails

Railsで不正なアクセスをBANして、IPを永続化してSlackに通知する

More than 1 year has passed since last update.

動作環境

  • Rails 5.0.1
  • Ruby 2.4.0

お断り

本記事はセキュリティ要件を担保するものではありませんので、セキュリティ要件は各自でご判断ください。

不正なアクセスを検知 & BAN

やりたいこと

今回は一定時間以内に一定回数以上ログインを試みた攻撃者をBANするケースを想定。
こんな感じのことをやりたい。

attack-detection.gif

他にも共有用のPublicなランダム文字列のURLがあって、そこを全探索しようとしてきた際などにも使える。

READMEに沿った実装

不正なアクセスの検知とBANにrack-attackというGemを使う。READMEに沿って進めると下記のようになる。

Gemfile
gem 'rack-attack'
console
$ bundle install
config/application.rb
+    config.middleware.use Rack::Attack
config/initializers/rack_attack.rb
class Rack::Attack
  Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new
  Rack::Attack.blocklist('fail2ban pentesters') do |req|
    cache_id = "pentesters-#{req.ip}-#{req.user_agent}"

    Rack::Attack::Fail2Ban.filter(
      cache_id,
      maxretry: 3,
      findtime: 10.minutes,
      bantime: 5.minutes
    ) do
      # The count for the IP is incremented if the return value is truthy
      self.attack?(req)
    end
  end

  def self.attack?(req)
    # Your attack detection logic
  end
end

カスタマイズ項目の解説

ここから解説を含めて、詳細な設定の手順を示すが、TL;DRな人は少し先に「最終的なrack_attack.rb」を示しているので、そこまで読み飛ばしても可。

self.attack?にBANする条件を記載する。
Rack::Requestを取得できるので、Pathを判定するもよし、Methodを判定するもよしで、ここはよしなに条件を書く。Rack::Requestから取得できる値はリンク先を参照。

一例として、deviseで作成したログイン画面でログインを試みた時の条件を下記に示す。
このリクエスト自体は攻撃ではないが、一定回数以上ログインを試みていた場合にBANをする想定で攻撃としてカウントする。

config/initializers/rack_attack.rb
def self.attack?(req)
  # Your attack detection logic
+ req.path == Rails.application.routes.url_helpers.user_session_path && req.post?
end

注意点としては、攻撃の条件のアクセスの場合にtrueを返すように記述する。
ここでtrueとなった回数が攻撃回数として記録される。
Rack::Attack::Fail2Ban.filterに記述された条件の閾値を越えたアクセスが来た際にBANを行う。

config/initializers/rack_attack.rb
Rack::Attack::Fail2Ban.filter(
  cache_id,
  maxretry: 3,
  findtime: 10.minutes,
  bantime: 5.minutes
) do

デフォルトの例だと、過去10分間に3回以上、cache_idに該当するアクセスから攻撃があった場合、cache_idに該当するアクセスを5分間BANするようになっている。ここの条件は各自のセキュリティ要件に合わせてカスタマイズする。攻撃者はスクリプトを組んで叩きにくるケースが多いと思うので、BANする条件を見抜かれて、叩き方を変えられた時用に、複数のRack::Attack::Fail2Ban.filterを書いて複数の条件を設定しておくと良いかと思う。

また、cache_idに記録するアクセスの条件を記述する。デフォルトだとIPのみ。今回はIPとUserAgent毎に攻撃回数を記録するような設定とした。

config/initializers/rack_attack.rb
cache_id = "pentesters-#{req.ip}-#{req.user_agent}"

上記の設定でアクセス制限が可能なのだが、
ただ、一点、問題があって、blocklistは内部の条件がtrueの際に403に飛ばす設定となっている、かつ、内部で呼び出しているRack::Attack::Fail2Ban.fail!はtrueを返すので、不正なアクセス以外もself.attack?の条件に合致した場合は403に飛んでしまう。今回で言うとログインしようとした際に、403に飛んでしまう。BANされたユーザのアクセスだけを403に飛ばしたい場合は、下記の記述を行う。

config/initializers/rack_attack.rb
Rack::Attack.blocklist('fail2ban pentesters') do |req|


+ Rack::Attack::Fail2Ban.banned?(cache_id)
end

ちなみに、この403に飛ばす設定は変更できて、下記の記述を加えることで、任意のstatusでエラーページを表示することができる。
下記、公式のコメント通り、攻撃者が攻撃に成功していると思わせるために503を返すことを薦めている。

config/initializers/rack_attack.rb
Rack::Attack.blocklisted_response = lambda do |env|
  # Using 503 because it may make attacker think that they have successfully
  # DOSed the site. Rack::Attack returns 403 for blocklists by default
  [ 503, {}, ['Blocked']]
end

--- 読み飛ばす人はここまで ---

最終的なrack_attack.rb

諸々書いたが、整理のために、最終的なconfig/initializers/rack_attack.rbを示す。

config/initializers/rack_attack.rb
class Rack::Attack
  BANTIME = 30.minutes.freeze

  Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new
  Rack::Attack.blocklist('fail2ban pentesters') do |req|
    cache_id = "pentesters-#{req.ip}-#{req.user_agent}"

    # あくまで一例
    conditions = [
      { maxretry: 5, findtime: 10.seconds },
      { maxretry: 15, findtime: 10.minutes },
      { maxretry: 30, findtime: 1.hour },
    ]

    conditions.each do |condition|
      Rack::Attack::Fail2Ban.filter(cache_id, condition.merge(bantime: BANTIME)) do
        # The count for the IP is incremented if the return value is truthy
        self.attack?(req)
      end
    end

    Rack::Attack::Fail2Ban.banned?(cache_id)
  end

  Rack::Attack.blocklisted_response = lambda do |env|
    [ 503, {}, ['Blocked']]
  end

  def self.attack?(req)
    req.path == Rails.application.routes.url_helpers.user_session_path && req.post?
  end
end

ここまでの実装で不正なアクセスをBANできるように🎉

冗長化構成の場合

ActiveSupport::Cache::MemoryStoreを使用しているので、複数インスタンス間で攻撃回数は共有されないので、キャッシュする領域をRedisなどに変更する対応が必要。

永続化

不正なアクセスをBANできるようになったが、このままでは、人知れずBANをしているので、BANする条件の見直しや攻撃元の管理のためにBANした際に永続化して、通知を行うようにしたい。

まずはAttackLogモデルの追加から

console
$ rails g model AttackLog ip:string user_agent:string
$ rails db:migrate  

永続化を行う処理の追加。BANしたタイミングをハンドリングできないので、Rack::Attack::Fail2Ban.banned?でBANされているかをハンドリングする。

config/initializers/rack_attack.rb
class Rack::Attack
  BANTIME = 30.minutes.freeze

  Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new
  Rack::Attack.blocklist('fail2ban pentesters') do |req|
    cache_id = "pentesters-#{req.ip}-#{req.user_agent}"

    # あくまで一例
    conditions = [
      { maxretry: 5, findtime: 10.seconds },
      { maxretry: 15, findtime: 10.minutes },
      { maxretry: 30, findtime: 1.hour },
    ]

    conditions.each do |condition|
      Rack::Attack::Fail2Ban.filter(cache_id, condition.merge(bantime: BANTIME)) do
        # The count for the IP is incremented if the return value is truthy
        self.attack?(req)
      end
    end

-   Rack::Attack::Fail2Ban.banned?(cache_id)
+   is_banned = Rack::Attack::Fail2Ban.banned?(cache_id)
+   if is_banned
+     range = (Time.zone.now - BANTIME)..Time.zone.now
+     AttackLog.find_or_create_by!(ip: req.ip, user_agent: req.user_agent, created_at: range)
+   end
+
+   is_banned
  end

  Rack::Attack.blocklisted_response = lambda do |env|
    [ 503, {}, ['Blocked']]
  end

  def self.attack?(req)
    req.path == Rails.application.routes.url_helpers.user_session_path && req.post?
  end
end

これでBANしたタイミングでDBにレコードが追加されるようになる🎉

Slackに通知

こちらの記事を参考にslackのWebhook URLを取得し、環境変数に追加する。

.env
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/XXXXXXXXX/XXXXXXXX/XXXXXXXXXXXXXXXXXXXXXXX

slackへの通知はslack-notifierというGemを使う。

Gemfile
gem 'slack-notifier'
console
$ bundle install

slackに通知する用のテンプレートを追加する。

app/views/layouts/attack_notifier.text.erb
不正なアクセスを検知しました。

IP = <%= attack_log.ip %>
UA = <%= attack_log.user_agent %>
DATETIME = <%= Time.zone.now %>

永続化したタイミングでSlackに通知を行いたいので、先ほど追加したAttackLog.find_or_create_by!のafter_createでnotifier.pingを呼び出す。

app/models/attack_log.rb
class AttackLog < ApplicationRecord
  after_create do |attack_log|
    erb = Rails.root.join('app/views/layouts/attack_notifier.text.erb').read
    notifier = Slack::Notifier.new(ENV["SLACK_WEBHOOK_URL"])
    notifier.ping(ERB.new(erb).result(binding))
  end
end

上記の設定を完了すると、BANしたタイミングで、永続化され、Slackに通知がきて、IPとUAを通知してくれる🎉

slack_notifier.png

終わりに

攻撃者からサービスとユーザを守りましょう🙆