fail2banでWordPressを守る

ブログなどのCMSにWordPressを使うと避けられないのが管理者ログインに対するブルートフォースアタック。Wordpressは利用者が非常に多いのでこれは仕方の無いことかもしれません。取り敢えずは管理者のユーザー名とパスワードを推測されにくい大文字小文字含む英数字で12文字以上厳守でときどき変更というところでしょうか。一部のウェブアプリは管理者のログイン用URL自体が数十文字のランダム英数字というようなものになっていて、そもそもログインURLだけで既に普通のID+パスワードより強固というようなのもあるのでWordPressもそういう技術を標準で取り入れて欲しいと思ったり、でもそれだとログインできなくなる管理者続出かもと思ったり。

最近のブルートフォースアタックはお上品で1回ずつのアタックに間を置いて、1つのボットが数回〜100回前後の回数試すと退いて、別のボットが次にまた同様に来てというのが多いようです。
で、そういうボットだけならIDとパスワードを強固にしてという対策でも何とかならないでもないのですが、未だに力ずくなボットもいるので短時間にすごい数のログインを繰り返します。放置するとWebサーバのパフォーマンスに影響が出ないとも限りません。また、管理者ログインを奪うブルートフォースアタックだけでなく、存在するしないに関わらず様々な(WordPressに限らず過去に脆弱性が指摘されたような)URLを片っ端から試すようなボットも来ます。これらもお引取りいただきたいところです。

そのようなボットをBANする(出禁にする)のに有効なのがfail2banです。「失敗したらバンする」名前が秀逸です。レンタルサーバだとVPS以上、または自宅サーバ以上で利用できます。残念ながらFTPとウェブ操作だけしかさせて貰えない簡易レンタルサーバでは利用できません。
今回はWordPress用に書いていますが、インターネットに公開している様々なサービスで利用可能でしかも役に立つので私は利用しまくっています。
今回は(というか「がとらぼ」ではいつも) FreeBSDにインストールします。また、WebサーバはApacheではなくNginxとします。もちろん、OSがLinuxでもWebサーバがApacheでも利用できます。

インストール

FreeBSD9,10あたりから使えるようになっている新しいパッケージシステムでfail2banのパッケージを検索します。

# pkg search fail2ban
py27-fail2ban-0.9.0

今回はpy27-fail2ban-0.9.0という返事が返されました。目的のports名はバージョンの数字を除いておそらくpy-fail2banです。(py27はPython2.7系のこと)

# cd /usr/ports/*/py-fail2ban
# pwd
/usr/ports/security/py-fail2ban
# 

セキュリティ系portsのディレクトリに居ました。真っ当です。

# make install

Python2.7が入っていなければ一緒にインストールされます。
設定ファイルは/usr/local/etc/fail2ban下になります。
fail2banのportsは2014年5月現在ではまだ行儀が悪くてportsを更新すると設定ファイルをまっ更なものに上書きしてくれやがります。重要なので憶えておく方が良いです。

jail.local

まず、jail.confと同じディレクトリにjail.localというファイルを作成します。これはjail.confより優先されて使用されます。jail.confは編集しないこと。書き方はjail.confを真似ます。

[DEFAULT]
ignoreip = 127.0.0.1 192.168.0.0/24 66.249.64.0/19
bantime  = 86400
findtime = 7200

[wordpress]
enabled = true
filter  = wordpress
action  = pf
          sendmail-whois[name=wordpress, dest=foobar@example.com, sender=fail2ban@example.com]
logpath = /var/log/wordpress-access.log
maxretry = 3

[error404]
enabled = true
filter  = error404
action  = pf
          sendmail-whois[name=error404, dest=foobar@example.com, sender=fail2ban@example.com]
logpath = /var/log/wordpress-error.log
maxretry = 10

ignoreipがfail2banのルールを適用しないIPアドレスまたはネットワーク、ここではlocalhostと自宅LANとして192.168.0.0/24、googlebot用として66.249.64.0/19を書いています。半角スペースで区切って並べるだけです。自宅LANのネットワークのところは実際にはグローバルIPアドレスにします。googlebot用ネットワークは参考程度で、これで完璧というわけではありません。
bantimeはBANになってから解除されるまでの時間を秒指定です。上の例では1時間3600(秒)×24(時間)=86400としました。
findtimeは「どれだけの時間」中に指定の失敗回数を検知するかという時間指定です。上の例では1時間3600(秒)×2(時間)=7200としました。
上の例ではDEFAULT内に記述していませんが、findtime時間中にmaxretry回数検知するとそのIPアドレスがBANされるようになります。

