執拗にメールログインしてくる海外IPをブロックする

# 執拗にメールログインしてくる海外IPをブロックする
## 概要
保守しているサーバーの一部でよくメールアカウントを乗っ取られてスパム配信されていることが多く、しょっちゅうメールキューが溜まっては、溜まった分のスパムを削除=>乗っ取られたメールアカウントのパスワードを変更=>報告の繰り返しで手を焼いていました。
fail2banというツールも運用しているのですが、failする回数が少なくすんなりログインされてしまっていて、fail2banもうまく機能しない(それはそれで原因を別途特定する必要はありますが..)
メールアカウント全てのパスワードを自分で変更する権限がないので、海外からログインされた場合に自動的にiptablesでブロックするようにしてみました。

## 環境
CentOS 6.7
qmail 1.03
couriertcpd 4.15
とはいえ、maillogからログイン成功の文字列をgrepしてるだけなのでそれが分かればMTAはなんでもいいですね。

## 対処法の検討
海外からのメールのログインをブロックしたいのですが、25番ポートをそのままブロックしてしまうと通常の外部から当サーバーへのメールの到着もブロックしてしまうので、ポートは塞がずにmaillogからloginに成功したものを抽出します。

```
Mar  8 05:00:30 value2 smtp_auth[25237]: SMTP user hoge@hoge.com : logged in from xxx.jp [000.000.000.000]
```

このログイン元のIPアドレスを抽出して海外IPかどうかをGeoIPを使って判別。
ただ、一回でも海外IPでログイン成功したものをブロックしてしまうとgmailからのsmtpログインや海外拠点のメール送信も弾いてしまう恐れがあるので、毎分チェックして、1分以内に規定回数以上のログインがあった場合に弾くことにします。(今回は仮で10回)

GeoIPは今回PHPでやることにしました。
(Pythonでも出来るようだけどpipのバージョンやら何やらで手間掛かりそうだったのでPHPにしました)

最終的には

1. cronで毎分maillogをチェック
2. 過去1分間にログイン成功したIPアドレスとIPアドレスごとの成功回数を取得
3. 規定回数(10回以上)ログインに成功しているもの
4. 海外IPかどうかチェック(GeoIP)
5. 海外IPならiptablesに登録

というフローにしました。
ブロックしたIPアドレスがiptablesに登録しっぱなしなのもあまりよろしくないので、定期的に過去分を削除するようにします。
これは1時間ごとにして、1時間以前のものをiptablesから解除するようにします。

## 手順
### GeoIPのデータを持ってくる
まずPHPでGeoIPの判定ができるようにGeoIPのデータを持ってきます

```
mkdir /usr/local/share/GeoIP/
cd /usr/local/share/GeoIP/
wget http://geolite.maxmind.com/download/geoip/database/GeoLite2-Country.mmdb.gz
gunzip -f GeoLite2-Country.mmdb.gz
wget http://geolite.maxmind.com/download/geoip/database/GeoLite2-City.mmdb.gz
gunzip -f GeoLite2-City.mmdb.gz
```

### PHPのcomposerとgeoipライブラリをインストール
PHPからGeoIPを使えるようにcomposerを使ってgeoipライブラリをインストール

```
curl -sS https://getcomposer.org/installer | php
php composer.phar require geoip2/geoip2:~2.0
```

### GeoIPを使った国名判定スクリプト
IPアドレスを引数に渡すと国名を返すスクリプトの作成
(引数のチェックが甘いですが、今回は他のスクリプトから呼び出すだけなので...)

```/usr/local/share/GeoIP/geoip.php
<?php
    if(count($argv) < 2) {
        exit;
    }
    require_once 'vendor/autoload.php';
    use GeoIp2\Database\Reader;

    $reader = new Reader('/usr/local/share/GeoIP/GeoLite2-City.mmdb');

    $ip_addr = $argv[1];
    $record = $reader->city($ip_addr);
    print($record->country->name);
?>
```

テストで実行してみる

```
/usr/bin/php /usr/local/share/GeoIP/geoip.php 8.8.8.8
United States
```

うまく国判定できました。

### スクリプト本体
スクリプトの本体を作ります。

