📘

【Python】Scapyで色んなポートスキャンを実装してみる

11 min read

概要

サーバーに対してポート解放の有無を調査する行為をポートスキャンと呼びます。ポートスキャンは手法が幾つかありますが、この記事はタイトルの通り、Pythonでそれぞれの手法を実装・検証してみたもので、主にScapyというライブラリを使用しています。実際の生パケットの中を覗いたりRFC規格を参照したりしつつ、理解を深めることが目的です(凝り性)。
※なお、nmapという同様のことができるツールもあります。

注意事項!!! ※絶対に読んでね

ポートスキャンは不正アクセス禁止法に抵触する行為ではないと思いますが(法律の専門家ではないため見解は差し控える)、普通は不正アクセスの事前調査と見なされるので、第三者のサーバーに対しては絶対に行うべきではありません。仮にポートスキャンを試す場合でも、自身が所有するPCかサーバーを使用するようお願いします。

当方の実行環境です。

  • macOS
  • Python 3.9
  • Ubuntu Server 20.04LTS (攻撃を受ける側のサーバー)

ポートスキャンの類型

他にもあるようですが、今回取り上げるのは以下。

手法 説明
TCPスキャン 3-way handshakeで接続する。実装が容易。ネットでは実装例としてはよく見る。
SYNスキャン SYNパケットを送信。ステルススキャンの代表格。開いてればSYN+ACKが返る。
FINスキャン FINパケットを送信。但し結果の判断が難しい。
NULLスキャン フラグを何も立てずに送信。結果はFINと同様。
X-masスキャン クリスマススキャン。FIN+PSH+URGパケットを送る。結果はFINと同様。
ACKスキャン ACKパケットを送信。フィルター処理の有無の判別に用いる。
UDPスキャン UDPポートを検出できるが、FWが有効だと判断が難しい。

FIN、NULL、X-masスキャンは一緒のものとして解説しています。

類型ごとの実装例

1つずつ、Pythonプログラムを例示しながら解説してみます。

TCPスキャン

3-way handshakeで接続を試行する方法です。Pythonでの実装はとても簡単で、標準ライブラリsocketだけで作れます。ただ、SYNスキャンなど他の方法に比べ、接続先にログが記録される可能性が高くなる方法のようです。

下は相手先ホスト192.168.1.100のポート80に対し、接続をする例です。

import socket
import os

s = socket.socket()
errno = s.connect_ex(('192.168.1.100', 80))
s.close()

if errno == 0:
    print('port is open')
else:
    print(os.strerror(errno))

.connect_ex()は接続に成功すればゼロを、失敗すればエラーコードを返します(os.strerror()はエラーコードをメッセージに変換する関数)。ちなみに、.connect()関数もほぼ同様の動きをしますが、こちらはエラーコードは返さず、接続に失敗すると例外が発生します。

【補足】connect_ex()、connect()は何をしているのか

さて、Pythonの公式マニュアル(socket.connect()ソケットプログラミング HOWTO)では、.connect()ないし.connect_ex()は単純に「接続する」としか書いてませんが、これは、TCPの3-way handshakeが成功したという意味でよいのでしょうか(まぁ、先にもう説明してしまいましたが)。
一応、実際に確認してみます。

tcpdumpで通信をキャプチャしてみます。

sudo tcpdump port 80 and host 192.168.1.100

s.connect(('192.168.1.100', 80))を実行すると、以下の3つのパケットがキャプチャされました。

Flagsの箇所で、"S"と"."がありますが、前者がSYN、後者がACKを意味します。SYNACK+SYNACKのやり取りが行われていますね。つまり、.connect()による接続は、3-way handshakeを意味しているようです。[1]

ついでに、s.close()の場合も見てみましょう。tcpdumpの結果は以下のようでした。

FIN+ACKFIN+ACKACKとなっています。終了処理はFINFIN+ACKACKだと思っていたのですが(『マスタリングTCP/IP』にもそう書いてある)、最初にFIN+ACKが送られていますね。ちなみに、TCPについてRTC793を見ると、Figure 13.の「Normal Close Sequence」の図では、確かに最初にFIN+ACKが送られています(この辺りの詳細をご存じの方がいたら教えて下さい)。

SYNスキャン

SYNパケットを送りつける方法で、3-way handshakeの途中までを試行するので「half-openスキャン」とも呼ばれます。また、FINスキャンなどと合わせて「ステルススキャン(Stealth scanning)」と呼ばるようです。ポートが開いていればSYN+ACKが、閉じていればRST+ACKが返されます。nmapの標準の手法です。TCP接続確立の必要がないので、その分速そうです。

SYNスキャンをPythonで実装するには、標準ライブラリのsocketだと難しくなりそうなので、Scapyを使用します。Scapyは、パケット操作が簡単にできるライブラリです。Scapyの実行には管理者権限が必要です[2]

Scapyのインストールは以下のコマンドで。

pip install scapy

コードは以下の通りです。

