PythonでTCPスタックを記述するとどうなる?

Hacker School在籍中、ネットワーキングの理解をより深めたいと思い、小規模なTCPスタックを書いてみようと思い立ちました。個人的には、C言語よりもPythonの方になじみがありましたし、その頃ちょうど、パケット送信を非常に簡単にするscapyネットワーキングライブラリも見つけたところでした。

そんなわけで、teeceepeeを書き始めました。

基本的な構想は次のとおりです。

  1. TCPパケットを送信可能にするRaw socketを開く
  2. google.comを取得するためにHTTP要求を送る
  3. 応答を取得しパースする
  4. 成功を祝う

適切なエラー処理などについてはさほどの注意も払わず、ただただウェブページを取得し、勝利を宣言しようと思っていました(^_^)

ステップ1:TCPハンドシェイク

手始めは、GoogleとのTCPハンドシェイクです(以下は必ずしも正しく動作しませんが、原理は示します)。各行にはコメント文を付けました。

TCPハンドシェイクの実行手順:

  • me(自分):SYN
  • Google:SYNACK
  • me(自分):ACK

とてもシンプルですよね。実際にコードにしてみると次のようになります。

# My local network IP
src_ip = "192.168.0.11"
# Google's IP
dest_ip = "96.127.250.29"
# IP header: this is coming from me, and going to Google
ip_header = IP(dst=dest_ip, src=src_ip)
# Specify a large random port number for myself (59333),
# and port 80 for Google The "S" flag means this is
# a SYN packet
syn = TCP(dport=80, sport=59333,
          ack=0, flags="S")
# Send the SYN packet to Google
# scapy uses '/' to combine packets with headers
response = srp(ip_header / syn)
# Add the sequence number 
ack = TCP(dport=80, sport=self.src_port,
          ack=response.seq, flags="A")
# Reply with the ACK
srp(ip_header / ack)

シーケンス番号?

このシーケンス番号は何なのでしょうか。TCPにおいて重要な点は、一部のパケットが消えた場合にパケットを再送信できるようにすることです。シーケンス番号により、パケットが消えたかどうかを確認することができるようになります。たとえば、Googleが4パケットを送ってきたとしましょう。サイズはそれぞれ110、120、200、そして500バイトです。最初のシーケンス番号を0とすると、パケットの各番号は0、110、230、そして430となります。

ここで、仮にシーケンス番号が2000で100バイトのパケットが突然送られてきたら、それはパケットが消えたということ意味します。なぜなら、次の番号は930であるべきですからね。

こちら側でパケットが消えたということをGoogle側はどう把握するのでしょうか?Googleからパケットを受信する度に、こちらはACKを送る必要があります(シーケンス番号230のパケットを受け取ったよ。ありがとう!)。もしGoogleが、このACKを受け取らなかった場合、Googleはパケットを再送信するのです。

TCPプロトコルは、極度に複雑で、その中にはありとあらゆるレート制限の論理が含まれています。しかし、この投稿では、それらについて詳しく触れるようなことはしませんので、TCPが複雑だということだけを覚えておいていただければ結構です。

SYNパケットがシーケンス番号にどのように作用するか、などを含め、より詳しい情報を知りたい場合、個人的にはUnderstanding TCP sequence numbersが分かりやすくてお勧めです。

ステップ2:問題発生。既にTCPスタックが存在した

上記のコードを実行すると、問題が発生しました。うまく動作しないのです。

しかも予想外の挙動です。全く応答を得ることができません。私はWireshark(パケットを監視できるすばらしいツール)を使って、内容を解析してみました。

me: SYN
google: SYNACK
me: RST

おかしいですね。RSTパケットなんて送った覚えはありません。RSTとは「接続停止。終了」の意味です。私のコードにそんな命令はありません。

その時に脳裏をかすめたのが、自分のコンピュータにはTCPスタックが既に存在している、という事実でした。つまり、どういうことが実際に起こっていたかというと:

my Python program: SYN
google: SYNACK
my kernel: lol wtf I never asked for this! RST!
my Python program: ... :(

カーネルを回避するには、どうしたらいいでしょうか?このことをJari Takkalaに相談すると、彼はARPスプーフィングを提案してくれました。ARPスプーフィングを使えば、異なるIPアドレス(たとえば192.168.0.129のように)があると見せかけることができます。

これを経て、新たな交信は以下のようになりました。

e: hey router! send packets for 192.168.0.129 to my MAC address
router: (does it silently)
my Python program: SYN (from 192.168.0.129)
google: SYNACK
kernel: this isn't my IP address! <ignore>
my Python program: ACK YAY

今度は無事に動作しました。すばらしい。パケットが送信できるようになり、カーネルの干渉なしに応答を得られるようになったのです。やりましたね。

ステップ3:ウェブページを取得する

このステップはある意味、中断のステップです。ここで、苛立たしい数多くのバグを修正し、Googleがhttp://google.comのHTMLを送ってこないようにします。最終的に、私は全てのバグを修正し、この戦いに勝つことに成功しました!

やらなければならないことは:

  • HTTPのGET要求を含むパケットをまとめる
  • 1つだけでなく、多数の応答パケットに対応できるようにする
  • シーケンス番号によるバグフィクスに十分な時間をかける
  • 適切に通信を終了できるようにする

ステップ4:Pythonの遅さを実感する

全てが動作するようになった段階で、またWiresharkを使い、パケット交信の内容を調べてみました。詳しくは以下をご覧ください。

me/google: <tcp handshake>
me: GET google.com
google: 100 packets
me: 3 ACKs
google: <starts resending packets>
me: a few more ACKs
google: <reset connection>

Googleからのパケット(P)とこちらからのACK(A)のシーケンスは、「P P P A P P P P P A P P A P P P P A」のような感じです。Googleからのパケットは、私のプログラムがそれに応答しACKを送信するよりも、はるかに速く送られてきていますね。面白いことに、Googleのサーバは、こちらがACKを送らないことをネットワークの問題か何かと勘違いしてしまったようです。

そして最終的には、恐らく通信に問題があると判断され、リセットされてしまいました。

でも、問題だったのは通信ではありません。私のプログラムの応答速度だったのです。Pythonのプログラムは、ミリ秒単位でパケットに応答するにはあまりに遅すぎました。

(後記:この認識は、どうやら誤りのようです(^_^) この時の実際の原因として考えられるものが、こちらで議論されています。)

人生の教訓

実際に業務用のTCPスタックを書く場合、Pythonはお勧めしません(驚きですが)。また、TCPの仕様は本当に複雑ではありますが、仮に実装が完全ではなかったとしても、サーバからの応答は得ることができます。

私も自分の作ったものが動作したことには満足しています。ARPスプーフィングは非常に厄介でしたが、私はそれを使ってcurlのバージョンを作りました(ただし、25%の確立でしか動作しません)。もし、そのコードを知りたい場合はhttps://github.com/jvns/teeceepee/でご覧ください。

今回の試みは、たとえばC言語のような全うな言語でTCPスタックを記述するよりも、はるかに楽しく示唆に富んだものだったと思います(^_^)