今回は6〜12行目にwordpressエントリを作成しました。
enable = trueを指定することでそのエントリが有効になります。jail.conf内には沢山のエントリが登録されていますが、これらはenable=falseが指定されているので初期値では無効ということになります。
filter = wordpressを指定することでfilter.d内のwordpress.confルール設定が読み込まれます。filter.dディレクトリ内に既に存在しないファイル名で指定します。portsを更新した時に上書きされるのを防ぐため。
action = pfを指定することでaction.d内のpf.confアクション設定が読み込まれます。FreeBSDのパケットフィルタにPFを使用している場合。iptablesを使用しているならaction = iptablesを指定します。私は国別に弾くとかしているので扱いやすくて高速なPFを使用しています。action.dフォルダ内に既に存在しないファイル名で指定します。
sendmail-whoisを指定することで、誰かがBANされると指定した宛先にメールが送信されます。fail2banを使い始めてから当分はメールを見て不適切なBANになっていないか確認した方が良いと思われます。例えば誤って検索エンジンのクローラーbotをBANしてしまうようなことになると悲しいので。 logpath はNginxの出力するログファイルです。今回はエラーログではなくアクセスログをフルpath指定します。バーチャルドメイン別にログを分けているならそれを。
maxretry = 3で3回ルールにヒットした場合、ただしfindtime内(上の例では2時間中)でそのアクセスを行ったIPアドレスがBANされます。

同様にerror404エントリも作成します。(14行目〜)
こちらはNginxのエラーログを指定します。バーチャルドメイン別にログを分けているならそれを。

filter.d/wordpress.conf

filter.dディレクトリ下にwordpress.confを作成します。

[Definition]                                                                    
failregex =    ^<HOST> .* \"POST /wp-login\.php HTTP/1\.1\" 302 .* \"http\://www\.example\.com/wp-login.php\" .*$
ignoreregex =

failregexには必ず「<HOST>」が必要です。ログファイル内のどこにIPアドレスがあるのか、ヒットさせたい文字列との位置関係も確認します。
この例ではログファイルの最初にIPアドレスがあり(中略)「"POST /wp-login\.php HTTP/1\.1" 200」を探して(後略)となります。文字列中のピリオドはエスケープさせます。

以下のようなログを想定しています。1行目のエラーコード200を返している方がログイン失敗時のログ、302を返している方がログイン成功時のログです。 (xxx.xxx.xxx.xxxはIPアドレス)

xxx.xxx.xxx.xxx - - [15/May/2014:17:33:49 +0900] "POST /wp-login.php HTTP/1.1" 200 1610 "http://host.example.com/wp-login.php" "Mozilla/5.0 (X11; Linux x86_64; rv:29.0) Gecko/20100101 Firefox/29.0"
xxx.xxx.xxx.xxx - - [15/May/2014:17:35:51 +0900] "POST /wp-login.php HTTP/1.1" 302 5 "http://host.example.com/wp-login.php" "Mozilla/5.0 (X11; Linux x86_64; rv:29.0) Gecko/20100101 Firefox/29.0"
ちなみにログ形式はnginx.conf下のように設定している。
    log_format  main    '$remote_addr - $remote_user [$time_local] "$request" '
                        '$status $body_bytes_sent "$http_referer" '
                        '"$http_user_agent"';

filter.d/error404.conf

filter.dディレクトリ下にerror404.confを作成します。

[Definition]                                                                    
failregex = ^.* failed \(2: No such file or directory\), client: <HOST>, .*$
            ^.*Unable to open primary script .* client: <HOST>, server .*$
ignoreregex =

action.d/pf.conf

FreeBSDのportsからインストールしているなら変更不要です。

[Definition]
actionban = /sbin/pfctl -t <tablename> -T add <ip>/32
actionunban = /sbin/pfctl -t <tablename> -T delete <ip>/32

[Init]
tablename = fail2ban