from scapy.all import sr1, IP, TCP

try:
    ip = IP(dst='192.168.1.100')
    tcp = TCP(dport=80, flags='S')
    ret = sr1(ip/tcp, timeout=1, verbose=0)

    print('snd: {0:#010b} ({1})'.format(bytes(tcp)[13], tcp.flags))
    print('rtn: {0:#010b} ({1})'.format(bytes(ret['TCP'])[13], ret['TCP'].flags))

except Exception as e:
    print('Exception: {0}'.format(e))

flags='S'で、SYNフラグを立てます。timeout=1はタイムアウト、verbose=0はメッセージ出力を抑制するオプションです。出力項目としては、送信・受信パケットのフラグ、TCPヘッダーの14バイト目(フラグがセットされる箇所)をビット文字列で出力してみました。
タイムアウト時間内に応答がない場合、retは何もセットされないのでNoneになります。

ポートが開いてると、以下のように出力されます。SYNパケットが送られ、応答としてSYN+ACKが返されていますね。

snd: 0b00000010 (S)
rtn: 0b00010010 (SA)

↓の図はTCPヘッダーの13〜14バイト目の構成図[3]ですが、CWRFINが14バイト目に当たります。ビット文字列を見ると、同じようにセットされていることが分かります。

     0   1   2   3   4   5   6   7   8   9  10  11  12  13  14  15
   +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
   |               |           | N | C | E | U | A | P | R | S | F |
   | Header Length | Reserved  | S | W | C | R | C | S | S | Y | I |
   |               |           |   | R | E | G | K | H | T | N | N |
   +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+

ポートが閉じている場合は、RST+ACKが返されます(↓)。

snd: 0b00000010 (S)
rtn: 0b00010100 (RA)

ちなみに、Firewallが有効な場合は応答パケットは返ってこないと思われます(UbuntuおよびCentOSではそうだった)。

ステルスとは言え、IPSやIDS製品によっては検出・遮断されると思いますので、相手側にバレないわけではありません[4]

FIN、NULL、X-max(クリスマス)スキャン

文字通り、FINスキャンはFINパケットを送りつける方法で、NULLスキャンはフラグを何も立てません。X-masスキャンはFINPSHURGフラグを立てたパケットを送りつける方法です(クリスマスツリーのようにイルミネートするからX-masらしい)。SYNスキャン検出に対する回避手段として用いられるようです。

で、どのようにポートの状態を判断するかと言うと、RFC793で定められた仕様の裏を突くというのが、このやり方のようです。

If the state is CLOSED (i.e., TCB does not exist) then ...(中略)... An incoming segment not containing a RST causes a RST to be sent in response.

要するに、ポートが閉じている場合、やって来たセグメント(TCPにおけるデータの単位)がRSTを含まなければRSTを返答する、とあります。つまり、

  1. FIN(or NULL, X-mas)パケットを送る
  2. RSTが返ればポートは閉じていると判断
  3. 応答がなければポートは開放されていると判断

と判断するものです(後で書くとおり難がありますが)。

実際にやってみましょう。コードは以下の通りで、送り先のサーバーはUbuntu20.04のnginx(port:80)です。便宜上、ufw disableにしてFirewallは無効にしてあります。FINスキャンも、NULLも、X-masも結果は同じなので、例ではFINスキャンのみを挙げています。

from scapy.all import sr1, IP, TCP

try:
    ip = IP(dst='192.168.1.100')
    tcp = TCP(dport=80, flags='F')
    ret = sr1(ip/tcp, timeout=1, verbose=0)

    print('snd: {0:#010b} ({1})'.format(bytes(tcp)[13], tcp.flags))
    print('rtn: {0:#010b} ({1})'.format(bytes(ret['TCP'])[13], ret['TCP'].flags))

except Exception as e:
    print('Exception: {0}'.format(e))

結果は、応答がありませんでした。要するにポートは開いているということになります。

snd: 0b00000001 (F)
Exception: 'NoneType' object is not subscriptable

閉じているポートに対しては、仕様通りRSTを返しました(実際にはRST+ACK)。

snd: 0b00000001 (F)
rtn: 0b00010100 (RA)

ちなみに、Firewallを有効にすると応答は返ってきません。

あと、OSによって応答結果が異なるようです。

手持ちの環境で確認したところ、

  • Windows8と10は、ポートがオープンかクローズかに関係なく、RST+ACKが返される
  • Ubuntu 20.04 LTSは、開いていれば応答なし、閉じていればRST+ACKを返す
  • CentOS 8.3.2011は、開いていても閉じていても何も返さない
  • いずれの環境でも、Firewallを有効にしていればポートの状態に関わらず何も返さない

という結果でした。

TCPスキャンと比較すればステルス性が高そうですが、SYNスキャンでも説明した通りセキュリティ製品によっては検出・遮断がされてもおかしくはないですし、OSによってはRFC793に準拠した動作をしないため、TCPスキャンやSYNスキャンと比較すると結果の判断が難しい方法であるかもしれません。