```/root/bin/mailban.sh
#!/bin/bash

IFS=$'\n'
# ブロックするしきい値。1分間にNUM回以上のログインに成功した場合にブロックする
NUM=10

# 現在の1分にすると、スクリプト実行後から「分」が変わるまでの分が出来ないので、1分前にして60秒丸々取得できるようにする
# maillogでは、日が1桁だと月と日の間に「 」(スペース)が一つ入るので「 %-d」=>「 +%-d」と、「+」を足して正規表現にしている
DATE=`date -d "1 minute ago" +"%b +%-d %H:%M:"`
# iptablesのコメントにセットする登録日時
ADDDATE=`date +"%Y-%m-%d-%H"`

# この1分間の間にログインに成功したIPアドレスとIPアドレスごとの回数を抽出し配列にする
IPLIST=(`egrep "^${DATE}" /var/log/maillog | grep "logged in from" | egrep -o "[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}" | sort | uniq -c | sort -nr`)

for V in "${IPLIST[@]}"; do
    COUNT=`echo ${V} | awk '{print $1}'`
    IP=`echo ${V} | awk '{print $2}'`

    # 規定回数以上だった場合
    if [ ${COUNT} -ge ${NUM} ]; then

        # 自分自身のIPじゃない場合
        if [ "${IP}" != "127.0.0.1" ]; then

            # 国を調べる
            COUNTRY=`/usr/bin/php /usr/local/share/GeoIP/geoip.php ${IP}`

            # 日本じゃない場合
            if [ ${COUNTRY} != "Japan" ]; then

                # iptablesに登録済みかどうか確認する
                MATCHES=`iptables -L -n | grep "create from mailban at" | grep -c ${IP}`

                # 登録済みでない場合
                if [ ${MATCHES} -eq 0 ]; then

                    # iptablesにコメント付きで登録する
                    iptables -A INPUT -s ${IP} -j DROP -m comment --comment "create from mailban at ${ADDDATE}" 
                    echo "${ADDDATE}:Add to iptables. src ${IP}." >> /root/bin/mailban.log
                fi
            fi
        fi
    fi
done

exit 0
```

気をつけなきゃいけないのはiptablesで、iptalesのルールを追加する時に「このスクリプトから追加したもの」と分かるようにしておかないとあとでルールを見返した時に混乱してしまいます。
またあとで自動的にルールを削除する場合も判断しにくくなり、関係ないルールも削除してしまわないようにするためです。
そのためにiptablesのルール追加時にコメントを追加して判定しやすくします
(オリジナルのCHAINでもいいですね)

スクリプトで追加されるルールは下記のようになります
(日付の部分は登録時の「年-月-日-時」で自動的に付加されます)

```
iptables -A INPUT -s xxx.xxx.xxx.xxx -j DROP -m comment --comment "create from mailban at 2019-03-08-11}"
```

iptalbesで見た時は下記のように表示されます

```
iptables -L -n
Chain INPUT (policy ACCEPT)
target     prot opt source               destination
DROP       all  --  xxx.xxx.xxx.xxx         0.0.0.0/0           /* create from mailban at 2019-03-08-11 */
```

あとは1時間毎にこの `create from mailban at` のコメントが入ったルールのみを対象にして削除すればいいです

### cronに登録
あとは作成したスクリプトとiptablesの削除、GeoIPの更新をcronに登録します

**スクリプト本体(毎分)**

```
* * * * * /bin/bash /root/bin/mailban.sh
```

**古いiptablesルールの削除(1時間ごと)**
これにはひと工夫あって、削除するルールを自動的に作るより、 `--line-numbers` を使ってルール番号を表示し、そのルール番号で削除しています。
またルール番号を普通に若い順から消してしまうと一つ消すごとにルールが少なくなり、ルール番号がずれてしまうので、ルール番号を逆順にして5->4->3->2->1と削除するようにしています。
(若い順から1->2->3..としたとき、1を削除したタイミングで2のルールが1に置き換わってしまった後に2のルールを削除してしまう=>つまり元々3だったルールが削除されてしまうのでどんどんずれていく)
基本的に若い番号ほど古いルールなので、現在の時間を除外した結果全てが古いルールで番号がソートされているはずです。
※とはいえiptablesは事故の元なので自己責任でお願いします..

```
0 * * * * /sbin/iptables -L -n --line-numbers | grep "create from mailban at" | grep -v "`date +'%Y-%m-%d-%H'`" | awk '{print $1}' | sort -r | xargs -I{} iptables -D INPUT {}
```

**GeoIPの更新(毎日深夜1時)**

```
0 1 * * * cd /usr/local/share/GeoIP/ && wget http://geolite.maxmind.com/download/geoip/database/GeoLite2-Country.mmdb.gz && gunzip -f GeoLite2-Country.mmdb.gz && wget http://geolite.maxmind.com/download/geoip/database/GeoLite2-City.mmdb.gz && gunzip -f GeoLite2-City.mmdb.gz > /dev/null 2>&1
```

## 動かしてみる

これでしばらく動かしてみて、動きを見ながら規定回数等調整していけばいいと思います。
あとはホワイトリストも作ることが出来ますね。

Python版GeoIPもあるので、この辺全部まとめて1セットにできそう。