Linux/fail2banでアクセスログ監視~その2

ありもしないWordPressへのアクセスが止まらないよ~(2017-03-08、更新:2017-09-12)


fail2banでアクセスログ監視 が導入できたので、その続きです。
また、明らかに同じ接続元からの攻撃が連続で来る場合は
静的なIPフィルタで接続拒否し続ける こともご検討ください。



botが蔓延しているせいなのか、
脆弱なURLを探すアクセスが、時に大量やってきます。

(一部抜粋)
/admin/config.php
/mysqladmin/scripts/setup.php
/phpadmin/scripts/setup.php
/pma/scripts/setup.php
/wordpress/
/wp/wp-admin/
/wp-login.php

404 Not Found で終わるから支障は無い・・・ですが、
脆弱性を探られた挙句に想定外の攻撃を食らうのは嫌なので、
「想定外のアクセス=即 ban」とするのが今回の目標です。

片っ端から即 ban  ~ignoreregex 以外、全拒否編

URL や ステータスを元に fail2banのフィルタを作ります。
設定が上手くいかない場合は 前回(導入編) もご参照ください。

まず新しいフィルタルール、
/etc/fail2ban/filter.d/apache-whitelist.conf を作成します。

[Definition]
# ここはログに合わせる。ログの中から HOST が特定できる正規表現にすること。
failregex = <HOST>

# 見づらいから改行しましたが、本来は一行で書く。
# スペース有無に注意。パイプ「|」区切りは先頭行末には不要。
ignoreregex = .css.php[ \?]|index.html[ \?]|index.php[ \?]| / |
                .gif |.png |.jpg |.ico |.css |.js |
                robots.txt |\?plugin=sitemap |/sitemap.xml | 408 |
                /directory1/|/directory2/

# 日付書式を変更している場合は、
# datepattern も作成する

failregex はHOST名ありのログ全てを対象にします、

ignoreregex で無視するログ、
つまり今回は「接続許可する」ログの正規表現を書きます。
 「|」は「または(or)」の意味、
 「?」は前文字の0回か1回の繰り返しの意味なので、そうならないように「\?」
 「[ \?]」は、スペース か ?のどちらか、という意味、
 「.」も任意の一文字という意味ですが、今回は「\.」には直しません。面倒なので。
それを踏まえて、
 index.htmlなどクエリ文字「?」を許可するファイルと、
 それ以外のクエリを認めないファイル・拡張子を分けて指定する。
 検索用のrobots.txtファイルやsitemapも忘れずに足して、
 408 はリクエストTime Outを無視するため、
 (408 Time Out 時はメソッドもURLも出ない)
 最後にディレクトリごと許可する場合を指定しています。

・・・と、いきなり記入例を挙げてしまいましたが
正規表現の選別方法の例は後述します。
最初は、もっと緩い条件から徐々に細かい ignoreregex に変えてもよろしいかと。

これらをfail2ban の jail に設定します。
今回は /etc/fail2ban/jail.d/local.conf に直接書いています。

[apache-whitelist]
enabled = true
port    = http,https
usedns = no
logpath = %(apache_access_log)s tail
filter = apache-whitelist
maxretry = 1
findtime = 1
bantime = 1
backend = pyinotify

maxretry を1に指定して、一回のアクセスで即 ban します。
bantime は上記では1にしていますが、
設定確認後に数分~数時間に増やしましょう。

これで設定を読み込みます。

# service fail2ban reload

あとは、「tail -f /var/log/fail2ban.log 」などしながら、
Webブラウザや wget 等で不正なURLへアクセスして確認してみましょう。

認証失敗に対するフィルタ ~HTTP応答ステータスで拒否編

うちのページはWikiなので、編集機能と認証機能もあります。
それらへの不正アクセスも減らすために、
HTTPの応答ステータスでもフィルタを検討します。
(Apache の LogFormat だと %s か %>s の部分)


ここで脱線しますが、apache の LogFormat について。

LogFormat は デフォルトだと
「%h %l %u %t \"%r\" %>s %b」だと思いますが、
%s 等のよく使う値は、%r 等の書式自由な値より前に持ってくると便利です。
なぜかというと、
%r はスペースを含む数が変わるので
スペース区切りで前から何番目という指定ができなくて面倒くさいからです。
(同じく、日時 %t も使いやすい形に変えられます)