ACKスキャン

ACKスキャンはポートが状態を確認するというよりは、Firewallなどのフィルター処理の有無を判別するのに用います。

Firewallを無効にしているUbuntuに対して、ACKスキャンをしたのが以下。

from scapy.all import sr1, IP, TCP

try:
    ip = IP(dst='192.168.1.100')
    tcp = TCP(dport=80, flags='A')
    # tcp = TCP(dport=9999, flags='A')
    ret = sr1(ip/tcp, timeout=1, verbose=0)

    print('snd: {0:#010b} ({1})'.format(bytes(tcp)[13], tcp.flags))
    print('rtn: {0:#010b} ({1})'.format(bytes(ret['TCP'])[13], ret['TCP'].flags))

except Exception as e:
    print('Exception: {0}'.format(e))

ポートの状態に関係なく、RSTを返します。

snd: 0b00010000 (A)
rtn: 0b00000100 (R)

Fiwarellを有効にすると、FWが許可してるポートに対してはRSTが返ります(httpは開けてる)。

snd: 0b00010000 (A)
rtn: 0b00000100 (R)

しかし、許可していないポート(9999は遮断)に対しては、応答を返しません。

snd: 0b00010000 (A)
Exception: 'NoneType' object is not subscriptable

ACKを送りつけてRSTが返されれば「FWを通り抜けている可能性がある」ことが分かります(但し、そのポートの解放の有無までは不明)。

UDPスキャン

UDP(DNS、DHCPなど)のポートの状態を確認することができます。但し、完璧な方法ではないようです。

手法としては、

  1. UCPパケットを送りつける
  2. ポートが開いていれば、何も返ってこない
  3. 閉じていれば、ICMPのtype 3(Destination Unreachable: 到達不能)で応答される

ただ、Firewallで遮断されている場合も応答がないので、FWの遮断なのかポートが開いているかの切り分けが困難となります。

実験用に、Ubuntu ServerにUDPパケットを受信するプログラムを作ります。ポート50007を用意して受信を待ちます。

server.py
import socket

s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.bind(("0.0.0.0", 50007))
while True:
    res = s.recvfrom(65565)
    print(res)

以下で実行。

python server.py

クライアント側のプログラムは↓。簡略化して例外処理は省いています。

udp_scan.py
from scapy.all import sr1, IP, UDP, ICMP

ip = IP(dst='192.168.1.100')
udp = UDP(dport=50007)
ret = sr1(ip/udp, timeout=1, verbose=0)

if ret is None:
    print('dest:{0}'.format(ip.dst))
    print('Port is open or FW blocked.')

elif ret.haslayer(ICMP):
    print('desc:{0}'.format(ret[IP].src))
    print('ICMP type:{0}'.format(ret[ICMP].type))
    print('ICMP code:{0}'.format(ret[ICMP].code))

else:
    pass

サーバー側のFirewallを無効にした状態で実行すると、クライアント側は何も応答を受信しません。

dest:192.168.1.100
Port is open or FW blocked.

server.pyはUDPパケットを受信すると、以下のように出力しました。

(b'', ('192.168.1.9', 53))

Firewallを有効にした場合、クライアント側は同じ出力ですが(Port is open or FW blocked.を出力)、当然、サーバー側は何も受信しません。なので、ポートが開いてるかの判断は難しい場合が多そうです。

閉じたポートに対して送信するとどうなるでしょうか。UDPの宛先ポートを変えてみて、

udp = UDP(dport=9999)

すると、送信した側のクライアントに以下の結果が出力されます。

desc:192.168.1.100
ICMP type:3
ICMP code:3

ICMPについて定義しているRFC792を見ると、type 3は"Destination Unreachable"、code 3は"port unreachable"とあります。
ただ、Firewallが有効だと、やっぱり応答は返ってきません。

DHCPサーバー(ポート67)やDNS(53)の検出などに使用できるかもしれませんが(ポート変えてなければ)、Firewallで遮断しているか否かの切り分けが難しいので、少し使い勝手が悪そうな気がします。

他にも、Windowスキャンとか色々あるらしいのですが、この辺りにします。

以上です。

参考

脚注
  1. 環境によってはECNのフラグがセットされることがあるようです(下図)。
    tcpdump
    "E"と"W"はECN(Explicit Congestion Notification)と呼ばれるネットワークの輻輳を通知するためのもので、ここでは詳細は割愛します(私も初めて知りました)。 ↩︎

  2. macOSの場合、root権限でVSCodeを実行するには以下のコマンドで(デバッグ実行できなくなる)。 sudo /Applications/Visual\ Studio\ Code.app/Contents/Resources/app/bin/code ↩︎

  3. RFC3540より抜粋 ↩︎

  4. Fortinetの解説を見る限り、そんな気はします。Fortinet Port Scan ↩︎

Discussion

ログインするとコメントできます