Python2 + Scapyで、Raspberry Pi 2 Model B をNAT/NAPTルータ化してみた #router_jisaku

前回・前々回で、Python2 + Scapyで、Raspberry Pi 2 Model B をブリッジ化してみました。

 
ルーター自作でわかるパケットの流れ」に従い、次はルータを作ります。

ルーター自作でわかるパケットの流れ

ルーター自作でわかるパケットの流れ

 
ただ、書籍ではL2ルータを実装していましたが、NAT/NAPTなルータが欲しかったので、L3ルータを作成してみました。

 
目次

 

環境

NAT/NAPTによるIPアドレス変換があるため、今回は

  • 192.168.10.0/24
  • 192.168.11.0/24

の環境を用意します。

各機器の役割は、ブリッジの時と同じように

  • Mac = HTTPサーバ
  • 無線LANアクセスポイント = スイッチ
  • ラズパイ = NAT/NAPTルータ
  • Windows10 = ルータにぶら下げたクライアント

とします。

 

ネットワーク構成

各機器の接続は、ブリッジの時と同じです。

-----------------------------------------
Mac
(`en0` : WiFi)
-----------------------------------------
|
|
-----------------------------------------
(無線)
無線LANアクセスポイント
(スイッチ)
-----------------------------------------
|
|
-----------------------------------------
(`eth0` : オンボードLANアダプタ)
Raspberry Pi 2 Model B
(`eth1` : 外付けUSB有線LANアダプタ)
-----------------------------------------
|
|
-----------------------------------------
(オンボードLANアダプタ)
Windows10
-----------------------------------------

 

ネットワークアダプタ設定

明示的に設定したネットワークアダプタ設定です。

なお、以降の表記は

とします。

機器 アダプタ IPアドレス DGW DNS
Mac en0 192.168.10.101/24 192.168.10.1 192.168.10.1
無線LAN AP 192.168.10.1/24
ラズパイ eth0 192.168.10.50/24 192.168.10.1 192.168.10.1
ラズパイ eth1 192.168.11.60/24
Windows 192.168.11.201/24 192.168.11.60 192.168.10.1

 
ラズパイ上の状態もこんな感じです。

# インタフェースのIPアドレス
pi@raspberrypi:~ $ ifconfig
eth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 192.168.10.50  netmask 255.255.255.0  broadcast 192.168.10.255
eth1: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 192.168.11.60  netmask 255.255.255.0  broadcast 192.168.11.255

# ルーティング情報
pi@raspberrypi:~ $ route -n
Kernel IP routing table
Destination     Gateway         Genmask         Flags Metric Ref    Use Iface
0.0.0.0         192.168.10.1    0.0.0.0         UG    202    0        0 eth0
192.168.10.0    0.0.0.0         255.255.255.0   U     202    0        0 eth0
192.168.11.0    0.0.0.0         255.255.255.0   U     203    0        0 eth1

 

各機器の構成

こちらもブリッジの時と同じです。

  • Mac OS X 10.11.6
    • 疎通確認のために、PythonでHTTPサーバを立てる
  • Raspberry Pi 2 Model B
    • Raspbian
      • RASPBIAN STRETCH WITH DESKTOP
      • Version: November 2017
    • Python 2.7.13
      • Raspbianにインストールされていたものを使用
    • Scapy 2.3.3
    • 外付けUSB有線LANアダプタ(LUA4-U3-AGT)
    • SSHMacから接続可能
  • Windows10
    • curlをインストール済

 

Scapyの関数を調べる

ルータ化するにあたり、Scapyで使えそうな関数を調べてみます。

 

インタフェースのMACアドレスIPアドレス取得

Scapyを実行する機器のインタフェースからMACアドレスIPアドレスを取得するには、

を使います。

関数の引数には、インタフェース名(下記の例だと eth0 )を渡します。

from scapy.all import get_if_hwaddr, get_if_addr

global_eth_mac_address = get_if_hwaddr('eth0')
global_eth_ip_address = get_if_addr('eth0')

 

パケット中のレイヤの存在確認とレイヤ取得

