🕸️

Scapyで学ぶパケットの世界:初心者が実際に試しながら理解してみた

に公開

パケットの中身で遊ぶ

ネットワークの各層の働きについて概念的には理解できるけど、今一つ実態がぼんやりして理解が進まないなぁというときにScapyというツールに出会いました。Scapyを使えば、自分の手で「パケットを作る」「観察する」「経路をたどる」ことができる...らしい。が、パケットを観察して一体何が分かるのかがよく分からなかったので試してみました。

私はPythonの仮想環境でScapyを導入し、ping → キャプチャ → traceroute → ARPスキャンまで試してみました。ここでは、私が感じた疑問や「なるほど」と思った発見をまとめます。

Scapyって何?

ScapyとはPythonで使える パケット操作ライブラリ です。

ネットワークパケットを:

  • 作る
  • 送る
  • キャプチャする(読む)
  • 解析する

ができる便利ツールです。

よく使うクラス

  • IP() : IPヘッダ
  • TCP() : TCPヘッダ
  • UDP() : UDPヘッダ
  • rdpcap() : pcapファイル読み込み
  • wrpcap() : pcapファイル書き出し

補足

パケットをGUIで見る場合、Wiresharkというものもあります。どちらもパケットを扱うツールでpcapファイル(パケットキャプチャファイル)を読み書きできます。

Wiresharkは:

  • GUIで操作
  • 人間が見やすい形にデコードして表示
  • クリックやフィルタで分析するのが得意

**Scapyは:

  • Pythonライブラリ
  • プログラムでパケットを作成・解析・送信できる
  • 自動処理や実験に強い

という特徴があります。以前Wiresharkでパケットを見たこともあったけど、その時はよく分からず挫折してしまった苦い過去もあります。

1. パケットって何?

パケットはその名の通りネットワークの上の小包みたいなもの。通信の「小さな断片」です。ネットワークの通信は大きなデータを小さなパケットに分割して送ります。

改めてOSI参照モデルのL2,L3,L4のざっくりしたおさらいです。

  • Ethernet (L2)
    • LAN内の宅配便屋さんの住所ラベル(MACアドレス)
    • 同じネットワークの中で「どの端末に届けるか」を決める
    • ルータを越えるとMACは書き換わる
  • IP (L3)
    • インターネット世界の住所(IPアドレス)
    • 宛先の国や都市を決める感じ
    • 経路(ルーティング)を通じて遠くまで届ける
  • TCP (L4)
    • 信頼できる会話のルール
    • コネクションを作る(3ウェイハンドシェイク)
    • データに番号をふる(シーケンス番号)
    • 届いたか確認する(ACK)
    • 抜けたら再送する

ethernetは自分のLAN内探索専用で、見つからないなら次のルータまでしか届けられない近距離専用通信なのです。でもその中にある荷物のラベル(IP)は一貫してsrc/dstを記載しているから届けられる。だから、LANを出ても最終的なIPラベルを頼りに、ルータをバトンのように渡り歩いて相手にパケットが届くわけです。

そしてScapyの守備範囲は下記の感じ:

  • L2 (データリンク層)
    • Ethernetフレーム作成
    • MACアドレス指定
  • L3 (ネットワーク層)
    • IPパケット生成
    • IPヘッダ操作
  • L4 (トランスポート層)
    • TCP/UDP/ICMPヘッダ操作
    • ポート番号指定
  • L5 (セッション層相当)
    • アプリ層に近いプロトコルを作ることも可能
    • 明確にL5範囲もできるとはされていない?

Ethernet (L2), IP (L3), TCP/UDP/ICMP (L4) は IETFやIEEEが標準化 していて、ヘッダ形式やフィールドの位置が明確に決まっています。Scapyはその仕様通りにパケットを組み立て/解析できます。

でもセッション・プレゼンテーション・アプリ層は「アプリケーションごとに独自」な部分が多い。標準化はあるけど多様で複雑。Scapyには一部だけサポートありなので、メインの範囲ではないのかな。(このあたり、実際どこまで出来るのかは今回は踏み込んで正直理解できていません)

上記の説明の通り、1パケットだけ見ても「ほんの一部」しかわからないことが多いです。でも、その1つのパケットだけでも意外と面白い発見がありました。

