バックグラウンドノイズを観測する

状況説明

インターネットフェイシングなノードを運用していれば、様々なパケットを受け取ることになる。 普通は自分の建てているDNSサーバやWebサーバへのアクセスが頭に浮かぶけれども、当然そうではないパケットも送られてくる。 それは、例えば、ポートスキャンであったり、アドレス詐称された結果のバックスキャッタであったりするわけだ。 自ノード側ではそんなパケットを受け取るような設定になっていないのが大半なので、ほとんどのパケットは単純に捨てられているわけだが、これを観測したら面白いと思う人も射るようだ。 そういう観測をやるとしたらどうするか、ということを考えてみた。

環境

例によってOSは FreeBSD で、およそ10.3-RELEASEの頃に試している。 FreeBSD には IPFW という名のファイアウォールが同梱されていて、特定のルールにマッチするパケットを特別なネットワークインタフェイスに出力することができるので、これを使ってパケットを誘導して観測しよう。

今回の観測ノードにはネットワークインタフェイスが2個あって、片方を管理用(xn0)に使い、他方を観測用(xn1)に用いることにした。 ネットワークインタフェイスが1個しかない場合には、管理用に使うポート(例えば SSH)を除外しておく必要が生じる。 なお、インタフェイス名からわかるように観測ノードはXen系の仮想マシンであり、有り体に言えばAWS EC2のものである。

構想

目指すところは次の通りである。

  1. xn1に入ってきたパケットをすべて IPFW のログ出力用ネットワークインタフェイス(ipfw0)に出力し、
  2. ipfw0を監視するプログラムがIPヘッダなどを解析してデータベースへ送り込む。

IPFWを有効にする

IPFW のデフォルトルールはdeny allなので、有効にする前に爾後の通信を確保する設定とルールを入れておく。 まず、/etc/ipfw.rulesファイルを新設してallow allのルールを入れておく。(デフォルトで65535番にdeny ip from any to anyが来るのでその直前にallow ip from any to anyを投入する):

ipfw add 65530 allow ip from any to any

また、kernelに読み込まれた IPFW を有効にする設定を/etc/rc.confに入れておく。

firewall_enable="YES"
firewall_script="/etc/ipfw.rules"

この段階ではまだ IPFW がkernelに読み込まれていないので、/boot/loader.confに次のエントリを追記する。:

ipfw_load="YES"
ipfw_nat_load="YES"

これでrebootすれば IPFW が有効でallow allなルールが適用された状態で起動してくるはずである。駄目だった場合にはネットワーク側からはアクセス不能になる場合があるのでよく確認しておくこと。 コンソールからならばログインして復旧も可能だが、AWS EC2の場合にはコンソールが表示専用なので万事窮するので注意。

xn1へのパケットをipfw0へ

さて、これで IPFW が動作しているはずなので、xn1を出入りするパケットを log インタフェイスへ出力する設定を入れる。 まずは/etc/rc.confで

firewall_logif="YES"

を追記・reboot (多分/etc/netstartでもいける)することでログインタフェイスが生えてくる。

$ ifconfig ipfw0
ipfw0: flags=8801<UP,SIMPLEX,MULTICAST> metric 0 mtu 65536
        groups: ipfw

さらに、/etc/ipfw.rulesを書き換えてxn1を出入りするパケットをlogインタフェイスへ出力する設定を入れる。

# clear all existing rules
ipfw -f flush
ipfw table all flush
ipfw -f nat flush
for i in `ipfw nat show config | /usr/bin/cut -d' ' -f3`
do
    ipfw nat $i delete
done

# allow loopback
ipfw add 10 allow all from any to any via lo0

# guards
ipfw add 20 deny ip from any to 127.0.0.0/8
ipfw add 20 deny ip from 127.0.0.0/8 to any
ipfw add 20 deny ip from any to any dst-port 135,137,139,445,1433,1434,2049,3389,5353 in recv xn0
ipfw add 20 deny ip from any to any proto icmp icmptypes 5,9,10,13,14,15,16,17,18 in recv xn0

# monitoring rules
ipfw add 90 count log ip from any to any via xn1

# check state
ipfw add 100 check-state

# egress traffics via xn0 allowed.
ipfw add 110 allow ip from me to any out xmit xn0 keep-state
ipfw add 110 allow icmp from me to any out xmit xn0 keep-state

# egress traffics via xn1 allowed.
ipfw add 110 allow ip from me to any out xmit xn1 keep-state
ipfw add 110 allow icmp from me to any out xmit xn1 keep-state

# listening ports
TRUSTEDIP=120
ipfw table ${TRUSTEDIP} add 1.1.1.1
ipfw add ${TRUSTEDIP} allow ip from table\(${TRUSTEDIP}\) to me dst-port 22 in recv xn0 keep-state

# safe guard
#ipfw add 65530 allow ip from any to any

20行目のルール90番でxn1を出入りするパケットをlogせよという 設定にしているので、ipfw0へ出力されることになる。同じ行でcountとしているのは何らかの動作を指定する必要があるからで、多分allowでも可。 countの場合には29行目からのkeep-stateで戻りパケットが許可されるようにしている。

