2 ポイント 投稿者 GN⁺ 2023-09-11 | 1件のコメント | WhatsAppで共有
  • IPv4 NAT は通常、TCP/UDP のポートで応答先を区別するが、ping の ICMP echo にはポートがないため、Linux がどの値をマッピングキーとして使うのかが核心
  • 実験ではネットワーク名前空間client1client2natboxserver を作成し、iptables MASQUERADE により 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/24
    • client2: 192.168.99.2/24
    • natbox の内部インターフェース: 192.168.99.3/24
    • natbox の外部インターフェース: 10.0.100.1/24
    • server: 10.0.100.2/24
  • Fedora 38 Server VM と Linux kernel 6.2.9ipiptablestcpdump などを root で実行
  • 2 つのクライアントは br0 ブリッジに接続され、natbox はブリッジとサーバー側の veth pair にそれぞれ接続
  • クライアントのデフォルトルートは 192.168.99.3 に設定し、サーバーへ向かうトラフィックが natbox を通るようにする
  • natbox では net.ipv4.ip_forward=1 でパケット転送を有効にし、iptablesnat テーブル POSTROUTING チェーンに MASQUERADE ルールを追加
    • ip netns exec natbox iptables -t nat -A POSTROUTING -o eth1 -j MASQUERADE

パケットキャプチャで見た ICMP NAT

  • client1server 名前空間で 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
  • 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_rtsident フィールドがある
    • デフォルト値は -1
    • CLI オプション -e0 から IDENTIFIER_MAX である 0xFFFF までの値に上書きできる
  • rts->ident == -1 の場合、pingSOCK_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 を維持するが、もう一方のクライアントのリクエストは ID 30218 に変わる
  • NAT デバイスは、同じ外部 IP と同じ ICMP ID の組み合わせが衝突しないよう、一方の ID を変更する
  • 衝突処理の場所を探すため、Linux の net/netfilter ディレクトリで ICMP id フィールドを使うコードを確認

netfilter、conntrack、NAT の役割

  • iptables ルールを実装するカーネルサブシステムは netfilter
  • MASQUERADE ルールが NAT を行うため、ICMP NAT の実装も netfilter 内にある
  • nf_nat_core.cnf_nat_setup_infoget_unique_tuple を呼び出し、さらに nf_nat_l4proto_unique_tuple へ続く
  • nf_nat_l4proto_unique_tuple には IPPROTO_ICMP のケースがあり、tuple->src.u.icmp.id を参照する
  • nf_nat_proto.cnf_nat_manip_pktnf_nat_ipv4_manip_pktl4proto_manip_pkt を経て、ICMP の場合は icmp_manip_pkt を呼び出す
  • icmp_manip_pkthdr->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 アドレスと ICMP typecode がある
  • 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_idget_random_u16() でランダム ID を生成し、有効な ICMP ID 範囲に調整したうえで、使用中かどうかを検査する
  • デフォルトの ID 範囲は ID 範囲全体であり、iptables MASQUERADE ルールで --to-ports により 100-200 のような範囲を指定できる
  • 未使用のタプルを見つけられなければ重複 ID が connection に残り、その後 __nf_conntrack_confirm が重複を検出してパケットをドロップする

bpftrace で確認したカーネル動作

  • 理解した netfilter の動作を確認するため、bpftrace を使用
  • トレース対象のカーネル関数は nf_nat_setup_infonf_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_buffnf_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 ID 999 を維持する
  • 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件のコメント

 