2行目のactionbanの行がBANになる時にpfのfail2テーブルにそのIPアドレスを追加するコマンドです。/32になっているので1つのIPだけです。
3行目のactionunbanの行がBANが解除になる時にpfのfail2テーブルからそのIPアドレスを削除するコマンドです。
tablenameはfail2banでこれは変更する必要は特に無いかと思います。

/etc/rc.conf

/etc/rc.confに以下を加えます。

pf_enable="YES"
pf_rules="/etc/pf.conf"
fail2ban_enable="YES"

動作確認

作ったルールで正しくヒットするか確認します。(1行ずつ)

# fail2ban-regex /var/log/wordpress-access.log /usr/local/etc/fail2ban/filter.d/wordpress.conf
# fail2ban-regex /var/log/wordpress-error.log /usr/local/etc/fail2ban/filter.d/error404.conf
以下のような出力結果が出ます。(空行除去)
unning tests
=============
Use   failregex file : ./wordpress.conf
Use         log file : /var/log/wordpress-access.log
Results
=======
Failregex: 2330 total
|-  #) [# of hits] regular expression
|   1) [2330]  .* "POST /wp-login\.php HTTP/1\.1" 200 .*
`-
Ignoreregex: 0 total
Date template hits:
|- [# of hits] date format
|  [38325] Day/MONTH/Year:Hour:Minute:Second
`-
Lines: 38325 lines, 0 ignored, 2330 matched, 35995 missed

日時フォーマットが正しく検知されていることを確認します。[38325] Day/MONTH/Year:Hour:Minute:Secondの行がそれです。38325行で日時フォーマットが同一形式の 日/月/年:時:分:秒 で認識されています。また、ログ内の行数は最後の行でLines: 38325となっているので合っています。fail2banでは検知日時が重要なのでここが正しく機能していない場合は問題です。
最後の行に2330 matchedとあるので2330行が作成したルールで検知できたということになります。
なお、fail2ban-regexではlocal.jailを見ていないのでfindtimeやmaxretryは関係なく、ログファイル内の全ての行を比較して結果を出力します。
また、ルールにマッチしなかった行が本当に思惑通りに検知されなかったのか確認するために以下のようにオプションを付けてマッチしなかった行全てを表示させて確認しておきます。

# fail2ban-regex --print-all-missed ログファイル ルールファイル

/etc/pf.conf

pfの設定ファイルを触ります。

ext_if = "fxp0"
table <cnfilter> persist file "/etc/iprange_cn.txt"
table <botfilter> persist file "/etc/iprange_bot.txt"
table <fail2ban> persist

tcp_services = "{ smtp, www, https }"
udp_services = "ntp"

priv_nets = "{ 127.0.0.0/8, 192.168.0.0/16, 172.16.0.0/12, 10.0.0.0/8 }"
IcmpPing ="icmp-type 8 code 0"
SynState ="flags S/SA synproxy state"
TcpState ="flags S/SA modulate state"
UdpState ="keep state"
set block-policy drop 
set loginterface $ext_if
set skip on lo0
scrub in all
block all
block in  quick on $ext_if from $priv_nets to any
block out quick on $ext_if from any to $priv_nets
block in  quick on $ext_if from { <cnfilter>, <botfilter>, <fail2ban> } to ($ext_if)
block out quick on $ext_if from ($ext_if) to { <cnfilter>, <botfilter>, <fail2ban> }

pass in on $ext_if inet proto tcp  from any to ($ext_if) port $tcp_services $TcpState
pass in on $ext_if inet proto udp  from any to ($ext_if) port $udp_services $UdpState
pass in on $ext_if inet proto icmp from any to ($ext_if) $IcmpPing $UdpState

pass out on $ext_if inet proto tcp $TcpState
pass out on $ext_if inet proto udp $UdpState
pass out on $ext_if inet proto icmp $UdpState

ちなみに2行目のcnfilterは支那よけのテーブルです。
IPアドレスのリストはファイアーウォール用のルール(東アジアフィルター)さんのところからpf用のファイルを頂いて来てそれぞれファイル名を変更して置きます。(無くなったようです。)代わりにこちらに作りました。
3行目のbotfilterは日本国内から来るBaiduspiderやYetibot(旧NaverBot)などお前だけは来るなというボットを登録しています。(非公開) 昔のNaverBotも酷かったですが、Baiduのは対策してもすり抜けようとするしUA偽装もするしとあまりにも酷すぎです。

