動作環境
- Rails 5.0.1
- Ruby 2.4.0
お断り
本記事はセキュリティ要件を担保するものではありませんので、セキュリティ要件は各自でご判断ください。
不正なアクセスを検知 & BAN
やりたいこと
今回は一定時間以内に一定回数以上ログインを試みた攻撃者をBANするケースを想定。
こんな感じのことをやりたい。
他にも共有用のPublicなランダム文字列のURLがあって、そこを全探索しようとしてきた際などにも使える。
READMEに沿った実装
不正なアクセスの検知とBANにrack-attackというGemを使う。READMEに沿って進めると下記のようになる。
gem 'rack-attack'
$ bundle install
+ config.middleware.use Rack::Attack
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をする想定で攻撃としてカウントする。
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を行う。
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毎に攻撃回数を記録するような設定とした。
cache_id = "pentesters-#{req.ip}-#{req.user_agent}"
上記の設定でアクセス制限が可能なのだが、
ただ、一点、問題があって、blocklist
は内部の条件がtrueの際に403に飛ばす設定となっている、かつ、内部で呼び出しているRack::Attack::Fail2Ban.fail!はtrueを返すので、不正なアクセス以外もself.attack?
の条件に合致した場合は403に飛んでしまう。今回で言うとログインしようとした際に、403に飛んでしまう。BANされたユーザのアクセスだけを403に飛ばしたい場合は、下記の記述を行う。
Rack::Attack.blocklist('fail2ban pentesters') do |req|
+ Rack::Attack::Fail2Ban.banned?(cache_id)
end
ちなみに、この403に飛ばす設定は変更できて、下記の記述を加えることで、任意のstatusでエラーページを表示することができる。
下記、公式のコメント通り、攻撃者が攻撃に成功していると思わせるために503を返すことを薦めている。
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
を示す。
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モデルの追加から
$ rails g model AttackLog ip:string user_agent:string
$ rails db:migrate
永続化を行う処理の追加。BANしたタイミングをハンドリングできないので、Rack::Attack::Fail2Ban.banned?
でBANされているかをハンドリングする。
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を取得し、環境変数に追加する。
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/XXXXXXXXX/XXXXXXXX/XXXXXXXXXXXXXXXXXXXXXXX
slackへの通知はslack-notifierというGemを使う。
gem 'slack-notifier'
$ bundle install
slackに通知する用のテンプレートを追加する。
不正なアクセスを検知しました。
IP = <%= attack_log.ip %>
UA = <%= attack_log.user_agent %>
DATETIME = <%= Time.zone.now %>
永続化したタイミングでSlackに通知を行いたいので、先ほど追加したAttackLog.find_or_create_by!
のafter_createでnotifier.ping
を呼び出す。
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を通知してくれる🎉
終わりに
攻撃者からサービスとユーザを守りましょう🙆