GN⁺ 2023-09-11
Hacker News のコメント
  • https://samy.pl/pwnat/ に興味を持つかもしれない
    サーバーが起動すると、固定アドレス 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 アドレスを知ることができる
    • 要するに、3.3.3.3 へ pingするトリックは、NAT 配下のサーバーが NAT 配下のクライアント IP アドレスを知るための方法で、https://ifconfig.co のような非 NAT サーバーは不要
      このツールの中核動作は、その後クライアントとサーバーの間に UDP トンネルを作ること
      ただ、ざっと見た限りでは NAT が UDP の送信元ポートを書き換えないことを前提にしているようで、すべてのルーターで動くわけではなさそう。WebRTC などで使われる STUN はより洗練された手法を実装しているが、それでもダメな場合はリレーである TURN を使う必要がある
      同じ問題は 3.3.3.3 ping トリックにも当てはまる可能性が高い。記事にあるように NAT が ping 識別子を書き換えるなら、このトリックは破綻するはず
  • ローカルネットワーク上のデバイスがインターネット上のデバイスへ ping を送ると、NAT を行うルーターが ping の送信元アドレスを自分のグローバル IP に変え、ICMP パケットの ID フィールドを一意の値に書き換える
    応答を受け取ると、ルーターはその一意の ID 値を使って、ローカルネットワーク上の正しいデバイスへ応答を転送する
    • もっと正しく見るなら、OS が同じ宛先へ向かう異なる ICMP 会話をどう区別しているかを考えればよい
      コンピューター 1 台と Wireshark/tcpdump だけで確認できる
      記事自体は良く、ネットワーキングの理解がまったくなかった人には気づきを与えるかもしれない。ただ本質的には、自分で考えるというより、きちんとしたネットワーク実験室を作ってソースを掘る方法に近いように見える
    • この考えをもう少し推し進めると、ステートレスプロトコルをステートフルプロトコルに変えていることになる
    • ping もリクエストとレスポンスを対応づけるには、結局そうした 状態情報が必要になる
    • なぜ「一意の値」の代わりに 送信元プライベート IPを使わないのか気になる
    • その ID は ICMP ヘッダーにあるのか、それとも IP 側に属するのか気になる
  • 「どう動くのか」系の記事が抽象化レイヤーを下って ソースコードまで掘り下げているのを見るとうれしい。説明も良く、情報量も多い
    • 自分もこれを書きに来た。ルーティングとネットワーキングはいまだに混乱するし、関連する記事はたいていあまりに 抽象的に感じる
      こういう手を動かして追える例は本当にありがたく、自分でもやってみるつもり
      このテーマで理解しやすかった、ほぼ唯一の別記事は Tailscale のこの記事。 「噛み砕いた例」が多く、全体がどう噛み合っているのかが明確になる
      https://tailscale.com/blog/how-nat-traversal-works/
  • 良い記事
    偶然にもこの週末、OpenWRT ルーターで 透過プロキシを有効にしようとして Netfilter と格闘していた
    Netfilter を見るときに基本の参考資料にできるのは https://wiki.nftables.org/wiki-nftables/index.php/Main_Pagehttps://www.netfilter.org/projects/nftables/manpage.html
  • ICMP にはポートがないので、NAT が ICMP Echo Reply を正しいポートへ戻す問題を扱う必要はない
    しかし ICMP Echo Request には ID があり、実質的に送信元ポート番号のような役割をする
    ICMP Echo を正しく NAT 処理するには、UDP の送信元ポートを再マッピングするのと同じように、ID も双方向に再マッピングしなければならない
    NAT 配下のマシンが同時に 2 つのホストから ping を受け、2 つのホストが偶然同じリクエスト番号を使うと曖昧になるためだ
    別の可能性としては、識別子を書き換えず、各 ID に紐づくリモートマシンのリストを保持すること。ID が衝突したらリストにリモート IP アドレスが複数入り、NAT 配下のマシンから応答が来たら、NAT はリストから 1 つを選んでそのマシンへ応答を送り、その項目を削除すればよい
  • NAT は本当に汚い抽象化だ。IPv4 は消えるべき
    • 自宅のインターネットには 192.168 帯の複数のサブネットにデバイスがある。少し前に ISP を乗り換えたことで、自宅が属する AS が変わり、新しい IPv4 アドレスを受け取ったが、WAN ルーターで新しい IP 宛てに入ってくるトラフィック転送だけ更新すればよかった
      IPv6 だったら、ネットワーク内のすべてのノードを変更し、内部 DNS も更新しなければならなかったはず
      理論上は持ち運べる自分の /48 を持つこともできるが、新しい ISP がそれを広告してくれる必要があり、現在の ISP は対応してくれるとしても一般的ではない
      1 週間前に電話線が切れたときは 5G MiFi を取り出して WAN 接続をそちらへ移し、そのインターフェースで簡単に masquerade をかけるだけで済んだ。電波が弱くて良くはなかったが、動作はした
      問題は、IPv6 をやるとしても、依然としてデュアルスタックを運用するか、汚い NAT 抽象化を使わなければならない点だ。自分にとってメリットなしに作業だけが増える
      仕事の方も同じ。内部 172.16/12 サブネットを使う車両同士が接続・ルーティングし、さまざまな VPN 接続で外部とつながっている。地下に駐車されることが多く、電波がほとんどないため、いくつかの方法のうちどれか 1 つでも動くことを期待する構成になっている