summary()

では、さっそくScapyでパケットをキャプチャするtest_summary.pyを実行してみましょう。

from scapy.all import sniff

# 10個だけキャプチャ
packets = sniff(count=10)

# 簡単に表示
packets.summary()

sniff() は 生パケットを直接読む処理なので、Linux では root権限 が必要です。今回はお試しなのでsudoで権限を与えて実行。

sudo /home/user/sandbox/network/venv/bin/python /home/user/sandbox/network/test_summary.py

Ether / IP / TCP 192.0.2.25:46924 > 203.0.113.4:https S
Ether / IP / TCP 203.0.113.4:https > 192.0.2.25:46924 SA
Ether / IP / TCP 192.0.2.25:46924 > 203.0.113.4:https A
Ether / IP / TCP 192.0.2.25:46924 > 203.0.113.4:https PA / Raw
Ether / IP / TCP 203.0.113.4:https > 192.0.2.25:46924 A / Raw
Ether / IP / TCP 192.0.2.25:46924 > 203.0.113.4:https A
Ether / IP / TCP 192.0.2.25:46924 > 203.0.113.4:https PA / Raw
Ether / IP / TCP 203.0.113.4:https > 192.0.2.25:46924 PA / Raw
Ether / IP / TCP 203.0.113.4:https > 192.0.2.25:46924 PA / Raw
Ether / IP / TCP 192.0.2.25:46924 > 203.0.113.4:https A

最初の10件のパケット概要が確認できました。

これは接続の開始(ハンドシェイク)から実際のデータやりとりまでの流れを簡潔に表した一覧になっています。

  • S = SYN
  • SA = SYN+ACK
  • A = ACK
  • P = PSH
  • Raw = ペイロード有り

流れはS → SA → A(3ウェイハンドシェイク)→ その後データ(PA / Raw など)

  1. 192.0.2.25:46924 > 203.0.113.4:https S
    クライアント → サーバへ SYN

  2. 203.0.113.4:https > 192.0.2.25:46924 SA
    サーバ → クライアントへ SYN+ACK

  3. 192.0.2.25:46924 > 203.0.113.4:https A
    クライアント → サーバへ ACK
    → ここまでが 3-way ハンドシェイク

その後の PA / Raw はデータ送受信(Push + ACK + 生データあり)。 A だけは ACK 応答だけのパケット。テキストで解説されていた3ウェイハンドシェイクが、実際にキャプチャで確認できました。

show()

先ほど取り出したパケットの先頭をもう少し詳しく見てみます。

packets[0].show()
###[ Ethernet ]###
  dst       = 00:11:22:33:44:55
  src       = 66:77:88:99:aa:bb
  type      = IPv4
###[ IP ]###
     version   = 4
     ihl       = 5
     tos       = 0x0
     len       = 60
     id        = 65307
     flags     = DF
     frag      = 0
     ttl       = 64
     proto     = tcp
     chksum    = 0x6ffc
     src       = 192.0.2.25
     dst       = 203.0.113.4
     \options   \
###[ TCP ]###
        sport     = 46924
        dport     = https
        seq       = 2126599486
        ack       = 0
        dataofs   = 10
        reserved  = 0
        flags     = S
        window    = 64860
        chksum    = 0xcbd2
        urgptr    = 0
        options   = [('MSS', 1380), ('SAckOK', b''), ('Timestamp', (3644677925, 0)), ('NOP', None), ('WScale', 7)]

これは TCP 3ウェイハンドシェイクの最初のSYNパケットです。次にサーバから SYN+ACK が返って、クライアントが ACK を返すと接続成立です。

  • Ether:src/dst は送受信のMAC
  • IP:src/dst は送受信のIP、ttl 生存時間、DF は断片化禁止
  • TCP
    • sport/dport:送受信ポート(https=443)
    • seq:このパケットの初期シーケンス番号
    • ack:SYNなので0
    • flags:S(SYN)
    • window:受信ウィンドウサイズ
    • options:MSS 最大セグメント長、SAckOK SACK可、Timestamp 時刻印、WScale ウィンドウ拡張

※ 192.0.2.25:46924 → 203.0.113.4:443がクライアント→サーバ通信です。