パケットの各レイヤの存在確認と取得は、パケットの

  • レイヤの存在確認: haslayer()
  • レイヤ取得: getlayer()

を使います。

from scapy.all import conf
from scapy.layers.inet import TCP

s = conf.L2socket(iface='eth0')
p = s.recv()

# IPレイヤ存在確認
has_tcp = p.haslayer(IP)

# IPレイヤ取得
tcp_layer = p.getlayer(IP)

 
なお、getlayer()では、該当レイヤよりも上のレイヤを取得します。

例えば p.getlayer(IP) の場合、IPとその上のTCPを取得します。

# -*- coding: utf-8 -*-
from select import select

from scapy.all import conf
from scapy.layers.inet import IP

while True:
    l2s = conf.L2socket(iface='eth0')
    r, _, _ = select([l2s], [], [])
    for s in r:
        p = s.recv()
        if p and p.haslayer(IP):
            layer = p.getlayer(IP)
            layer.show()

# => show()の結果 
#     ###[ IP ]### 
# version   = 4L
# ihl       = 5L
# tos       = 0x0
# len       = 64
# id        = 37143
# flags     = DF
# frag      = 0L
# ttl       = 64
# proto     = tcp
# chksum    = 0x13b9
# src       = 192.168.10.101
# dst       = 192.168.10.50
# \options \
#     ###[ TCP ]### 
# sport     = 56898
# dport     = http
# seq       = 1880557107
# ack       = 0
# dataofs   = 11L
# reserved  = 0L
# flags     = S
# window    = 65535
# chksum    = 0xa411
# urgptr    = 0
# options   = [('MSS', 1460), ('NOP', None), ('WScale', 5), ('NOP', None), ('NOP', None), ('Timestamp', (591430886, 0)), ('SAckOK', ''), ('EOL', None)]

 

レイヤ属性へのアクセス

上のshow()の結果を見ると、IPレイヤには chksum 属性があります。

アクセスするには、ドット(.)と属性名を使います。

# IPレイヤのチェックサム(chksum) へアクセス
ip_packet.chksum

# TCPレイヤの宛先ポートへのアクセス
tcp_layer = target_packet.getlayer(TCP)
dport = tcp_layer.dport

 

パケットの送信

送信方法は色々とありますが、今回は send() 関数を使います。

send関数は、レイヤ3以上(IPレイヤ)以上のパケットを送信します。その際のレイヤ2はScapyがいい感じに作成します。

send(packet_to_global, verbose=0)

 
なお、引数 verbose は、送信ログをコンソール出力するかを決めます。

デフォルト値は 2 のようで

.
Sent 1 packets.

が出力されます。

verbose=0 の場合、コンソールへ出力しません。

 

チェックサムの再計算

IP・TCPUDPにはチェックサムがあります。

そのため、内容を変更した場合にはチェックサムを再計算する必要があります。

Scapyでの再計算を調べてみると、del文でチェックサムを削除した後でパケットを再生成すれば良いようです。
python - How to calculate a packet checksum without sending it? - Stack Overflow

del ip_packet.chksum
result = IP(str(ip_packet))

 

パケットの情報表示
  • show()
  • summary()
  • mysummary()

があります。

それぞれの違いは以下です。

layer = p.getlayer(IP)
print '---- show() ----'
layer.show()
print '---- summary() ----'
print layer.summary()
print '---- mysummary() ----'
print layer.mysummary()

# => 実行結果
# ---- show() ----
# ###[ IP ]### 
# version   = 4L
# ihl       = 5L
# tos       = 0x0
# len       = 64
# id        = 19636
# flags     = DF
# frag      = 0L
# ttl       = 64
# proto     = tcp
# chksum    = 0x581c
# src       = 192.168.10.101
# dst       = 192.168.10.50
# \options \
#     ###[ TCP ]### 
# sport     = 56968
# dport     = http
# seq       = 2173034460L
# ack       = 0
# dataofs   = 11L
# reserved  = 0L
# flags     = S
# window    = 65535
# chksum    = 0x8485
# urgptr    = 0
# options   = [('MSS', 1460), ('NOP', None), ('WScale', 5), ('NOP', None), ('NOP', None), ('Timestamp', (592296199, 0)), ('SAckOK', ''), ('EOL', None)]
# 
# ---- summary() ----
# IP / TCP 192.168.10.101:56968 > 192.168.10.50:http S
# ---- mysummary() ----
# 192.168.10.101 > 192.168.10.50 tcp

 