そして、書式を変える場合は、
https通信についても確認しておきましょう(SSLの有無と、そのログの書式)。
私の環境では http-ssl.conf で TransferLog が別途定義されていたため、
こちらの書式も直す必要がありました。
(TransferLogの前に、ニックネーム無しの LogFormat を定義する)


話を戻して、
アクセスログはこの書式に変えました。
「LogFormat "%{%y%m%d/%H:%M:%S}t %h %>s ~"」
(時刻、IP、ステータス。欲しいのは3番目)

170201/11:22:33 127.0.0.1 200 (~以降省略)

それをふまえて、追加する fai2banの filter の値は

failregex = ^[\d:/]* <HOST> [45]\d\d

400番台はクライアントエラー、500番台はサーバエラーです。
([45]\d\dではなく、(401|403) のように明確な方が良いかもしれません)
404 Not Found については、
「正常」な接続もあるのでフィルタする場合は慎重に。
(例えば favicon.ico や apple-touch-icon をはじめとした、
 有無にかかわらずブラウザ標準で取りに来ては 404 になるファイルがある*1

banできないよ!? ~接続済みセッションを 一旦 Closeするには

ログを見ながらのフィルタなので、
常に初回アクセスは許可しています。*2
・・・そう、最初のセッションで一気に連続でやられたら、
止めることができないのです。やられました・・・

fail2ban.log で、なぜか バンバンうるさいと思ったら・・・
[apache-whitelist] Ban 162.243.242.xxx
[apache-whitelist] 162.243.242.xxx already banned
[apache-whitelist] 162.243.242.xxx already banned

以下は wget で1秒間隔で(-w 1)アクセスした例です。
index(正常)、 bad_url(攻撃)、 index(正常) の順に同一セッション内でアクセスしています。

# wget null-i.net/index.html -w 1  null-i.net/bad_url.cgi -w 1  null-i.net/index.html
(2つ目 bad_url.cgi でfail2ban が有効になるが、3つ目が接続できてしまう)
# wget null-i.net/index.html
(続けてコマンド実行し、今度はfail2banで接続拒否済みであることを確認)

firewallで drop できるのは新規セッションなので、
接続済みのセッションは一度、切断させないといけません。
さて、どうしようか・・・
KeepAlive*3を無効にする手もあるけど、正常な通信の効率が落ちるのは避けたいし・・・
404エラーの場合だけ、セッションを切断できないか?
・・・という訳で試しに ErrorDocument ディレクディブで cgi からの closeを試みます。

まずは、404 Not Found で呼ばれる cgi を作ります。

# cat bad_url.cgi
#!/bin/perl

# refresh での10秒後ジャンプ先 null-i.net は書き換えてください!!
# もっとも、banする場合はジャンプできないので、この行は削除でも構いませんが。
my $body=<<'EOS';
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Not Found</title>
<meta http-equiv="refresh"content="10; url=http://null-i.net/">
<body>
Not Found, and some access will be drop by firewall, sorry.
</body></html>
EOS

# connection close することを明示する
print "Content-type: text/html; charset=UTF-8\r\n";
print "Content-Length: " . length($body) . "\r\n";
print "Connection: close\r\n";
print "\r\n";
print $body;

close(STDOUT);
close(STDERR);

一行目の perl のPATHは環境に合わせて変更し、cgiファイルの chmod と chown を忘れずに。
もちろん perl 以外でも構いません、Connection close を投げるのが目的です。
(初 cgi の場合は、少し苦戦するかもしれませんが、
 apacheのサイトにある cgiチュートリアルを参考にがんばりましょう。)

このファイルを、404 の時に参照するように
http.conf で指定します。PATHは例なので cgiを置いた場所に変更してください。*4

ErrorDocument 404 /cgi-bin/bad_url.cgi

そして、「service httpd restart」(あるいは graceful)などでコンフィグを更新したら、
もう一度、動作確認します。

# wget null-i.net/index.html -w 1  null-i.net/bad_url.cgi -w 1  null-i.net/index.html
(2つ目 bad_url.cgi でfail2ban が起動して、3つ目が拒否されることを確認する)

念のために tcpdumpで確認したところ、
404 Not Found 時にサーバ側からTCP-Finを返していることは確認できました。
つまり、apache が一旦接続を切る旨を wget へ伝えています。
そのため、wgetの3つ目は新規セッションになり、firewallで drop されます。

ただし、さらに言えば、
wget の秒間隔「-w 1」を消せば、やはり3つとも通ることが確認できます。
これは単に、
アクセスログ出力~fail2ban検知~firewall設定
まで(1秒くらい?)のタイムラグでしょう。
これをログ監視で防ぐのは難しいので、止めるなら・・・WAFか?*5

ここまでできたら、 bantime を変えつつ、様子を見ましょう。
一応、数日様子を見ましたが、
これだけでも 脆弱性探しの 404の数がグッと減りました。


後日談:
という訳で、様子を見たのですが、

  1. 明らかに効果があるのは、定番のアクセス
    WordPress、DBなどの定番な脆弱性探しはキーワードが決まっているので
    止める価値はある
  2. ただ、不正アクセスのバリエーションは多いので、
    正規表現で静的に止めるのは限界はある
  3. fail2ban が発動する前に連続で来る、
    最初の一秒間で一気にくるアクセスは煩わしい

2つ目はいたちごっこなので、一旦おいておいて
(企業ならちゃんとした製品を導入すれば良いのでしょうけれど)
3番については、結局 「squid を導入してのTCP_RESET で切断」という手で様子を見ています。
もちろん、Apacheのプラグインを探しても良いですし、
snort を間に挟んで(監視ではなく中継させて)不正アクセスは切断するとか、色々できそうです。

(参考)フィルタルールの絞り込み作業例

ここからはフィルタ正規表現を足したり削ったりする作業例です。
何から手を付けようか思い浮かばない場合に、
案の1つとして参考にして頂ければと思います。

まず、
特定のアクセスだけを許すのか(ホワイトリスト)、
特定アクセスだけを止めるのか(ブラックリスト)。
今回は前者を採用して、
想定外のアクセスはすべて拒否する方針です。
(次から次へと違うURLでアクセスしてくるので、ブラックリストは難しい)

ちなみに、
うちのサイトはPukiwikiで作っています。
cgiも自分でアレンジしたもの以外は、Pukiwikiがベースです。
結果、index.php にクエリが続く、というのが基本書式です。

よって正常なWebアクセスは、こんなものを想定します、
(そもそも(HEADとかGETとか)何言ってるのか分からない方、これらはHTTPリクエストの一行目の構文なので、Webアクセスを監視するならばぜひ調べてみてください)

HEAD  /                            HTTP/1.1
GET   /aaa/image.png               HTTP/1.1
POST  /index.php?aaa=bbb&ccc=ddd   HTTP/1.1
※スペースを強調して書いています。

なお、「クエリ」というのは上記3行目の「?aaa=bbb&ccc=ddd」の部分で、
Webブラウザから Webサイトへ情報を渡すときに付きます。
(Pukiwikiだとサイト間の移動や文字列検索など、色々使っています)
一方で閲覧のみのファイル、画像ファイルとかには付きません。

そこで、あくまで一例になりますが・・・
ディレクトリ、ファイル名や拡張子、クエリ有無
に着目するのはどうでしょうか。
つまり以下のパターンをホワイトリストの基本形とします。

「/」のみ
「/」+「ディレクトリ名」+「/」
「特定のファイル名・または拡張子」+「スペース or クエリ」 ※クエリを許可する
「特定のファイル名・または拡張子」+「スペース」      ※クエリを許可しない

それはさておき、
細かくは、のちのち調整するとして、
まず試しにファイル名のリストを作ってみます。

# cat regexp.txt
[ ]/[ ]
[ ]//[ ]
index.html[ \?]
index.php[ \?]
.gif[ ]
.png[ ]
.jpg[ ]
.ico[ ]
.css[ ]

上記の[ \?] はスペース or ?という意味で、?は「\?」と表現します。
これをアクセスログにかけてみます。
以下、apacheの例です。nginx など別Webサーバの場合も流れは同じですが。

 軽く素振りしてみましょう。
 regexpでフィルタした結果を数行出してみます。

# egrep -f regexp.txt  /var/log/httpd/access_log | head

このログか、あるいは httpd.conf などの LogFormat で
応答ステータス(%s)と、リクエスト内容(%r)が何フィールド目か確認します。
ステータスやリクエストがログ出力に無い場合は追加しましょう。
私の環境だとそれぞれ3番目、6番目以降だったので、

# egrep -f regexp.txt  /var/log/httpd/access_log | awk '{ printf("%s: %s %s %s\n", $3, $6, $7, $8);
# egrep -vf regexp.txt  /var/log/httpd/access_log | awk '{ printf("%s: %s %s %s\n", $3, $6, $7, $8);
期待する出力の例
200: "GET / HTTP/1.1"
200: "GET /index.html HTTP/1.1"
200: "GET /index.php?cmd=list HTTP/1.1"
200: "GET /image/rss.png HTTP/1.0"

「egrep -f 」はファイル内の文字列リストで絞り込むためのオプション
「-v 」は ”含まない”場合に指定、つまり除外条件指定。
awkで $3番目(ステータス)、$6~$8番目(HTTPリクエスト)を出しています。
この出力結果でステータス・リクエストが無事表示できたら、更に絞り込みをかけます。

egrep -vf regexp.txt  /var/log/httpd/access_log* |\
 awk '{ printf("%s: %s %s %s\n", $3, $6, $7, $8); }' |\
 egrep '^2' |\
 sort -u

200で始まるリクエスト(egrep '^2')を表示します。
sort -u は重複行を排除して表示行数を減らす為です。
つまり、
この出力が0件になるのが目標です。正常な通信はすべてregexp.txtに含まれる状態にします。
regexp.txt の過不足を直して egrep を繰り返しましょう。
たとえば私の場合、 robot.txt や favicon.ico 等が抜けていることが分かりました。

この出力が0件になったら、逆にリストの内容が甘くないかを確認します。

egrep -f regexp.txt  /var/log/httpd/access_log* |\
 awk '{ printf("%s: %s %s %s\n", $3, $6, $7, $8); }' |\
 egrep '^[45]' |\
 sort -u

400、500番台で始まるリクエスト(egrep '^[45]')を表示します。
表示されたものは正常な通信として許可する予定なので、
許容できないものまで regexp.txt リストに入っていないか、確認しましょう。

regexp.txt が確定したら、前述の fail2ban フィルタへ戻ります。
各行を「パイプ(|)」で繋いで、ignoreregex に渡しましょう。

(参考)正規のアクセスと不正アクセスを分けられない場合は・・・

正規表現をどう書くかについては・・・慣れです [smile] (私もよく間違えます)
ただ、
ファイル名やディレクトリ構成は工夫できます。
ファイル名変更が困難でない限りは(仕様上できなかったりしなければ)、
初期の名前・構成は攻撃対象になるだけなので、
変えてしまうことをお勧めします。
例えば、

「/cgi-bin/setup.cgi」の置き場所を「/cgi-bin/my_secret/setup.cgi」に変える~
(mkdir と mv するだけです)

これだけでも bot の追跡は減りますし、
ホワイトリストで仕訳もしやすくなります。(/my_secret/で仕訳できる)


・・・ここまで長くなってしまいましたが、
おつかれさまでした!

私も日々、手探りの話題なので、少しでもご参考になれば幸いです。


*1 他には .well-known/assetlinks.json に apple-app-site-association ・・・あと、何だ?自分の知らない不正アクセスも正常アクセスも区別付きませんよ、ねぇ?
*2 そう、プロレスラーは初撃は全身で雄々しく受け止める掟なのです!・・・しかし私はSEなので初撃もらったら斃れてしまいます!このままでは・・・
*3 HTTPのKeepAliveについて。Webサーバから情報を取得する時の流れで 「1.TCP接続する、2.HTTPで送受信する、3.TCP切断する」、という流れを「1.TCP接続する、2.HTTPで送受信する、3.HTTPで更に何度も送受信する、4.最終的にTCP切断する」と、一回のTCP接続で複数のHTTP送受信し、いちいちTCPを繋いだり切ったりする負荷を減らすための仕組みです。TCPのKeepAliveというのもありますが、それは少し別の話。HTTP要求のKeepAliveヘッダ や 応答のConnectionヘッダ がそれ関連の指示になります。
*4 cgi-binが初期ディレクトリとして用意されていると思いますが、できれば名前変更した方が良いかと。予想されて攻撃されてしまうので。
*5 Webアプリケーションファイアウォール、もフリーウェアはあります。あとはWAFでは無いですが、squid等をリバースプロキシにしてURLフィルタをかける、とか?

  最終更新のRSS