TCPの項目の意味はこんな感じです。

  • flags = S → SYN フラグが立っている。最初の「接続していい?」の合図。
  • seq = 2126599486 → クライアントが選んだ初期シーケンス番号。この番号を基準にサーバとやりとりが進む。
  • ack = 0 → まだ応答を期待していない。
  • sport = 46924 / dport = https(443) → クライアントがランダムに選んだ送信元ポート 46924 → サーバの HTTPS ポート 443 へ。

あくまでethernetはLAN内の端末に対して送るため、IPはネット世界を送るため、TCPは信頼のためであることが分かりますね。これがちゃんとスタックされて1つのパケットに格納されておりました。

2. PingをScapyで作って送る

あれ?ちゃんとネットワークの接続ができているかな?という場面でお世話になっているPingもScapyでやってみましょう。

from scapy.all import IP, ICMP, sr1

# 1. パケットを作る
pkt = IP(dst="8.8.8.8")/ICMP()

# 2. パケットの中身を確認
print("=== 送信パケット ===")
pkt.show()

# 3. 実際に送って応答を受け取る
ans = sr1(pkt, timeout=2)

# 4. 応答パケットの中身を確認
if ans:
    print("\n=== 応答パケット ===")
    ans.show()
else:
    print("No reply")

出力結果:

=== 送信パケット ===
###[ IP ]###
  version   = 4
  ihl       = None
  tos       = 0x0
  len       = None
  id        = 1
  flags     = 
  frag      = 0
  ttl       = 64
  proto     = icmp
  chksum    = None
  src       = 192.0.2.25
  dst       = 8.8.8.8
  \options   \
###[ ICMP ]###
     type      = echo-request
     code      = 0
     chksum    = None
     id        = 0x0
     seq       = 0x0
     unused    = b''

Begin emission
.
Finished sending 1 packets
*
Received 2 packets, got 1 answers, remaining 0 packets

=== 応答パケット ===
###[ IP ]###
  version   = 4
  ihl       = 5
  tos       = 0x0
  len       = 28
  id        = 0
  flags     = 
  frag      = 0
  ttl       = 114
  proto     = icmp
  chksum    = 0x6984
  src       = 8.8.8.8
  dst       = 192.0.2.25
  \options   \
###[ ICMP ]###
     type      = echo-reply
     code      = 0
     chksum    = 0xffff
     id        = 0x0
     seq       = 0x0
     unused    = b''

解説

送信パケット(echo-request)

  • IPヘッダ
    • src = 192.0.2.25(手元のPC)
    • dst = 8.8.8.8(Google DNS)
    • proto = icmp
  • ICMPヘッダ
    • type = echo-request(Pingの「お願い」)
    • id / seq = 0x0(識別用番号)

応答パケット(echo-reply)

  • IPヘッダ
    • src = 8.8.8.8(相手から返ってきた)
    • dst = 192.0.2.25(自分のPCへ返送)
    • ttl = 114(途中のルータを何個か通った)
  • ICMPヘッダ
    • type = echo-reply(Pingの「返事」)
    • id / seq = 0x0(リクエストと同じ → 正しく対応している証拠)

コードにある/ は「レイヤーを重ねる」という記法です。Ether/IP/TCPを包んでいくイメージ。echo-request を送ると、ちゃんと echo-reply が返ってきて感動。また応答パケットのttlを見ると114という数値あり。これもテキストでは読んだけど、実際、なんで114が返ってきたんだろうという疑問がわきました。

TTLとは

Time To Live(生存時間)

  • IPパケットがルータを通るたびに 1 ずつ減るカウンタ
  • 0 になったらパケットは破棄される(ループ防止用)

ここでの ttl = 114:

  • Googleのサーバ(8.8.8.8)は、たぶん 初期値 128 でパケットを送信している
  • 手元のPCに届いたとき 114 になっている
  • → 128 - 114 = 14個のルータを通過した と推測できる

注意点

  • TTLの値そのものが「ルータの数」ではない
  • 初期値 − 受け取った値(ttl)= 通過したルータ数
  • Windows系は初期128、Linux/Unix系は64、Ciscoは255が多い

こうやって通過したルータ数(ホップ数)って分かるんですね。

3. Tracerouteで経路を追う