RSTフラグの立ったパケットをルータから送信しない方法の調査

今回は厳密なルータを作成するわけではないため、NAT/NAPTした時に発生するRST(Reset)フラグの立ったパケットについては、ルータから送信しないようにします。

これを送信してしまうと、TCPのやり取りが途中で中断されてしまうため、NAT/NAPTがうまく動かないです。
NAT の理解は芋づる式 - インフラまわりのプロになりたい

 
そのため、

os.system('sudo iptables -A OUTPUT -p tcp --tcp-flags RST RST -j DROP')

と、iptablesのルールを追加します。

なお、iptablesのオプションの意味は、

  • -A OUTPUT
    • OUTPUTチェイン(ローカルで生成されたパケットをルーティングの前に変換するためのチェイン)に、規則を追加する
  • -p tcp
  • --tcp-flags RST RST
    • RSTフラグがセットされているTCPパケットに対する処理
  • -j DROP
    • ここまでの規則にマッチしたパケットを破棄する

です。

参考:

 

NATルータの実装

ここまでで調査が終わったため、次はルータを実装していきます。

なお、前回、I/O処理をいろいろと試しましたが、今回はScapyのsniff()関数に合わせて、I/O多重化の select() だけの実装例となります。

また、MacからSSHでラズパイに接続していることもあり、SSHのパケットが入ってきます。動作が遅くなったりするので、SSHパケットの場合は処理しないようにしています。本来は不要な処理ですが...

scapy_nat_router_using_ip_layer.py

# -*- coding: utf-8 -*-
from scapy.all import conf, send, get_if_hwaddr, get_if_addr
from select import select
from scapy.layers.inet import IP, TCP, UDP
import os


def recreate_ip_packet(ip_packet):
    # 各レイヤのチェックサムは、削除することでScapyが再計算してくれる
    # https://stackoverflow.com/questions/5953371/how-to-calculate-a-packet-checksum-without-sending-it
    if ip_packet.haslayer(TCP):
        del ip_packet.getlayer(TCP).chksum
    if ip_packet.haslayer(UDP):
        del ip_packet.getlayer(UDP).chksum
    del ip_packet.chksum

    # 再計算するために、IPレイヤより上層のパケットインスタンスを生成する
    result = IP(str(ip_packet))
    return result


def is_ssh_packet(target_packet):
    if not target_packet.haslayer(TCP):
        return False
    tcp_layer = target_packet.getlayer(TCP)
    return tcp_layer.dport == 22