IPv6へ移行すると、また /48 を移さなければならない。しかもこれらの車両は複数のスポーツ競技場でインターネット接続を受けているが、その多くは MITM/443 を無効にしたり、UDP ブロックを解除したりすることさえ難しい。土曜の午前10時に到着して2時間後には動かさなければならない環境では通用しない
デュアルスタックへ移行して作業量とリスクを倍にするビジネス上のメリットが何なのか分からない

  • IPv6がこの問題を解決するかは確実ではない。技術的にはそうだが、主要プロバイダーが家庭ユーザーには /64 しか与えず、「ビジネス向け」の /48 には高額な料金を課しているため、すでに IPv6 NAT や /64 のさらなる分割につながっている。本来はそうすべきではない
  • そうなると CG-NAT はさらに嫌われることになる
  • リンディ効果を念頭に置く必要がある。技術やアイデアのように消耗しないものの将来の寿命は現在の年齢に比例する、という観察で、IPv4は古いぶん今後もかなり長く残る可能性が高い
    https://en.wikipedia.org/wiki/Lindy_effect
  • IPv6 も消えるべきだ。優勢になる時間は十分にあったのに、ずっと停滞している
  • 中央サーバーなしで NAT 越えを処理しつつ、UDPベースのP2Pネットワーキングに短いメッセージを送るために pingを悪用できるのか気になる。メッセージ部分はすでに誰かが解明しているようだ
    https://stackoverflow.com/questions/31857419/how-to-send-a-m...
    残念ながら ping はOSが処理するため、ピアIP上のアプリはメッセージを読めない
    こうしたサービスの一部にユーザー空間フックを提供して、双方が NAT 配下にある本当のP2Pを可能にする時期ではないかと思う。少なくとも読み取り専用のイベントストリームのようなものでもいい。今やそれを妨げている障壁はすべて人為的なものに感じる
    • 些細な技術的訂正だが、ping は UDP ではなく ICMP
      ただし、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ヘッダー内のアドレスと同じようにイングレスフィルタリングされるかどうかに依存する
      追記: このアイデアの実装を指すトップレベルコメントがすでに出ている
    • すでにある: https://samy.pl/pwnat/
    • 探していたものと厳密に同じではないが、pingを悪用するという話で pingfs を思い出した。クラウドコンピューティングにまったく新しい定義を与えるものだ
      [1] - https://github.com/yarrick/pingfs
    • IPv6 の採用がさらに進めば、全員が公開ルーティング可能なIPを持ち、NATを完全に避けられるので、こうした問題は減るはずだ
  • こういうブログ記事を書くとき、特定のコード行にリンクして、そのリンクが時間が経っても生きていて有用なままであるようにするのが難しすぎて腹立たしい
    GitHubなら特定のコミットハッシュ、ファイル名、行番号の組み合わせに結び付けることはできるが、コードベースが大きく変わるとあまり役に立たない。git.blender.org のようなあまり使われていない git Webビュー ではうまくいかなかった
    • Linuxカーネルコードなら elixir を使えば、少なくとも特定バージョンへリンクできる。ある程度の持続性が欲しいなら LTS バージョンを使えばいい
      https://elixir.bootlin.com/linux/latest/source
  • 要するに、ICMPパケットの中には idフィールド があり、NetfilterはICMPパケットまたはフレームを「特殊なケース」として認識する