ポートスキャナーの構築

はじめに

「ポートスキャナーの構築」実験へようこそ。

ポートスキャナーは、ターゲットマシン上で開いているポートや実行中のサービスを特定するために使用される、ネットワークセキュリティの基本的なツールです。Nmap のようなツールは非常に強力ですが、その内部動作を理解することは、サイバーセキュリティの専門家やネットワークエンジニアを目指す上で不可欠です。

この実験では、Python を使用して独自の TCP ポートスキャナーをゼロから構築します。まずコマンドライン引数の解析から始め、次にポートが開いているかどうかを確認するためのコアとなるソケット接続ロジックを実装します。その後、ツールを堅牢にするための例外処理を追加し、最後にマルチスレッドを実装してスキャンプロセスを大幅に高速化します。この実験を終える頃には、機能的で高速なポートスキャナーが完成し、Python によるネットワークプログラミングの確かな理解が得られているはずです。

この実験の教育的な目的は、Nmap のような成熟したツールを置き換えることではありません。それらのツールが自動化している「入力処理、接続試行、タイムアウト、結果の解釈、パフォーマンスのトレードオフ」といった要素を明らかにすることです。一度自分で基本的なスキャナーを構築すれば、プロ仕様のスキャナーの仕組みはそれほど謎めいたものではなくなります。

コマンドライン引数の解析

このステップでは、コマンドライン引数を受け取れる Python スクリプトを作成し、ポートスキャナーの基盤を構築します。コマンドライン引数を使用すると、ユーザーはコードを変更することなく、ターゲットの IP アドレスやスキャンしたいポートの範囲を指定できるようになります。

これは、ツールのロジックとユーザー入力を分離する良い例です。優れたセキュリティツールは、コードはそのままに、オペレーターがコマンドラインからパラメーターを変更できるため、再利用性が高くなります。

ここでは、ユーザーフレンドリーなコマンドラインインターフェースを簡単に作成できる、Python の組み込みモジュール argparse を使用します。

まず、プロジェクトディレクトリにいることを確認してください。ここで Python スクリプトを作成・実行します。

cd ~/project

nano テキストエディタを使用して scanner.py という新しいファイルを作成します。

nano scanner.py

以下のコードをエディタにコピー&ペーストしてください。このコードは argparse モジュールをインポートし、ターゲット IP とポート範囲の入力を処理する関数を定義しています。

import argparse
import sys

def get_arguments():
    parser = argparse.ArgumentParser(description="A simple TCP port scanner")
    parser.add_argument("-t", "--target", dest="target", help="Target IP address", required=True)
    parser.add_argument("-p", "--ports", dest="ports", help="Port range to scan (e.g., 1-1024)", default="1-1024")

    options = parser.parse_args()
    return options

if __name__ == "__main__":
    options = get_arguments()

    ## Extract the target and ports from the user's input
    target_ip = options.target
    ports_input = options.ports

    print(f"[*] Target set to: {target_ip}")
    print(f"[*] Port range set to: {ports_input}")

Ctrl+O を押してから Enter を押してファイルを保存します。次のステップに直接進む場合は、nano を開いたまま編集を続けても構いません。ターミナルに戻りたいときだけ Ctrl+X を使用してください。

それでは、Python でスクリプトを実行してテストしてみましょう。ローカルホストの IP (127.0.0.1) と小さなポート範囲 (7990-8010) を渡します。

python3 scanner.py -t 127.0.0.1 -p 7990-8010

以下のような出力が表示されるはずです。

[*] Target set to: 127.0.0.1
[*] Port range set to: 7990-8010

もし -t 引数なしでスクリプトを実行しようとすると、argparse が自動的にエラーメッセージを生成し、正しい使用方法を表示してくれるため、ターミナルからツールを使いやすくなります。

ソケット接続ロジックの実装

このステップでは、ポートスキャナーの核となる機能であるソケット接続を実装します。

プロトコルレベルで見ると、基本的な TCP 接続スキャンは単純です。ターゲットポートへの TCP 接続を試み、成功するかどうかを観察するだけです。この一つのアイデアだけで、多くのポートスキャナーの動作を説明できます。

ソケットとは、コンピュータネットワークを介してデータを送受信するためのエンドポイントです。ポートが開いているかどうかを確認するために、スキャナーはターゲット IP アドレスの特定のポートに対して TCP 接続の確立を試みます。接続が成功すればポートは開いており、失敗またはタイムアウトすればポートは閉じているか、フィルタリングされています。

scanner.py が nano で開いたままの場合は、そのまま編集を続けてください。閉じてしまった場合は、再度開きます。

nano ~/project/scanner.py

socket モジュールを含め、スキャンロジックを追加するようにコードを更新します。ファイルの内容全体を以下のコードに置き換えてください。

import argparse
import sys
import socket

def get_arguments():
    parser = argparse.ArgumentParser(description="A simple TCP port scanner")
    parser.add_argument("-t", "--target", dest="target", help="Target IP address", required=True)
    parser.add_argument("-p", "--ports", dest="ports", help="Port range to scan (e.g., 1-1024)", default="1-1024")
    return parser.parse_args()

