Linux は NAT 経由の ping をどのように処理するのか?
(devnonsense.com)- IPv4 NAT は通常、TCP/UDP のポートで応答先を区別するが、
pingの ICMP echo にはポートがないため、Linux がどの値をマッピングキーとして使うのかが核心 - 実験ではネットワーク名前空間で
client1、client2、natbox、serverを作成し、iptablesMASQUERADE により 192.168.99.0/24 から 10.0.100.0/24 へ NAT される構成を再現 - RFC 792 とパケットキャプチャを照合すると、ICMP echo の Identifier と Sequence Number がリクエスト・レスポンスの対応付けに使われ、Linux の ICMP
SOCK_DGRAM経路ではソケットの local port が ID に入る - 2 つのクライアントが同じ ICMP ID
999を使うと、netfilter が衝突を避けるため一方の ICMP ID をランダムな値に変更し、応答では元のクライアント IP と ID に戻す - Linux NAT は、ポートのない ICMP でも conntrack タプルに元方向・応答方向の状態を保存し、ICMP ID を操作可能なキーとして使って、応答を正しい内部ホストへマッピングする
実験環境と NAT 構成
- 1 台の Linux マシン上で複数のデバイスを模倣するため、ネットワーク名前空間を使用
- 2 つのクライアント、NAT ルーター役の
natbox、サーバーをそれぞれ別の名前空間として作成し、プライベートネットワークとサーバー側ネットワークを分離client1:192.168.99.1/24client2:192.168.99.2/24natboxの内部インターフェース:192.168.99.3/24natboxの外部インターフェース:10.0.100.1/24server:10.0.100.2/24
- Fedora 38 Server VM と Linux kernel 6.2.9 で
ip、iptables、tcpdumpなどを root で実行 - 2 つのクライアントは
br0ブリッジに接続され、natboxはブリッジとサーバー側の veth pair にそれぞれ接続 - クライアントのデフォルトルートは
192.168.99.3に設定し、サーバーへ向かうトラフィックがnatboxを通るようにする natboxではnet.ipv4.ip_forward=1でパケット転送を有効にし、iptablesのnatテーブルPOSTROUTINGチェーンに MASQUERADE ルールを追加ip netns exec natbox iptables -t nat -A POSTROUTING -o eth1 -j MASQUERADE
パケットキャプチャで見た ICMP NAT
client1とserver名前空間でtcpdump -n icmpにより ICMP パケットをキャプチャ- クライアント側では
192.168.99.1 > 10.0.100.2の echo request と10.0.100.2 > 192.168.99.1の echo reply が見える - サーバー側では同じリクエストの送信元 IP が
10.0.100.1に変わっており、NAT が送信元アドレスをnatboxの外部 IP に書き換えたことを確認 - 異なるクライアントの ICMP リクエストは異なる id フィールドを持つ
- 例では
client1の ID は31428 client2の ID は33391
- 例では
- この観察から、
natboxが ICMP 応答を内部クライアントへ戻す際に ID フィールドを利用できることが分かる
RFC 792 と ping の ICMP ID
- ICMP は 1981 年に公開された RFC 792 で定義された古いプロトコル
- ICMP echo と echo reply メッセージには Type、Code、Checksum、Identifier、Sequence Number、Data がある
- Type は echo request と echo reply を区別する
- echo request の Type は
8 - RFC 引用部分では echo reply が
1と記載されている - Code は
0
- echo request の Type は
- RFC 792 は、Identifier と Sequence Number を echo request と reply の対応付けに使えると説明している
- Identifier は TCP/UDP のポートのようにセッション識別に利用でき、Sequence Number は各 echo request ごとに増加できる
- RFC は ID を実際にどう選ぶかを規定していないため、実装である
pingのソースコード確認が必要
iputils ping で ID が決まる仕組み
pingコマンドは iputils パッケージに含まれるping4_send_probe付近のコメントは、ICMP echo request を作る際にID フィールドはランダムな数値で、Sequence Number は増加する整数だと説明しているping内部にはstruct ping_rtsのidentフィールドがある- デフォルト値は
-1 - CLI オプション
-eで0からIDENTIFIER_MAXである0xFFFFまでの値に上書きできる
- デフォルト値は
rts->ident == -1の場合、pingはSOCK_DGRAM型とIPPROTO_ICMPプロトコルでソケットに bind する- Linux の
IPPROTO_ICMPソケットの説明によると、ICMP ヘッダーはsend()時に検査・整理され、id はソケットの local port 番号に設定される pingが source port を指定しない場合、Linux カーネルが空いているポートをランダムに選び、そのポートが ICMP パケットの ID として使われると考えられる
同じ ICMP ID が衝突する場合
- 2 つのクライアントで
ping -e 999を使い、同じ ICMP ID 999 でサーバーへ ping を送る - サーバー側のキャプチャ結果では、一方のクライアントのリクエストは ID
999を維持するが、もう一方のクライアントのリクエストは ID30218に変わる - NAT デバイスは、同じ外部 IP と同じ ICMP ID の組み合わせが衝突しないよう、一方の ID を変更する
- 衝突処理の場所を探すため、Linux の
net/netfilterディレクトリで ICMPidフィールドを使うコードを確認
netfilter、conntrack、NAT の役割
iptablesルールを実装するカーネルサブシステムは netfilter- MASQUERADE ルールが NAT を行うため、ICMP NAT の実装も netfilter 内にある
nf_nat_core.cのnf_nat_setup_infoはget_unique_tupleを呼び出し、さらにnf_nat_l4proto_unique_tupleへ続くnf_nat_l4proto_unique_tupleにはIPPROTO_ICMPのケースがあり、tuple->src.u.icmp.idを参照するnf_nat_proto.cのnf_nat_manip_pktはnf_nat_ipv4_manip_pktとl4proto_manip_pktを経て、ICMP の場合はicmp_manip_pktを呼び出すicmp_manip_pktはhdr->un.echo.id = tuple->src.u.icmp.idにより、実際の ICMP ID をパケットへ書き込む
conntrack タプルで ICMP を表現する方法
- netfilter の connection は TCP 接続だけを意味するのではなく、UDP や ICMP のようなコネクションレスなプロトコルでも、出ていくパケットと入ってくるパケットを関連付ける状態を指す
nf_connにはtuplehash[IP_CT_DIR_MAX]があるIP_CT_DIR_ORIGINAL: 送信パケット方向IP_CT_DIR_REPLY: 受信する応答方向
- 各
nf_conntrack_tuple_hashは connection を識別するnf_conntrack_tupleを持つ - タプルは操作可能な
srcと不変のdstに分かれるsrcには IP アドレスとプロトコル別フィールドがある- ICMP のプロトコル別フィールドは
__be16 id dstには変更されない IP アドレスと ICMPtype、codeがある
- NAT は送信パケットをどのように変更したかを connection に保存しておき、応答パケットでその変更を元に戻す
ICMP ID を選ぶコードパス
natboxが ICMP echo を受け取ると、nf_nat_setup_infoが新しい connection を作成し、送信元 IP と ICMP ID を変更する必要があるかどうかを決定する- その後、ICMP パケットごとに
nf_nat_manip_pktが connection に保存された値に従って、送信元または宛先 IP と ICMP ID を設定する get_unique_tupleは、利用可能な NAT タプルを選ぶ中核的な経路find_best_ips_protoが送信元 IP アドレスを書き換えるnf_nat_used_tupleがタプルがすでに使用中か確認し、使用中でなければ現在のタプルをそのまま返す- そのため、2 つのクライアントの ICMP ID が異なれば、NAT 後のパケットでも ID は保持される
- すでに使われているタプルであれば
nf_nat_l4proto_unique_tupleが呼ばれ、プロトコル別 NAT を実行する
- ICMP では
tuple->src.u.icmp.idが NAT 対象のキーとして選ばれる find_free_idはget_random_u16()でランダム ID を生成し、有効な ICMP ID 範囲に調整したうえで、使用中かどうかを検査する- デフォルトの ID 範囲は ID 範囲全体であり、
iptablesMASQUERADE ルールで--to-portsにより100-200のような範囲を指定できる - 未使用のタプルを見つけられなければ重複 ID が connection に残り、その後
__nf_conntrack_confirmが重複を検出してパケットをドロップする
bpftrace で確認したカーネル動作
- 理解した netfilter の動作を確認するため、
bpftraceを使用 - トレース対象のカーネル関数は
nf_nat_setup_infoとnf_nat_manip_pkt kprobeは関数呼び出し時点をトレースし、kretprobeは関数の戻り時点をトレースするkretprobeでは関数引数に直接アクセスできないため、進入時に BPF map へ引数を保存し、戻り時に再度読み出すstruct sk_buffは Linux カーネルがパケットを表現する構造体bswapはネットワークバイトオーダーである big endian を little endian に変換するために使われるntopは IP アドレスを文字列に変換する- 最新の Linux カーネルの BPF Type Format(BTF)により、BPF プログラムは
sk_buff、nf_connなどのカーネルデータ構造を、ヘッダーを含めずに参照できる - この
bpftraceプログラムは Linux kernel 6.2.9 でテストされており、別のカーネルバージョンでは動作可否が異なる可能性がある
トレース結果と結論
- 同じ ICMP ID
999で 2 つのクライアントが ping を送ると、nf_nat_setup_infoはクライアントごとに 1 回ずつ呼び出される - 1 つ目のクライアント
192.168.99.1は original tuple と reply tuple のどちらも ICMP ID999を維持する - 2 つ目のクライアント
192.168.99.2は reply tuple の ICMP ID が32809に書き換えられる nf_nat_manip_pktは echo request でNF_NAT_MANIP_SRCにより送信元 IP を10.0.100.1に変更し、応答ではNF_NAT_MANIP_DSTにより宛先 IP を元のクライアント IP に戻す- 応答パケットの ICMP ID も NAT 後の値から、元のクライアントが送った値へ復元される
- デフォルトの ICMP conntrack timeout は
/proc/sys/net/netfilter/nf_conntrack_icmp_timeoutで確認でき、観測されたデフォルト値は 30 秒 - クライアントが 30 秒を超えてパケットを送らない場合、次の ping 時に
nf_nat_setup_infoが再度呼び出される - Linux の ping NAT 動作は Netfilter Hacking HOWTO にも文書化されており、核心は conntrack タプルと ICMP ID の書き換えにある
1件のコメント
Hacker News のコメント
サーバーが起動すると、固定アドレス 3.3.3.3 に固定の ICMP Echo Request パケットを送り始め、このパケットは戻ってこないことを想定している
3.3.3.3 は到達可能なホストでも、スプーフィング対象でもない。代わりに、クライアントは接続しようとするときサーバー IP を知っているので、サーバーへ ICMP Time Exceeded パケットを送る。その ICMP パケットの中には、サーバーが 3.3.3.3 に送っていた「元の」固定パケットが入っており、このハードコードされたパケットが pwnat の識別子として機能する
クライアントはインターネット上の 1 ホップであるかのように振る舞い、サーバーの元の「ICMP echo request」が転送できなかったと知らせているわけだ。NAT は ICMP Time Exceeded 内のパケットがサーバーの送ったパケットと一致することを見て、NAT 配下のサーバーへ転送する。このときクライアントの完全な IP ヘッダーまで含まれるため、サーバーは クライアント IP アドレスを知ることができる
このツールの中核動作は、その後クライアントとサーバーの間に UDP トンネルを作ること
ただ、ざっと見た限りでは NAT が UDP の送信元ポートを書き換えないことを前提にしているようで、すべてのルーターで動くわけではなさそう。WebRTC などで使われる STUN はより洗練された手法を実装しているが、それでもダメな場合はリレーである TURN を使う必要がある
同じ問題は 3.3.3.3 ping トリックにも当てはまる可能性が高い。記事にあるように NAT が ping 識別子を書き換えるなら、このトリックは破綻するはず
応答を受け取ると、ルーターはその一意の ID 値を使って、ローカルネットワーク上の正しいデバイスへ応答を転送する
コンピューター 1 台と Wireshark/tcpdump だけで確認できる
記事自体は良く、ネットワーキングの理解がまったくなかった人には気づきを与えるかもしれない。ただ本質的には、自分で考えるというより、きちんとしたネットワーク実験室を作ってソースを掘る方法に近いように見える
こういう手を動かして追える例は本当にありがたく、自分でもやってみるつもり
このテーマで理解しやすかった、ほぼ唯一の別記事は Tailscale のこの記事。 「噛み砕いた例」が多く、全体がどう噛み合っているのかが明確になる
https://tailscale.com/blog/how-nat-traversal-works/
偶然にもこの週末、OpenWRT ルーターで 透過プロキシを有効にしようとして Netfilter と格闘していた
Netfilter を見るときに基本の参考資料にできるのは https://wiki.nftables.org/wiki-nftables/index.php/Main_Page と https://www.netfilter.org/projects/nftables/manpage.html
しかし ICMP Echo Request には ID があり、実質的に送信元ポート番号のような役割をする
ICMP Echo を正しく NAT 処理するには、UDP の送信元ポートを再マッピングするのと同じように、ID も双方向に再マッピングしなければならない
NAT 配下のマシンが同時に 2 つのホストから ping を受け、2 つのホストが偶然同じリクエスト番号を使うと曖昧になるためだ
別の可能性としては、識別子を書き換えず、各 ID に紐づくリモートマシンのリストを保持すること。ID が衝突したらリストにリモート IP アドレスが複数入り、NAT 配下のマシンから応答が来たら、NAT はリストから 1 つを選んでそのマシンへ応答を送り、その項目を削除すればよい
IPv6 だったら、ネットワーク内のすべてのノードを変更し、内部 DNS も更新しなければならなかったはず
理論上は持ち運べる自分の /48 を持つこともできるが、新しい ISP がそれを広告してくれる必要があり、現在の ISP は対応してくれるとしても一般的ではない
1 週間前に電話線が切れたときは 5G MiFi を取り出して WAN 接続をそちらへ移し、そのインターフェースで簡単に masquerade をかけるだけで済んだ。電波が弱くて良くはなかったが、動作はした
問題は、IPv6 をやるとしても、依然としてデュアルスタックを運用するか、汚い NAT 抽象化を使わなければならない点だ。自分にとってメリットなしに作業だけが増える
仕事の方も同じ。内部 172.16/12 サブネットを使う車両同士が接続・ルーティングし、さまざまな VPN 接続で外部とつながっている。地下に駐車されることが多く、電波がほとんどないため、いくつかの方法のうちどれか 1 つでも動くことを期待する構成になっている
IPv6へ移行すると、また /48 を移さなければならない。しかもこれらの車両は複数のスポーツ競技場でインターネット接続を受けているが、その多くは MITM/443 を無効にしたり、UDP ブロックを解除したりすることさえ難しい。土曜の午前10時に到着して2時間後には動かさなければならない環境では通用しない
デュアルスタックへ移行して作業量とリスクを倍にするビジネス上のメリットが何なのか分からない
https://en.wikipedia.org/wiki/Lindy_effect
https://stackoverflow.com/questions/31857419/how-to-send-a-m...
残念ながら ping はOSが処理するため、ピアIP上のアプリはメッセージを読めない
こうしたサービスの一部にユーザー空間フックを提供して、双方が NAT 配下にある本当のP2Pを可能にする時期ではないかと思う。少なくとも読み取り専用のイベントストリームのようなものでもいい。今やそれを妨げている障壁はすべて人為的なものに感じる
ただし、pingを使うデータ持ち出し戦略や別の通信方式を見たことはある。最近はたいていのファイアウォールのデフォルト設定が、pingを含むすべてのICMPを黙って捨てるので、P2P用途にはほぼ無理だと思う
idは実質的に(sport, dport)と同等に見えるが、16ビットなので32ビットより空間がずっと小さいただ、NATホールパンチングの核心的な問題は、接続を作るには両端の双方で動きが必要だという点ではないかと思う。だから、ノードSがノードTと会話しようとしていることをTに知らせる 調整サーバー が常に必要になる
それでも考える材料にはなる。ICMPルーティングメッセージ、たとえば unreachable や TTL expired のようなもので実現する方法があるのか気になる。あるIPへ traceroute すると任意の別IP群からパケットが返ってきて、これはたいてい NAT を通過する
着信接続を受けたいホストTがランダムな「ダミー」IPアドレスを選び、
(ルーターIP, ダミーIP)を自分の識別子として公開したうえで、そのダミーIPへ定期的にパケットを送る、と想像できる。Tと会話したいホストSは、そのダミーアドレスに関する ICMP TTL-expired をTのルーターへ送ることができ、ルーターがそれを見てTへ転送するかもしれないもちろん、ICMPフィールド内のIPアドレスが、IPヘッダー内のアドレスと同じようにイングレスフィルタリングされるかどうかに依存する
追記: このアイデアの実装を指すトップレベルコメントがすでに出ている
[1] - https://github.com/yarrick/pingfs
GitHubなら特定のコミットハッシュ、ファイル名、行番号の組み合わせに結び付けることはできるが、コードベースが大きく変わるとあまり役に立たない。git.blender.org のようなあまり使われていない git Webビュー ではうまくいかなかった
https://elixir.bootlin.com/linux/latest/source