def nat_router():
    try:
        global_socket = conf.L2socket(iface='eth0')
        local_socket = conf.L2socket(iface='eth1')

        # ルータのインタフェースのMACアドレス・IPアドレスを取得しておく
        global_eth_mac_address = get_if_hwaddr('eth0')
        global_eth_ip_address = get_if_addr('eth0')
        local_eth_mac_address = get_if_hwaddr('eth1')

        # RSTパケットをラズパイから送信すると、TCPが途中で切れてしまうので、送信しないようにする
        os.system('sudo iptables -A OUTPUT -p tcp --tcp-flags RST RST -j DROP')

        # NATテーブル
        nat_table = {}

        while True:
            readable_sockets, _, _ = select([global_socket, local_socket], [], [])

            for s in readable_sockets:
                p = s.recv()

                if not p or not p.haslayer(IP):
                    continue

                # 今回の場合、MacとラズパイをSSHでつないでいることからSSHパケットは除外する
                # 除外しないとパケット量が増えてしまい、curlがタイムアウトしてしまう
                if is_ssh_packet(p):
                    continue

                # ローカルからグローバルへ抜けるパケットが入ってきた場合
                if p.dst == local_eth_mac_address:
                    # IPレイヤのパケットを取得する
                    packet_from_local_to_global = p.getlayer(IP)
                    # レスポンスパケットのIPアドレスを変換するため、
                    # 変換後のIPアドレスをキーとして、変換前のIPアドレスを保管しておく
                    nat_table[global_eth_ip_address] = packet_from_local_to_global.src
                    # 送信元IPアドレスを、ルーターのグローバル側IPアドレスに差し替える
                    packet_from_local_to_global.src = global_eth_ip_address
                    # 送信用パケットを再生成する
                    packet_to_global = recreate_ip_packet(packet_from_local_to_global)
                    # 送信用パケットの概要をコンソールに表示する
                    print p.summary()

                    # send()はLayer3のパケットをいい感じに送信してくれる
                    # verbose=2(デフォルト値)など、1~3の場合、以下が表示される
                    # .
                    # Sent 1 packets.
                    # verbose=0の場合、何も出力されない
                    send(packet_to_global, verbose=0)

                # グローバルからローカルへ抜けるパケットが入ってきた場合
                elif p.dst == global_eth_mac_address:
                    packet_from_global_to_local = p.getlayer(IP)
                    # 変換後のIPアドレスから変換前IPアドレスを取得する
                    local_pc_ip_address = nat_table.get(packet_from_global_to_local.dst)
                    # 念のため、変換できない場合はパケットを送信しない
                    if local_pc_ip_address is None:
                        continue
                    # 宛先IPアドレスを変換前IPアドレスに差し替える
                    packet_from_global_to_local.dst = local_pc_ip_address
                    
                    packet_to_local = recreate_ip_packet(packet_from_global_to_local)
                    print p.summary()
                    send(packet_to_local, verbose=0)

    except KeyboardInterrupt:
        pass


if __name__ == '__main__':
    nat_router()

 
NAT処理は

# ローカルからグローバルへの送信
## NATテーブルへローカルIPアドレスを保持
nat_table[global_eth_ip_address] = packet_from_local_to_global.src
## 送信元IPアドレスを、ルーターのグローバル側IPアドレスに差し替える
packet_from_local_to_global.src = global_eth_ip_address

# グローバルからローカルへの送信
local_pc_ip_address = nat_table.get(packet_from_global_to_local.dst)
## 念のため、変換できない場合はパケットを送信しない
if local_pc_ip_address is None:
    continue
## 宛先IPアドレスを変換前IPアドレスに差し替える
packet_from_global_to_local.dst = local_pc_ip_address

という部分になります。このあと、IPパケットを再生成しています。

今回の通信の始まりはローカル側からの想定なため、上記のような簡易的なものにしてあります。

 

NATの動作確認

ラズパイをNATルータ化

作成したPythonスクリプトを実行し、ルータ化します。

pi@raspberrypi:~/router_jisaku $ sudo python scapy_nat_router_using_ip_layer.py 

 

tcpdumpでのパケット確認

パケットのやり取りを確認するため、ラズパイの別のターミナルで tcpdump を実行しておきます。

pi@raspberrypi:~ $ sudo tcpdump -i eth0 -e port 8000 -nn
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on eth0, link-type EN10MB (Ethernet), capture size 262144 bytes

なお、tcpdumpのオプションの意味は、

  • -i eth0
    • インタフェースeth0に対し、パケット確認する
  • -e
    • リンクレベルヘッダ(MAC)もダンプに表示する
  • port 8000
    • ポート8000番のパケットを確認する
  • -nn
    • ポート番号をプロトコル名に変換しない(ポート番号をそのまま表示する)

です。

 

Windowsからcurlで動作確認

Windowscurlを実行します。しばらくすると、

# curlの実行
>curl http://192.168.10.101:8000
# 実行結果
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
...
</html>

とHTTPレスポンスがありました。

 
HTTPサーバのログを見ると、アクセスがあったようです。