次に traceroute(Scapyでもできる)を試してパケットの経路を見てみましょう。
Scapyには traceroute() があって、Google DNS や Yahoo Japan に試してみました。

from scapy.all import traceroute
import socket

# 宛先
target = "8.8.8.8"

# 1回だけ試行する(retry=0)
ans, unans = traceroute([target], maxttl=30, dport=80, retry=0, verbose=0)

print(f"=== Traceroute to {target} ===")
for snd, rcv in ans:
    hop = snd.ttl
    ip = rcv.src
    try:
        host = socket.gethostbyaddr(ip)[0]
    except socket.herror:
        host = "?"
    print(f"{hop:2d}  {ip:15s}  {host}")

出力結果:

=== Traceroute to 8.8.8.8 ===
 1  172.19.32.1      LAPTOP-XXXX.EXAMPLE.net
 2  10.0.0.1         ?
 4  96.xxx.xxx.xxx  router1.isp.example.net
 5  68.xxx.xxx.xxx  router2.isp.example.net
 6  96.xxx.xxx.xxx  router3.isp.example.net
 8  96.xxx.xxx.xxx  router4.isp.example.net
  • 1: 172.19.32.1 → 手元のLANのゲートウェイ(最初のルータ)
  • 2: 10.0.0.1 → プロバイダ側のプライベートIPルータ(NATやCMTSなど)
  • 4, 5, 6, 8 → その先のISPの中継ルータ。96.x.x.x や 68.x.x.x が見えてます(多分Comcast系)
  • (3,7) → 応答しなかったルータで、tracerouteではよくあることらしい。
    • 8.8.8.8 → 最終目的地 Google Public DNS

最後に 8.8.8.8 が出ているので、Google DNS まで到達できたことが確認できました。

これは「手元のPC → ローカルゲートウェイ → ISPルータ → インターネットの経路 → Google DNS」までの経路図です。TTLでホップを1ずつ増やして、各地点のルータが返したICMP Time Exceededを拾っているわけです。

Yahoo Japanへのtraceroute

さて、先ほどはアメリカ国内で完結していましたが、Tracerouteの宛先を www.yahoo.co.jp にすれば、経路がアメリカから日本まで伸びていくのが見えます。

from scapy.all import traceroute
import socket

# 宛先を Yahoo Japan に変更
target = "www.yahoo.co.jp"

# 1回だけ試行する(retry=0)
ans, unans = traceroute([target], maxttl=30, dport=80, retry=0, verbose=0)

print(f"=== Traceroute to {target} ===")
# TTL順に並べると見やすい
for snd, rcv in sorted(ans, key=lambda x: x[0].ttl):
    hop = snd.ttl
    ip = rcv.src
    try:
        host = socket.gethostbyaddr(ip)[0]
    except socket.herror:
        host = "?"
    print(f"{hop:2d}  {ip:15s}  {host}")

出力結果:

 1  172.19.32.1      LAPTOP-EXAMPLE.mshome.net
 2  10.0.0.1         ?
 4  198.51.100.1     router1.isp.example.net
 5  198.51.100.2     router2.isp.example.net
 6  198.51.100.3     router3.isp.example.net
 7  198.51.100.4     router4.isp.example.net
 8  198.51.100.5     router5.isp.example.net
 9  198.51.100.6     router6.isp.example.net
10  198.51.100.7     router7.isp.example.net
11  198.51.100.8     router8.isp.example.net
12  198.51.100.9     router9.isp.example.net
13  198.51.100.10    router10.isp.example.net
14  198.51.100.11    router11.isp.example.net
15  198.51.100.12    router12.isp.example.net
16  198.51.100.13    router13.isp.example.net
17  198.51.100.14    router14.isp.example.net
18  198.51.100.15    router15.isp.example.net
19  198.51.100.16    router16.isp.example.net
20  198.51.100.17    router17.isp.example.net
21  198.51.100.18    router18.isp.example.net
22  198.51.100.19    router19.isp.example.net
23  198.51.100.20    router20.isp.example.net
26 106.187.13.25     ?
27 27.86.xx.xxx      ?
28 27.86.xx.xxx      ?
36 182.22.xx.xxx     ? 
37 182.22.xx.xxx     ?
38 182.22.xx.xxx     ?

以下同じIPが続く...
(xxxはマスクです)

