この文書の目的
この文書は SCAPY をインストールして TCP 通信を試してみよう、と思った人向けのものです。というのも、Ping (ICMP Echo) や DNS つまりコネクションレスなパケット操作については SCAPY はシンプルに、予想したとおりに書いて素直に処理できるのですが、TCP についてはいろいろと引っ掛かることが多いからです。
特にこの「準備編」では、TCP のために ACK などフラグを設定したパケットの操作で OS (カーネル)と衝突してしまい、コネクションをリセットされてしまう問題に焦点を当て、ARP Spoofing によってそれに対処する方法についてまとめました。
なお、主に仕組みを理解する過程に沿わせて書いていますので、TCP でデータを取得したいだけの人は、「その3:StreamSocket編」に直行するのが良いです。
前提
以下のことを前提に書いています。
- SCAPY がインストールされている
- TCP について一通りの理解がある・調べることができる
実験:3 Way Handshake 処理を書いてみる
以下に簡単に TCP の 3 Way Handshake 処理を書いてみました。適当な Web サーバに対して、TCP connection を開けてみます。以下のコードでは example.com としていますが、あなたがアクセスできる適当なホストに修正して実行すると良いです。
sport = random.randint(30000,60000)
seq = random.randint(1000,2000)
ip = IP(dst='example.com')
tcp = TCP(sport=sport, dport=80, flags='S', seq=seq)
syn = ip/tcp
syn_ack = sr1(syn)
tcp.seq = syn_ack.ack
tcp.ack = syn_ack.seq + 1
tcp.flags = 'A'
ack = ip/tcp
send(ack)
正常なケース
SCAPY に与えて実行すると、本来ならば以下のような表示になるはずです。赤文字箇所に注目してください。
Begin emission:
Finished sending 1 packets.
..............................................................................................*
Received 95 packets, got 1 answers, remaining 0 packets
.
Sent 1 packets.
つまり SYN 送信、SYN-ACK 受信、ACK 送信、の 3 パケットの動きが見えます。
このときの tcpdump によるモニタリングの結果は以下のようになるでしょう。Flags に続く [ ]
内の記号に注目してください。なお見やすさのために適度に不要な表示を除き、行番号がつけてあります。
1: IP 192.168.1.2.44506 > example.com.http: Flags [S], seq 1080, length 0
2: IP example.com.http > 192.168.1.2.44506: Flags [S.], seq 3401191913, ack 1081, length 0
3: IP 192.168.1.2.44506 > example.com.http: Flags [.], ack 1, length 0
Flags の表示を見れば、正しく SYN, SYN-ACK, ACK の 3 パケットでハンドシェイクが行われていることがわかります。あなたの環境がこのように正しく 3 Way Handshake できるのであれば、この文書をこれ以降読む必要は無いかもしれません。次の「その2:はじめてのGET編」に進んでください。
うまくいかないケース
ところが全く同じコードを与えた私のもう一台の環境(MacOS 10.15.7)では異なる挙動がtcpdump で観察できます。次は RST が手元のマシンのOSから送られてしまう例です。
1: IP 192.168.1.2.57947 > example.com.http: Flags [S], seq 1008, length 0
2: IP example.com.http > 192.168.1.2.57947: Flags [S.], seq 3656582299, ack 1009, length 0
3: IP 192.168.1.2.57947 > example.com.http: Flags [R], seq 1009, length 0
4: IP 192.168.1.2.57947 > example.com.http: Flags [.], ack 1, length 0
5: IP example.com.http > 192.168.1.2.57947: Flags [R], seq 3656582300, length 0
1, 2 パケットめまでは正常なケースでの SYN, SYN-ACK と同様ですが、3 パケットめは OS カーネルから RST フラグ [R]
付きパケットが送られています。4パケットめが一番目の事例の 3 パケットめと同じもの、つまりSCAPYのコードによって送られたACKです。
5パケットめは幾らか複雑で、つまり4パケットめによって相手ホスト example.com 側では通信は既に切断されたものとされ、そこに届いたACKに対して「いまさら」その相手はできない、とRSTで返されたことを示しています。
あなたの環境でもしこの [R]
フラグが出るようであれば、これ以降を読んで対策すると良いです。
TCP reset 問題
この、何故か自分の手元の機械が TCP Connection をリセットしてしまう問題については、 ScapyによるTCP通信 に丁寧に解説されています。
なお、筆者は複数台の MacOS 10.15.7(19H2)マシンを使って実験していたが、このときRSTパケットを送るものと、そうでないものが発生し、何に原因があるか見いだせず混乱した状態に陥りました。(一時期、MacOS の Firewall の ON/OFF によって現象が分かれる状態になったため、これか、と思われたのですが、何度か試すうちに再現性が失われました。)
Workaround - 回避策
上に示した解説ではファイアウォールを用いてOSによる介入を迂回する対策が示されていますが、他にも解決方法はあります。たとえば(上の解説でも参照されている)こちらの What happens if you write a TCP stack in Python? 文書(日本語版)では ARP Spoofing を用いた迂回策が提示されています。
ここでは SCAPY による解決策として、ARP Spoofing 的な方法を試します。Firewallに手を入れるのは恒久的に作用して良いのですが、もともと SCAPY を使ってパケット処理を学ぼうとしているのですから、そのための教材としてこの作業は悪くありませんからね。
- router の IP アドレスは 192.168.1.1
- その先につながれている Mac のアドレスは 192.168.1.2
- この Mac がインターネットのどこかにある example.com と通信する
- router から送られてきた 192.168.1.2 宛てのパケットは、Mac の OS が受信する
これを以下のような構成だと router に信じさせるのです。
- router の先には 192.168.1.180 が居る
- それは Mac の中にある(っぽい)
- この 192.168.1.180 がインターネットのどこかにある example.com と通信する
- router から送られてきた 192.168.1.2 宛てのパケットは、Mac の OS が受信する
- router から送られてきた 192.168.1.180 宛てのパケットは、SCAPY が受信する
つまり router は 192.168.1.180 宛てのパケットを Mac に接続されているネットワーク・インタフェイスに送りつけますが、Mac の OS は、これが「自分宛だ」とは思わず無視します(RSTパケットを送ることもない)。しかし Mac のインタフェイスを見張っている SCAPY はそのパケットを観測することができます。そして SCAPY の sr1( ) のような関数は「自分が送ったパケットへの返答だ」と考えて、これを「受信」処理に入れるのです。
手法の確認
先に乱暴なコードでの解決方法を示します。以下を SCAPY で実行することで、ターゲットのデバイス(router: 192.168.1.1) に、IP アドレス 192.168.1.180 が SCAPY プログラムが動作している手元の機器(Mac)のものだと教えることができます。
send(IP(dst='192.168.1.1', src='192.168.1.180')/ICMP())
time.sleep(0.3)
send(ARP(op='is-at', psrc='192.168.1.180', pdst='192.168.1.1', hwdst="ff:ff:ff:ff:ff:ff"))
動作としては、以下のようになることを期待しています。
- ターゲットに向けて ICMP echo request を出す
- ターゲットに ARP request を出させる
- ターゲットに向けて ARP response を送る
- すると、そこに向けて送られる ICMP echo reply が送り出される
ただし上記の乱暴なコードでは、4 番目のパケットについて受信処理を用意していません。投げっぱなしで、返信があっても捨てています。
以下にモニタリングの結果を示します。例によって見やすさのために適度に不要な表示を除き、行番号がつけてあります。
1: 3c:22:fb:03:55:c6 > c0:25:aa:2a:5e:42, IPv4, length 42: 192.168.1.180 > 192.168.1.1: ICMP echo request, id 0, seq 0, length 8
2: c0:25:aa:2a:5e:42 > ff:ff:ff:ff:ff:ff, ARP, length 60: Request who-has 192.168.1.180 tell 192.168.1.1, length 46
3: 3c:22:fb:03:55:c6 > c0:25:aa:2a:5e:42, ARP, length 42: Reply 192.168.1.180 is-at 3c:22:fb:03:55:c6, length 28
4: c0:25:aa:2a:5e:42 > 3c:22:fb:03:55:c6, IPv4, length 60: 192.168.1.1 > 192.168.1.180: ICMP echo reply, id 0, seq 0, length 8
行番号ごとに動きを説明します。先述の期待したとおりの結果になっています。
- SCAPY によって、source IP アドレスに 192.168.1.180 をつけた ICMP echo request が出る(ひょっとするとこれより前に router 向けの ARP 応答があるかもしれません)
-
- に返信するために、router から 192.168.1.180 向けの ARP リクエストが出る
- (0.3sec 待ってから)SCAPY によって 192.168.1.180 はここ(自身の MAC アドレス)に居るぞ、と ARP response を合成して送り出す
- router はこの返答を信じて、ICMP echo reply を送出する
これが成功してからしばらく(ターゲットのARPキャッシュに情報が残っている間)は、単に ping を送っても返ってきます。これでターゲットの ARP テーブルに載っていることが確実ですね。(下の記述は send() ではなく sr1() を使って正しく受信処理をしています。)
>>> sr1(IP(dst='192.168.1.1', src='192.168.1.180')/ICMP())
Begin emission:
Finished sending 1 packets.
...*
Received 4 packets, got 1 answers, remaining 0 packets
<IP version=4 ihl=5 tos=0x0 len=28 id=52956 flags= frag=0 ttl=64 proto=icmp chksum=0xbec src=192.168.1.1 dst=192.168.1.180 |<ICMP type=echo-reply code=0 chksum=0xffff id=0x0 seq=0x0 |<Padding load='\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' |>>>
>>>
コードの整理
以下にこれまでの内容を整理して、少しましにしたコードを示します。冒頭の、ターゲットに IP アドレスを教え込む処理に注目してください。実行すると、ICMP echo reply を受信したことが以下のメッセージで確認できます。
IP / ICMP 192.168.1.1 > 192.168.1.180 echo-reply 0 / Padding
これで OS によるリセット処理の介入なしに TCP connection を開けて、閉じることができると思います。
def arp_spoof(targetIP, injectIP):
send(ARP(op='is-at', psrc=injectIP, pdst=targetIP, hwdst="ff:ff:ff:ff:ff:ff"))
fakeIP = '192.168.1.180' # fake src IP address
gwaddr = conf.route.route('0.0.0.0')[2] # target gateway address
### inject IP address to target
t = threading.Timer(0.3, arp_spoof, args=(gwaddr, fakeIP, ))
t.start()
res = sr1(IP(dst=gwaddr, src=fakeIP)/ICMP())
print(res.summary())
### open TCP connection
sport = random.randint(30000,60000)
seq = random.randint(1000,2000)
ip = IP(dst='example.com', src=fakeIP) # need to set injected IP
tcp = TCP(sport=sport,dport=80,flags='S',seq=seq)
syn = ip/tcp
syn_ack = sr1(syn)
tcp.seq = syn_ack.ack
tcp.ack = syn_ack.seq + 1
tcp.flags = 'A'
ack = ip/tcp
send(ack)
### INSERT YOUR ACTION HERE
### close TCP connection
tcp.flags = 'FA'
fin_ack = sr1(ip/tcp)
tcp.seq += 1
tcp.ack = fin_ack.seq + 1
tcp.flags = 'A'
send(ip/tcp)
この、TCP コネクションを開いて閉じるまでの間に、あなたのパケット処理を書けば良いのです。
次
「その2:はじめてのGET編」に進んでください。