192.168.10.50 - - [06/Jan/2018 23:41:47] "GET / HTTP/1.1" 200 -

 
ラズパイのルータログにも出力がありました。

Ether / IP / TCP 192.168.10.50:64314 > 192.168.10.101:8000 S / Padding
Ether / IP / TCP 192.168.10.101:8000 > 192.168.11.201:64314 SA
Ether / IP / TCP 192.168.10.50:64314 > 192.168.10.101:8000 A / Padding
...

 
また、tcpdumpにも出力されていました。

14:41:46.288642 b8:xx:xx:xx:xx:xx > c4:xx:xx:xx:xx:xx, ethertype IPv4 (0x0800), length 68: 192.168.10.50.64314 > 192.168.10.101.8000: Flags [S], seq 3620418925, win 64240, options [mss 1460,nop,wscale 8,nop,nop,sackOK], length 0
14:41:46.357560 c4:xx:xx:xx:xx:xx > b8:xx:xx:xx:xx:xx, ethertype IPv4 (0x0800), length 66: 192.168.10.101.8000 > 192.168.10.50.64314: Flags [S.], seq 4046793993, ack 3620418926, win 65535, options [mss 1460,nop,wscale 5,sackOK,eol], length 0
14:41:46.907171 b8:xx:xx:xx:xx:xx > c4:xx:xx:xx:xx:xx, ethertype IPv4 (0x0800), length 62: 192.168.10.50.64314 > 192.168.10.101.8000: Flags [.], ack 1, win 256, length 0
...

 
これらより、NATルータ化が成功したと考えられました。

 

NAPTルータの実装

続いて、NAPTルータの実装を行います。

内容はほぼ同じですが、ローカルIPアドレスとポート番号をNAPTテーブルに保存しておくところが異なります。

なお、かなり遅い実装なため、curlタイムアウトする恐れがあります...

 

scapy_napt_router_using_ip_layer.py

# -*- coding: utf-8 -*-
from scapy.all import conf, send, get_if_hwaddr, get_if_addr
from select import select
from scapy.layers.inet import IP, TCP, UDP
import os
import random


def recreate_ip_packet(ip_packet):
    if ip_packet.haslayer(TCP):
        del ip_packet.getlayer(TCP).chksum
    if ip_packet.haslayer(UDP):
        del ip_packet.getlayer(UDP).chksum
    del ip_packet.chksum

    result = IP(str(ip_packet))
    return result


def is_ssh_packet(target_packet):
    if not target_packet.haslayer(TCP):
        return False
    tcp_layer = target_packet.getlayer(TCP)
    return tcp_layer.dport == 22