経路解析

  • 〜23 まで:isp内。上記は置き換えしているが実際のキャプチャデータではシカゴ→デンバー→サンノゼを通って西海岸に抜けていました
  • 26: 106.187.13.25、27–28: 27.86.*:日本側(キャリア/IX)に入った可能性大
  • 36以降ずっと 182.22.xx.xxx:宛先/手前の装置が同じIPで応答しているだけ(複数プローブの返答やICMP制限の影響)。到達自体はその前段でできています。

whois 182.22.xx.xxxを調べると:

inetnum:      182.22.0.0 - 182.22.127.255
netname:      YAHOO
descr:        LY Corporation
descr:        1-3 Kioicho, Chiyoda-ku, Tokyo, Japan
country:      JP

Tracerouteの最後に何度も出てきた 182.22.xx.xxx は、182.22.0.0 - 182.22.127.255 の範囲はであり、LY Corporation(旧Yahoo Japan)のネットワークとして登録されていました。

つまり、「アメリカ → Comcast バックボーン → 海底ケーブル → 日本(LYのネットワーク)」という経路で到達していることが確認できました。

4. 自分のPCのポートを調べるコード

注意: 実際の外部サイトにポートスキャンするのは規約違反や攻撃と見なされることがあります。なので「自分のサーバ」や「ローカル環境」で試すのが安全です。

from scapy.all import IP, TCP, sr

ip = "127.0.0.1"  # 自分のPC (localhost)
ports = [22, 53, 80, 443, 3306]  # 試したいポート番号を指定

# SYNパケットを送信して応答を得る
ans, unans = sr(IP(dst=ip)/TCP(dport=ports, flags="S"), timeout=2, verbose=0)

# 応答を解析
for s, r in ans:
    if r.haslayer(TCP):
        if r[TCP].flags == "SA":
            print(f"Port {s[TCP].dport} is OPEN")
        elif r[TCP].flags == "RA":
            print(f"Port {s[TCP].dport} is CLOSED")

判定ルール

  • 返事が SYN+ACK → ポートOPEN
  • RSTCLOSED
  • 応答なし → FW でブロック

自分のPCは全部閉じていたけど、python -m http.server 8080 を立ち上げて再スキャンすると 8080がOPEN と表示されて納得できました。

5. ARPスキャンでLAN内探索

ARP(Address Resolution Protocol)とは「このIPアドレスを持ってるのは、どのMACアドレスの端末?」とLAN内で問い合わせる仕組み。L2 (データリンク層) のプロトコル。

「自宅LANの範囲(サブネット)」を調べるには、自分のPCが今どんなIPアドレスを持っているかを確認します。私の環境はWSLなので、ip addrを叩くとeth0は172.19.35.58/20とのこと。ネットワークは172.19.32.0/20と判明。

from scapy.all import ARP, Ether, srp
target = "172.19.32.0/20"https://zenn.dev/dashboard
pkt = Ether(dst="ff:ff:ff:ff:ff:ff")/ARP(pdst=target)
ans, _ = srp(pkt, timeout=2, verbose=0)
for _, r in ans:
    print(f"IP: {r.psrc}, MAC: {r.hwsrc}")
=== Devices in LAN === IP: 172.19.32.1, MAC: 00:15:5d:4a:02:13

結果は 1 台だけ。「あれ?LANに機器はもっとあるはずなのに?」 理由は WSL環境だから、見えているのは仮想ゲートウェイのみ。WSL 環境の eth0 (172.19.35.58/20) は、実際には Hyper-V の仮想スイッチにつながっている。だから WSL 内から見ると「同じネットワーク上には ゲートウェイ(172.19.32.1) しかいない」状態。WSL の eth0 は Windows が作った仮想NIC。その先にある実ネットワークの機器は、WSL の中からはARPスキャンできない。実機LinuxやWindowsの arp -a なら他の機器も見えるはず。

まとめ

scapyを用いてパケットを取り出してみると、ネットワークの中身がよりリアルな事象として考えられるようになりました。これまでコンセプトとしネットワークのパケットを送信する仕組みは、理解していたつもりだったのですが、生のデータを見ることでEthernetは近距離住所、IPは世界住所、TCPは信頼のルールという層ごとの役割が腑に落ちました。

参考

Discussion