/dev/null

脳みそのL1キャッシュ

Pythonでポートスキャナを作る

はじめに

最近仕事中の雑談で、TCP/IP の勉強として何を作ればいいのかという話になったが、ポートスキャナがいいのではという意見が挙がりました。そういえば、自分はポートスキャナを使ったことはあったが、作ったことはなかったので、これを期に自分で作ってみました。

ポートスキャナのしくみ

今回作ったポートスキャナである pscan のしくみについて説明します。pscan の仕組みは簡単で、与えられた IP アドレスとポート番号レンジに対してアクセスをし、レスポンスが返ってきたら open と表示します。このままではあまりにも簡単なので、もう少し凝って、SYNスキャンを実装しました。

以下に TCP の 3-way handshaking の図を示しますが、SYN-ACK を受け取ったあと RST パケットを飛ばし接続を切るのが SYN スキャンです。3-way handshaking が成功せず、コネクションが確立しないので、サーバ側にはアクセスログが残りません。このことから、SYN スキャンはステルススキャンとも呼ばれています。

https://upload.wikimedia.org/wikipedia/commons/thumb/9/9a/Tcp_normal.svg/440px-Tcp_normal.svg.png

(図は Wikipedia より引用)

実装

成果物は以下の GitHub リポジトリにあります。

github.com

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 の流れになっていることがわかります。 f:id:d2v:20200921023904p:plain

なぜか RST パケットが 2 つも出現していますが、以下が原因っぽいです。

3hand shakeにて、TCP通信を結ぶときを例に説明する。説明の都合上、Client側をOSとプログラムに分けて記述する。ClientからServerにSYNを送ると、ServerはSYN-ACKを返してくる。しかし、ClientのOSは「TCP通信をかけていない」と認識しているため、SYN-ACKを受け取れず、RSTを返してTCP通信をリセットする。それに遅れてClientのプログラムがACKを返すが、すでにTCP通信はリセット済みである。

qiita.com

まあ、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 についても知っていたが、いざ自分がポートスキャナを作るとなると以外とハマることがあった。やっぱり知ってるのと作れるのは違うんだなー…

参考文献

manpages.ubuntu.com

e-words.jp