def scan_port(ip, port):
    ## Create a new socket using the IPv4 address family and TCP protocol
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    ## Set a timeout of 1 second so the scanner doesn't hang on closed ports
    sock.settimeout(1)

    ## connect_ex returns 0 if the connection was successful
    result = sock.connect_ex((ip, port))
    if result == 0:
        print(f"[+] Port {port} is OPEN")

    ## Always close the socket after using it
    sock.close()

if __name__ == "__main__":
    options = get_arguments()
    target_ip = options.target

    ## Parse the start and end ports from the input string
    start_port, end_port = map(int, options.ports.split('-'))

    print(f"[*] Scanning target {target_ip} from port {start_port} to {end_port}...\n")

    ## Loop through the specified range and scan each port sequentially
    for port in range(start_port, end_port + 1):
        scan_port(target_ip, port)

    print("\n[*] Scan completed.")

Ctrl+O と Enter でファイルを保存します。進捗を確認したい場合を除き、スクリプトのテストはまだ必要ないため、nano を開いたまま次のステップに進んで構いません。

このコードでは、socket.socket(socket.AF_INET, socket.SOCK_STREAM) で TCP ソケットを作成しています。connect_ex() メソッドは、接続失敗時に例外を発生させるのではなくエラーインジケーター(成功時は 0)を返すため、コードを簡潔に保つことができ、スキャナーにとって非常に便利です。

更新したスクリプトをローカルマシンに対して実行してみましょう。実験環境ではポート 8000 で隠れた Web サーバーが実行されているため、それを含む範囲をスキャンします。

python3 scanner.py -t 127.0.0.1 -p 7990-8010

以下のような出力が表示され、ポート 8000 が開いていることが正常に識別されるはずです。

[*] Scanning target 127.0.0.1 from port 7990 to 8010...

[+] Port 8000 is OPEN

[*] Scan completed.

実験環境によっては、出力に他の開いているポートが表示されることもあります。このステップで重要な結果は、ポート 8000 が開いていると表示されることです。

接続例外の処理

このステップでは、例外処理を追加してスキャナーをより堅牢にします。

セキュリティツールは、不完全な条件下(不正な入力、到達不可能なホスト、低速なネットワーク、中断された実行など)で動作することがよくあります。例外処理は装飾ではなく、実際の運用でツールを使用可能にするために不可欠な要素です。

ネットワーク操作は予測不可能です。ユーザーが無効なホスト名を提供したり、ネットワークが切断されたり、ユーザーが Ctrl+C を押してスキャンを途中でキャンセルしたい場合があるかもしれません。適切な例外処理がないと、Python スクリプトはクラッシュし、ユーザーに醜いトレースバックエラーを表示してしまいます。

ここでは try-except ブロックを使用して、これらの特定のエラーをキャッチし、適切に処理します。

scanner.py を編集中の場合は、同じ nano セッションで作業を続けてください。そうでない場合は、再度開きます。

nano ~/project/scanner.py

例外処理を含めるようにコードを更新します。内容を以下の更新版に置き換えてください。

import argparse
import sys
import socket

def get_arguments():
    parser = argparse.ArgumentParser(description="A simple TCP port scanner")
    parser.add_argument("-t", "--target", dest="target", help="Target IP address", required=True)
    parser.add_argument("-p", "--ports", dest="ports", help="Port range to scan (e.g., 1-1024)", default="1-1024")
    return parser.parse_args()

def scan_port(ip, port):
    try:
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.settimeout(1)
        result = sock.connect_ex((ip, port))
        if result == 0:
            print(f"[+] Port {port} is OPEN")
        sock.close()
    except socket.gaierror:
        ## This exception is raised for address-related errors, like an invalid hostname
        print("\n[-] Hostname could not be resolved.")
        sys.exit(1)
    except socket.error:
        ## This catches general socket errors
        print("\n[-] Could not connect to server.")
        sys.exit(1)

if __name__ == "__main__":
    options = get_arguments()
    target_ip = options.target

    try:
        start_port, end_port = map(int, options.ports.split('-'))
    except ValueError:
        print("[-] Invalid port format. Please use start-end (e.g., 1-1024)")
        sys.exit(1)

    print(f"[*] Scanning target {target_ip} from port {start_port} to {end_port}...\n")

    try:
        for port in range(start_port, end_port + 1):
            scan_port(target_ip, port)
    except KeyboardInterrupt:
        ## This catches the Ctrl+C signal from the user
        print("\n[-] Scan canceled by user. Exiting...")
        sys.exit(1)

    print("\n[*] Scan completed.")

Ctrl+O と Enter でファイルを保存します。例外処理をすぐにテストしたい場合は Ctrl+X で終了し、以下のコマンドを実行してください。そうでなければ、そのまま最後のステップに進み、完成したスキャナーを実行する準備ができたときに nano を終了させても構いません。