起動

pfを起動します。

# /etc/rc.d/pf start
もしくは、既に起動しているなら以下でルールファイルを読み込ませます。
# pfctl -f /etc/pf.conf

どちらにしてもsshでログインしている場合はセッションが変に切れて操作不能になります。

fail2banを起動します。

# /usr/local/etc/rc.d/fail2ban start

ログファイルを頭から読んで処理し始めるのでログファイルが巨大な場合は暫くコマンドプロンプトが帰って来ませんが異常ではありません。今回はfindtimeが有効になっているので上の設定のようにfindtimeを2時間としている場合は直近2時間にルールにマッチした行がmaxretry回数検知されなければBANされません。
BANが発生した場合はメールが送信される筈ですが、一応、pfのテーブルも確認しておきます。

# pfctl -t fail2ban -T show

BANされたIPアドレスがあればIPアドレスが1行に1つ表示されます。bantimeを過ぎたら自動的にfail2banテーブルから削除されこのコマンドを入力してもそのIPアドレスは表示されなくなります。(の筈です)

BANされたのをただちに取り消したい場合、以下のコマンドを入力します。(192.168.0.5は取り消したいIPアドレス)

# pfctl -t fail2ban -T delete 192.168.0.5

追記: WP fail2banプラグインを使う場合

上の例では管理画面へのログイン失敗をエラーコード200で見つけるという中途半端感のあるものですが、WP fail2banプラグインを使用すると Authentication failure という形ではっきり認証失敗を出してくれます。また、管理画面への認証失敗以外にも幾つかのログを吐いてくれるように設定できます。このプラグインの設定は管理画面ではなくwp-config.phpへの追記で行います。

WP fail2banのログの吐き出し先は標準では/var/log/auth.logのようです。1台のサーバで複数のwordpressを動かしていると普通は複数のアクセスログファイルをfail2banに監視させる必要があるのですが、それがauth.logファイル1つ監視させるだけで良いので設定する側としても楽かもしれません。

/usr/local/etc/fail2ban/jail.local
(WP fail2ban 3.0.0以降ならこのページの下部参照)

[wordpress]
enabled  = true
filter   = wordpress
action   = pf
           sendmail-whois[name=wordpress, dest=foobar@example.com, sender=fail2ban@example.com]
logpath  = /var/log/auth.log
timeregex = S{3}s{1,2}d{1,2} d{2}:d{2}:d{2}  #もしも日時FormatがJul 26 00:06:53のような形式で且つ自動認識されない場合
timepattern = %%b %%d %%H:%%M:%%S           #同上  認識されるならこの2行は不要
maxretry = 2

/usr/local/etc/fail2ban/filter.d/wordpress.conf (WP fail2ban 3.0.0以降ならこのページの下部参照)

[INCLUDES]
before = common.conf

[Definition]
failregex = Authentication failure for .* from <HOST>$
           Authentication attempt for unknown user author from <HOST>$
ignoreregex =

管理画面への認証失敗以外のログを出す設定を追加した場合はもちろんwordpress.confのfailregexにそれに対応する行を追記する必要があります。

2016年7月9日追記:
WP fail2banの最近のバージョンVer.3.0.0以降ではプラグインフォルダの中にwordpress-hard.confとwordpress-soft.confという2つのフィルタファイルが入っている。これをfail2banのfilter.dフォルダにコピーする。jail.localを編集してこのwordpress-hardとwordpress-softのフィルタを読ませる。という使わせ方をさせたいらしい。
それに従うなら少し上の方に書いているjail.localとフィルタファイルは使わない。(参考程度で)

jail.local
[wordpress-hard]
enabled = true
filter = wordpress-hard
logpath = /var/log/auth.log
maxretry = 1
port = http,https

[wordpress-soft]
enabled = true
filter = wordpress-soft
logpath = /var/log/auth.log
maxretry = 3
port = http,https

WP fail2banの説明では上をjail.localに書くようだが当然これをそのままだと機能しないので上の方で書いてるようにactionを指定する。その他ログファイルの指定を自分のシステムに合わせるとかはした方が良い。
フィルタファイル側も正しく機能するものであることを確認する。

関連記事