def napt_router():
    try:
        global_socket = conf.L2socket(iface='eth0')
        local_socket = conf.L2socket(iface='eth1')

        global_eth_mac_address = get_if_hwaddr('eth0')
        global_eth_ip_address = get_if_addr('eth0')
        local_eth_mac_address = get_if_hwaddr('eth1')

        os.system('sudo iptables -A OUTPUT -p tcp --tcp-flags RST RST -j DROP')

        # NAPTテーブル
        napt_table = {}

        while True:
            readable_sockets, _, _ = select([global_socket, local_socket], [], [])

            for s in readable_sockets:
                p = s.recv()

                if not p or not p.haslayer(IP):
                    continue
                if is_ssh_packet(p):
                    continue

                if p.dst == local_eth_mac_address:
                    packet_from_local_to_global = p.getlayer(IP)

                    # すでにNAPTで使用したローカルIP&ポートの場合は、以前のグローバルIP&ポートを使う
                    # IPとポートが変わってしまうと、TCPコネクションがうまくいかず、通信が途切れる
                    if (packet_from_local_to_global.src, packet_from_local_to_global.sport) \
                            in napt_table.values():
                        for global_eth_ip_address, global_port in napt_table.keys():
                            if napt_table[(global_eth_ip_address, global_port)] == \
                                    (packet_from_local_to_global.src, 
                                     packet_from_local_to_global.sport):
                                break
                    else:
                        # まだNAPTで使用していないローカルIP&ポートの場合
                        # NAPTで使う変換後のポートをランダムに取得する
                        # ポートの範囲は、プライベートポート番号とする
                        global_port = random.randint(49152, 65535)
                        # レスポンスパケットのIPアドレス・ポートを変換するため、
                        # 変換後のIPアドレス・ポートをキーとして、変換前のIPアドレス・ポートを保管しておく
                        napt_table[(global_eth_ip_address, global_port)] = \
                            (packet_from_local_to_global.src, packet_from_local_to_global.sport)
                        
                    # 送信元IPアドレスとポートを、ルーターのグローバル側IPアドレスとランダムなポートに差し替える
                    packet_from_local_to_global.src = global_eth_ip_address
                    packet_from_local_to_global.sport = global_port

                    packet_to_global = recreate_ip_packet(packet_from_local_to_global)
                    print packet_to_global.summary()
                    send(packet_to_global, verbose=0)

                elif p.dst == global_eth_mac_address:
                    packet_from_global_to_local = p.getlayer(IP)
                    # 変換後のIPアドレス・ポートから変換前IPアドレスを取得する
                    local_pc_ip_address, local_pc_port = napt_table.get(
                        (packet_from_global_to_local.dst, packet_from_global_to_local.dport))
                    # 念のため、変換できない場合はパケットを送信しない
                    if local_pc_ip_address is None or local_pc_port is None:
                        continue
                    # 宛先IPアドレス・ポートを変換前IPアドレス・ポートに差し替える
                    packet_from_global_to_local.dst = local_pc_ip_address
                    packet_from_global_to_local.dport = local_pc_port

                    packet_to_local = recreate_ip_packet(packet_from_global_to_local)
                    print packet_to_local.summary()
                    send(packet_to_local, verbose=0)

    except KeyboardInterrupt:
        pass


if __name__ == '__main__':
    napt_router()

 

NAPTルータの動作確認

NATの時と同じように

  • ラズパイにて、NAPTルータの起動
  • ラズパイにて、tcpdumpを起動
  • Windowsにて、curlを実行

したところ、うまく動作しました。

HTTPレスポンス

>curl http://192.168.10.101:8000
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
...
</html>

 
HTTPサーバのログにも

192.168.10.50 - - [06/Jan/2018 23:49:06] "GET / HTTP/1.1" 200 -

 
NAPTルータのログ

pi@raspberrypi:~/router_jisaku $ sudo python scapy_napt_router_using_ip_layer.py 
IP / TCP 192.168.10.50:57112 > 192.168.10.101:8000 S / Padding
IP / TCP 192.168.10.101:8000 > 192.168.11.201:64318 SA
IP / TCP 192.168.10.50:57112 > 192.168.10.101:8000 A / Padding
...

 
tcpdumpのログ

14:49:05.588728 b8:xx:xx:xx:xx:xx > c4:xx:xx:xx:xx:xx, ethertype IPv4 (0x0800), length 68: 192.168.10.50.57112 > 192.168.10.101.8000: Flags [S], seq 2576276753, win 64240, options [mss 1460,nop,wscale 8,nop,nop,sackOK], length 0
14:49:05.590540 c4:xx:xx:xx:xx:xx > b8:xx:xx:xx:xx:xx, ethertype IPv4 (0x0800), length 66: 192.168.10.101.8000 > 192.168.10.50.57112: Flags [S.], seq 1977036037, ack 2576276754, win 65535, options [mss 1460,nop,wscale 5,sackOK,eol], length 0
14:49:06.131767 b8:xx:xx:xx:xx:xx > c4:xx:xx:xx:xx:xx, ethertype IPv4 (0x0800), length 62: 192.168.10.50.57112 > 192.168.10.101.8000: Flags [.], ack 1, win 256, length 0

 

参考資料

 

ソースコード

GitHubに上げました。ディレクトscapy_python2/router/ にある

  • scapy_nat_router_using_ip_layer.py
  • scapy_napt_router_using_ip_layer.py

が今回のファイルです。
https://github.com/thinkAmi-sandbox/syakyo-router_jisaku