それでは、例外処理をテストしてみましょう。無効なポート形式でスクリプトを実行し、ValueError を発生させてみます。

python3 scanner.py -t 127.0.0.1 -p 8000,8001

Python がクラッシュする代わりに、クリーンなエラーメッセージが表示されるはずです。

[-] Invalid port format. Please use start-end (e.g., 1-1024)

次に、広い範囲のポートスキャンを開始し、実行中にキーボードで Ctrl+C を押してキャンセルしてみます。

python3 scanner.py -t 127.0.0.1 -p 1-5000

Ctrl+C を押すと、スクリプトが KeyboardInterrupt をキャッチし、正常に終了します。

[*] Scanning target 127.0.0.1 from port 1 to 5000...

[-] Scan canceled by user. Exiting...

もし中断する前にスキャンが完了してしまった場合は、1-9000 のように広い範囲で再度実行し、実行中に Ctrl+C を押してください。

マルチスレッドによる高速化

この最後のステップでは、現在のスキャナーの最大の問題である「速度」を解決します。

現在、スキャナーは一度に 1 つのポートしかチェックしていません。もしポートがフィルタリングされていてタイムアウトに 1 秒かかる場合、1,000 個のポートをスキャンするのに 1,000 秒かかってしまいます。これを解決するために、マルチスレッドを使用します。マルチスレッドを使用すると、プログラムは複数のタスクを同時に実行できます。1 つのポートチェックが終わるのを待ってから次を開始するのではなく、ポートごとに新しいスレッドを生成します。

scanner.py を開いたままにしている場合は、そのまま編集を続けてください。そうでない場合は、再度開きます。

nano ~/project/scanner.py

threading モジュールをインポートし、メインループをスレッドを使用するように更新します。内容を以下の最終バージョンに置き換えてください。

import argparse
import sys
import socket
import threading

def get_arguments():
    parser = argparse.ArgumentParser(description="A simple TCP port scanner")
    parser.add_argument("-t", "--target", dest="target", help="Target IP address", required=True)
    parser.add_argument("-p", "--ports", dest="ports", help="Port range to scan (e.g., 1-1024)", default="1-1024")
    return parser.parse_args()

def scan_port(ip, port):
    try:
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.settimeout(1)
        result = sock.connect_ex((ip, port))
        if result == 0:
            print(f"[+] Port {port} is OPEN")
        sock.close()
    except (socket.gaierror, socket.error):
        pass

if __name__ == "__main__":
    options = get_arguments()
    target_ip = options.target

    try:
        start_port, end_port = map(int, options.ports.split('-'))
    except ValueError:
        print("[-] Invalid port format. Please use start-end (e.g., 1-1024)")
        sys.exit(1)

    print(f"[*] Scanning target {target_ip} from port {start_port} to {end_port}...\n")

    try:
        ## Create a list to keep track of active threads
        threads = []

        for port in range(start_port, end_port + 1):
            ## Create a new thread for each port scan
            thread = threading.Thread(target=scan_port, args=(target_ip, port))
            threads.append(thread)
            thread.start()

        ## Wait for all threads to complete before exiting
        for thread in threads:
            thread.join()

    except KeyboardInterrupt:
        print("\n[-] Scan canceled by user. Exiting...")
        sys.exit(1)

    print("\n[*] Scan completed.")

ファイルを保存 (Ctrl+O, Enter) して終了 (Ctrl+X) します。

threading.Thread(target=scan_port, args=(target_ip, port)) を追加したことに注目してください。これは Python に対し、scan_port 関数をバックグラウンドで実行するように指示しています。thread.join() ループは、メインプログラムがすべてのバックグラウンドスキャンが完了するのを待ってから「Scan completed.」と表示するように保証します。

それでは、広い範囲のポートスキャンを実行してみましょう。以前の逐次実行バージョンよりもはるかに速く完了することがわかります。

python3 scanner.py -t 127.0.0.1 -p 1-9000

出力は以下のようになります。

[*] Scanning target 127.0.0.1 from port 1 to 9000...

[+] Port 8000 is OPEN

[*] Scan completed.

実験環境で実行されているサービスによっては、8000 以外にも開いているポートが表示される場合があります。それは正常な動作です。

これで、Python で高速なマルチスレッド対応 TCP ポートスキャナーを構築できました。

まとめ

この実験では、Python を使用して独自のマルチスレッド対応 TCP ポートスキャナーをゼロから構築しました。まず、argparse モジュールを使用してコマンドライン引数を解析し、ツールを柔軟にする方法を学びました。次に、ソケット接続を実装して開いているポートを検出することで、ネットワークプログラミングの深淵に触れました。ツールを堅牢にするために、ネットワークエラーやユーザーの中断を適切に管理する例外処理を追加しました。最後に、マルチスレッドを実装することで、低速な逐次実行スクリプトを高速な並行スキャナーへと変貌させました。

この実践的な経験は、プロ仕様のネットワークスキャンツールが内部でどのように動作しているかを解明し、ネットワークセキュリティや Python 開発のさらなる探求に向けた強力な基盤を提供します。