Pythonでポートスキャナを作る
はじめに
最近仕事中の雑談で、TCP/IP の勉強として何を作ればいいのかという話になったが、ポートスキャナがいいのではという意見が挙がりました。そういえば、自分はポートスキャナを使ったことはあったが、作ったことはなかったので、これを期に自分で作ってみました。
ポートスキャナのしくみ
今回作ったポートスキャナである pscan のしくみについて説明します。pscan の仕組みは簡単で、与えられた IP アドレスとポート番号レンジに対してアクセスをし、レスポンスが返ってきたら open と表示します。このままではあまりにも簡単なので、もう少し凝って、SYNスキャンを実装しました。
以下に TCP の 3-way handshaking の図を示しますが、SYN-ACK を受け取ったあと RST パケットを飛ばし接続を切るのが SYN スキャンです。3-way handshaking が成功せず、コネクションが確立しないので、サーバ側にはアクセスログが残りません。このことから、SYN スキャンはステルススキャンとも呼ばれています。
(図は Wikipedia より引用)
実装
pscan では raw socket を使います。raw socket は普通の socket とは異なり、TCP/IP のパケット、ひいては Ethernet のフレームまで触ることができます。SYN スキャンを実行する場合、socket が勝手に TCP コネクションを確立すると困るし、TCP ヘッダの flags を弄って SYN フラグを立てる必要があります。これらに対処するためには raw socket を使うしかありません。
pscan は 2 つの raw socket (sender sock, receiver sock) を使って、それぞれ別のスレッドでパケットを送受信します。
import threading import socket from random import randint from collections import defaultdict from pscan.protocol import * class PortScanner: . . . def __init__(self, targetip="127.0.0.1", port=80): self.send_sock = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_TCP) self.recv_sock = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_TCP)
AF_INET は IPv4 のプロトコルファミリーを通信に使用することを意味し、SOCK_RAW は raw socket であることを意味します。 第3引数には IPPROTO_TCP の他にも IPPROTO_RAW などが指定できますが、IPPROTO_RAW は IP ヘッダまで自分で作る必要があるので、面倒くさいです。
これらのソケットに対して、以下のようにデータを送信したり、受信したりします。
# 送信 def packet_send(self): for port in self.ports: # send SYN packet self.send(self.sport, port, "S") self.sport += 1 def send(self, sport, dport, flags): head = TCP(sport=sport, dport=dport, flags=flags) head.update_chksum(saddr=self.myip, daddr=self.targetip) self.send_sock.sendto(head.bytes(), (self.targetip, dport)) # 受信 def packet_recv(self): n_ports = len(self.ports) while n_ports != 0: data = self.recv_sock.recv(65565) ip = IP.load(data[:IP.size()]) tcp = TCP.load(data[IP.size():IP.size()+TCP.size()]) if ip.src != self.targetip or ip.dst != self.myip: continue if tcp.dport < 49152 or tcp.flags != "SA": continue if self.scanned[tcp.sport]: continue # received SYN-ACK packet self.dump_status(tcp.sport, "open") n_ports -= 1 self.scanned[tcp.sport] = True # send RST packet self.send(tcp.dport, tcp.sport, "R")
TCP/IP ヘッダの記述には Python の ctypes モジュールにある BigEndianStructure (ネットワークバイトオーダーはビッグエンディアンなので)を使います。Structure を使うことで、C 言語の構造体を Python で表現することができます。
# in base.py class ProtocolBase(BigEndianStructure): def __init__(self, **kwargs): super().__init__(**kwargs) self.payload = None self.parent = None def bytes(self): me = bytes(self) if self.payload: return me + self.payload.bytes() return me @classmethod def load(cls, data): buffer = io.BytesIO(data) c = cls() buffer.readinto(c) return c # in tcp.py class TCP(ProtocolBase): _fields_ = ( ("sport", c_ushort), ("dport", c_ushort), ("seq", c_uint), ("ack", c_uint), ("_dataofs", c_byte), ("_flags", c_byte), ("window", c_ushort), ("chksum", c_ushort), ("urgptr", c_ushort), ) def __init__(self, **kwargs): self.dataofs = 5 self.window = 8192 self.sport = randint(49152, 65535) # ephemeral port super().__init__(**kwargs)
このようにしておくことで、以下のように bytes と TCP/IP ヘッダ間の変換が楽になります。
# bytes to TCP/IP header data = self.recv_sock.recv(65565) ip = IP.load(data[:IP.size()]) tcp = TCP.load(data[IP.size():IP.size()+TCP.size()] # TCP/IP header to bytes tcp.bytes()
実証
さて、実際にポートスキャンをためしてみましょう。以下のようにまず 8000 ポートでローカルサーバを建てます。
$ python -m http.server 8000 Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...
続いて、tcpdump でパケットキャプチャを開始します。
$ tcpdump -i lo -w dump.pcap
最後にポートスキャンを開始します。
$ pscan -p 8000 Scanning 172.19.0.2... 8000/unknown : open
これで dump.pcap を確認してみると以下のように SYN → SYN-ACK → RST の流れになっていることがわかります。
なぜか RST パケットが 2 つも出現していますが、以下が原因っぽいです。
3hand shakeにて、TCP通信を結ぶときを例に説明する。説明の都合上、Client側をOSとプログラムに分けて記述する。ClientからServerにSYNを送ると、ServerはSYN-ACKを返してくる。しかし、ClientのOSは「TCP通信をかけていない」と認識しているため、SYN-ACKを受け取れず、RSTを返してTCP通信をリセットする。それに遅れてClientのプログラムがACKを返すが、すでにTCP通信はリセット済みである。
まあ、SYN スキャンの場合、SYN-ACK の存在さえ確認できればよくて、RST パケットを 2 回送っても特に問題なさそうなので、今回は特に対処しない方向でいこうと思います。
最後に Google に対するポートスキャンの結果でも載せておきます。
$ pscan -t 172.217.175.67 -p 0-1023 Scanning 172.217.175.67... 110/pop3 : open 143/imap2 : open 80/http : open 443/https : open
おわりに
今までに nmap を使ったこともあったし、SYN スキャンについても raw socket についても知っていたが、いざ自分がポートスキャナを作るとなると以外とハマることがあった。やっぱり知ってるのと作れるのは違うんだなー…