冒頭8行目までは既存ルールを消去する動きで、11行目ではlo0経由の通信をすべて許可、13行目からの数行で簡単な自ノード防衛ルールを入れている。 25行目から31行目までは自ノード発の通信のパケットをkeep-stateすることで戻りパケットを許可し、また、33行目からの数行では自ノードにあるSSHサーバへの通信を許可し、かつ、戻りパケットをkeep-stateによって許可している。 これらの戻りパケットは、23行目のcheck-stateで受け入れることになる。

この設定だと、直接にxn1を監視しても同じ結果になるが、90番ルールを

WATCHPORT=90
ipfw table ${WATCHPORT} add 22
ipfw table ${WATCHPORT} add 53
ipfw table ${WATCHPORT} add 80
ipfw table ${WATCHPORT} add 161
ipfw table ${WATCHPORT} add 162
ipfw add ${WATCHPORT} count log ip from any to any lookup dst-port ${WATCHPORT} in recv xn1
ipfw add ${WATCHPORT} count log ip from any to any lookup src-port ${WATCHPORT} out xmit xn1
ipfw add ${WATCHPORT} count log icmp from any to any in recv xn1
ipfw add ${WATCHPORT} count log icmp from any to any out xmit xn1

のようにすることで、監視対象となるパケットの種類を IPFW の段階で絞り込むことができるので2段階の設定にしている。 今の IPFW 実装だと(IPアドレスやCIDRブロックではなく)ポート番号のtableを持つことができるはずなのに、実際にはできないようなので全パケットを持っていく形になっている。もちろんtableを使わずに行を増やして指定すれば絞込可能なはずである。 (dst-portを列挙するという記法もできるはずだが、tableにしておくことで必要に応じて増減ができるので、できればtableを使いたい)

ipfw0を観測する

ここまで来ると、:

tcpdump -ni ipfw0

によってxn1を出入りするパケットを観測することができる。 ここでは、python/scapy を使って次のスクリプトを準備し、別途用意した redis へデータ蓄積をしている。

#!/usr/local/bin/python

from scapy.all import *
import redis
import datetime
import re

def main():
    def cb(pkt):
        u = datetime.datetime.utcnow()
        ubin = "{year:04d}{month:02d}{day:02d}{hour:02d}{minute:02d}".format(year=u.year, month=u.month, day=u.day, hour=u.hour, minute=(u.minute - u.minute % 10))
        uts = "{0:0.6f}".format((u - datetime.datetime(1970, 1, 1)).total_seconds())

        if (IP in pkt):
            srcip = re.sub(r'^172.16.xx.yy$', 'gl.ob.al.ip', pkt[IP].src)
            dstip = re.sub(r'^172.16.xx.yy$', 'gl.ob.al.ip', pkt[IP].dst)
            iphead = [srcip, dstip, len(pkt)]

            if (UDP in pkt):
                udphead = ['UDP', pkt[UDP].sport, pkt[UDP].dport]
                udpbinkey = ":".join(map(str, [ubin] + iphead + udphead))
                udptskey = ":".join(map(str, [uts] + iphead + udphead))
                pfpipe.pfadd(udpbinkey, [uts])
                pfpipe.expire(udpbinkey, 86400 * 30)

                if (DNS in pkt):
                    pcappipe.set(udptskey, pkt)       # Ether(a string gotten from redis) will restore the packet.
                    pcappipe.expire(udptskey, 86400 * 30)

                if (pkt[UDP].sport == 123 or pkt[UDP].dport == 123):
                    pcappipe.set(udptskey, pkt)
                    pcappipe.expire(udptskey, 86400 * 30)

                if (pkt[UDP].sport == 1900 or pkt[UDP].dport == 1900):
                    pcappipe.set(udptskey, pkt)
                    pcappipe.expire(udptskey, 86400 * 30)

            elif (TCP in pkt):
                data = map(str, [srcip, dstip, len(pkt), 'TCP', pkt[TCP].sport, pkt[TCP].dport])
                pfpipe.pfadd(":".join([ubin] + data), ":".join([uts] + data))
                pfpipe.expire(":".join([ubin] + data), 86400 * 30)
            elif (ICMP in pkt):
                data = map(str, [srcip, dstip, len(pkt), 'ICMP', pkt[ICMP].type, pkt[ICMP].code])
                pfpipe.pfadd(":".join([ubin] + data), ":".join([uts] + data))
                pfpipe.expire(":".join([ubin] + data), 86400 * 30)
            else:
                data = map(str, [srcip, dstip, len(pkt), 'IP'])
                pfpipe.pfadd(":".join([ubin] + data), ":".join([uts] + data))
                pfpipe.expire(":".join([ubin] + data), 86400 * 30)


        try:
            pfpipe.execute()
            pcappipe.execute()
        except:
            print "exception"
            print iphead, pkt
            print "exception"

        return

    pfpool = redis.ConnectionPool(host='localhost', port=6379, db=0)
    pfpipe = redis.Redis(connection_pool=pfpool, charset=None).pipeline()

    pcappool = redis.ConnectionPool(host='localhost', port=6379, db=1)
    pcappipe = redis.Redis(connection_pool=pcappool, charset=None).pipeline()

    sniff(prn=cb, iface='ipfw0', store=0)

if "__main__" == __name__:
    main()

ただし、例えばDNSパケットなどで非正規なフォーマットのパケットを読み込むと、このスクリプトは異常終了するようである。 対策コードを入れるか、あるいはIP/UDP/TCP/ICMPまでの解析に留めるか、何らかの対処が必要であろう。

備考

  • 2016/Dec/14 ごろ書いた。