Linuxのnetfilterのconnection trackingとNAT動作の仕組み

概要

LinuxではNATやファイアウォールなどのパケットの扱いを担当するカーネルの機能としてnetfilterというものが搭載されており、特にそのconnection trackingの機能を理解することがLinuxのNAT動作の理解には不可欠です。また、安価な市販ルーターの多くはLinuxを搭載しているため、netfilterを理解することは市販ルーターの動作を理解することにもつながります。

NAT動作タイプ(cone NAT, symmetric NATあるいはEIM/APDFなどの用語)については以前の記事(NATタイプ、ポートセービングIPマスカレード、UDPホールパンチング、STUN - turgenev’s blog)などを参照してください。

他のプロトコルに関しても参考になる部分はあるかもしれませんが、この記事では主にUDPTCPのみについて扱います。

conntrackコマンド

以下で説明するconnection trackingの様子はconntrack -Lというコマンドで閲覧できます。接続情報のタイムアウト秒数なども表示されていて大いに参考になります。

conntrackはデフォルトではインストールされていない場合もあるのでパッケージマネージャなどで入れましょう。

connection trackingのエントリ

connection trackingは一言でいえば「現在有効な接続」の一覧を通信の両端点のアドレスとポートに従って管理するものです。何かしら新規の通信が発生すると接続情報がエントリ(マッピング)として登録され、(接続形態に応じて)定められたタイムアウト時間が経過するまでそれが保持されます。

conntrack -Lコマンドの出力から1行取り出して見てみましょう。

tcp      6 118 TIME_WAIT src=192.168.1.12 dst=192.168.1.9 sport=3311 dport=5678 src=192.168.1.9 dst=192.168.1.12 sport=5678 dport=3579 [ASSURED] mark=0 use=1

これの意味は、「TCPプロトコル番号6番)通信で、タイムアウトまであと118秒で、状態はTIME_WAIT、通信を開始したのは192.168.1.12:3111で宛先は192.168.1.9:5678、相手側から見ると送信元は192.168.1.9:5678で送信先は192.168.1.12:3579、確立済みのコネクション(ASSURED)で、マークは0」という感じです。useだけはちょっと意味がよくわかりませんがiptables - "use" column in /proc/net/ip_conntrack or nf_conntrack? - Server Faultによると一種の参照カウンタ?のようです。あまり気にしなくていい気がします。

「相手側から見ると192.168.1.12:3579」なのはなぜ?というのは後で解説しますがNATが介在するとこうなります。

ここでの「マーク」は接続に対するマークであって、パケットに対するマークではありません。iptablesでいえばCONNMARKのほうであってMARKではありません。注意してください。

登録のタイミング、TCPUDPの違い

上記の通り登録のタイミングは「新規の通信が発生したとき」ですが、これはTCPUDPというプロトコルの性質によっても若干違います。

TCPでは明示的な接続の概念があり、基本的にはSYNパケットの送受信に伴ってエントリが作成されます。ただしSYNパケットが送られただけで応答がないときなどは通信が成立したとみなされないので、さっきのTIME_WAITのところはSYN_SENT、[ASSURED]のところは[UNREPLIED]などとなります。この時点ではタイムアウトも数十秒程度など短めに設定されています。ここからhandshakeが完了して通信が確立するとTIME_WAITのところがESTABLISHEDになり、[UNREPLIED]が[ASSURED]に変わって、タイムアウトも数時間程度など大幅に長くなります(もちろん設定で変えることはできます)。接続が終わると先ほどのようにTIME_WAITの状態になり、再びタイムアウトが短めに修正されます。

UDPでは接続状態がないのでもっと単純で、今までのリストに存在しない(src IP, src Port)と(dst IP, dst Port)の間で通信が発生するとエントリが作成されます。こちらも一方向にしか通信がない場合は[UNREPLIED]、双方向に通信が発生した場合は[ASSURED]と区別され、タイムアウト時間にも差があります。一方で、TIME_WAITやESTABLISHEDにあたる部分に関しての区別はないようです。

逆に言えば、新規の通信でないパケット、つまり既存のマッピングに一致する(src IP, src Port)と(dst IP, dst Port)の組み合わせを持つパケットはそのマッピングに従って転送されることになります。

だいたいここまでがconntrackの基礎です。

NATの適用

NATも、新規の通信が発生したとき、つまりconntrackのエントリ作成と同じタイミングで行われます。NATは大きく分けて送信元を書き換えるSNATと送信先を書き換えるDNATの2つがあり、SNATはPCを出て行く直前のパケット(iptablesのPOSTROUTING)、DNATはPCに入ってくる直前のパケット(iptablesのPREROUTING)及びPC内部で生成されて出て行くパケット(iptablesのOUTPUT)に適用されます。masqueradeというのはSNATの一種です。

例えばDNATの例としては192.168.1.1の3000に来たパケットを192.168.1.2(別のPC)の13000に転送するといったものが考えられます。

この場合であれば、192.168.1.1の3000に未知のホスト(例えば1.1.1.1:11111)からSYNパケットが送られてきた際には、

src=1.1.1.1 dst=192.168.1.1 sport=11111 dport=3000 src=192.168.1.2 dst=1.1.1.1 sport=13000 dport=11111 

のようなエントリが作成され、1.1.1.1:11111からすれば192.168.1.1:3000に送ったつもりのところが内側では192.168.1.2:13000から1.1.1.1:11111への通信が行われているという様子が表現されています。

エントリの競合の回避

エントリの新規作成にあたっては、「既存のエントリと競合(衝突)しないか?」ということが考慮されます。競合というのは、同じポートを同じ宛先に対して使用する二つのエントリが存在する状況です。例えば、192.168.1.2:6000が192.168.1.1:8000を使って1.1.1.1:11111と通信している状況で、192.168.1.3:7000も192.168.1.1:8000を使って1.1.1.1:11111と通信する、というようなエントリを作ってしまうと、1.1.1.1:11111からパケットが送信されてきたときにそれを192.168.1.2:6000と192.168.1.3:7000のどちらに送ればいいのかわからなくなってしまいます。

netfilterは、このように転送先が一意に決定できなくなるような状況が発生するのを避けます。例えばNAT先のポートとして8000-8010が指定されていたときにこの状況になったら、8000番以外のポートが選ばれます。もし8000番しか候補がなければ、ポートの割り当ては失敗し、通信が成立しなくなります。これはTCPでもUDPでも同じです。

逆に、通信先が違う場合であれば、同じ8000番を使う複数のマッピングが同時に存在していても問題はありません。この辺に関しては前回記事でも「ポートの共有」「ポートセービングIPマスカレード」のようなキーワードで説明しました。

この動作は、ポートが他のアプリケーションによって使用されている(EADDRINUSE)というエラーとは全く無関係なので注意してください。

ポート番号の選択とNAT動作タイプ

基本的にはDNATもSNATもどちらも、可能な限り元のポート番号を維持しようとします。つまり、変換元のポート番号と同じ番号が利用可能な場合は、上記の競合が起こらない限りは、それが割り当てられます。こちらのツイートソースコードの該当箇所っぽいの(find_appropriate_src関数)が載っています。 

同じ番号が選ばれる状況だとnetfilterのNAT動作はcone NAT、つまりEndpoint Independent Mapping (EIM)と一致します。一方で、競合が起これば一つの変換元ポートに対して複数の変換先ポートが存在することになるので、EIMの厳密な定義には当てはまりません。

そうでない(利用不可能or競合)場合は、ランダムなものが選ばれるようです。ただ、その変換元のポートから以前別のどこかに接続したときに選ばれた変換先ポートの履歴が残っていれば、それと同じものが選ばれることもあります(詳細はわからないが、そうならない場合もある)。この場合もやはりEIM的な挙動ということになります。

また、マッピングが宛先の(Address, Port)に従って管理されている関係上、未知の(Address, Port)から来た通信が内側に通ることはないので、フィルタリング動作としてはAddress and Port Dependent Filteringになります。ただしTCPに関しては、既知の(Address, Port)からだったとしても外部から来たものは別の接続と見なされるため、内側に通されません。従ってConnection Dependent Filtering(この用語はあまり使われませんが)になります。

結局、UDPに関しては、全体としてポート制限コーンNATPort Restricted Cone NAT)のように動作します。

ちなみに、SNATなどのオプションでrandomなどを指定すれば最初から完全にランダムなものが選ばれます。これはsymmetric NATやEndpoint Dependent MappingあるいはConnection Dependent Mappingといった動作に近くなりますが、あくまでランダムというだけで、必ず別のポートを割り当てるというわけでもないため、厳密にはそれらの定義には当てはまりません。

ファイアウォールによるフィルタリング動作

conntrackはNATを伴わないファイアウォールとしても機能します。典型的にはufw enableで有効になるようなもののことです。これも同じように、既存の接続と一致しないものは通されないので、UDPならAddress and Port Dependent Filtering、TCPならConnection Dependent Filteringの動作になります。

NAT自体が起こらないのでMappingも何もないですが、しいて言えばEIMと考えればいいです。

ポート範囲をずらす形でのNAT

上記の動作により、例えば192.168.1.1の10000-20000と192.168.1.2の10000-20000というように全く同じポート番号範囲での変換であれば同じ番号の間で1対1に変換が行われます。一方で、192.168.1.1の10000-20000と192.168.1.2の30000-40000など、個数は一致していたとしても範囲が異なっていると、変換先ポート番号は予測不能になります。

しかし、nat - Is it possible to map 1:1 port range iptable DNAT rules - Stack Overflowによると、比較的最近(2018年、Linux 4.19あたり)から、ポート範囲をずらす形での1対1での転送ができるようになったようです。

どのように指定するかというと、

iptables -t nat -A PREROUTING -d 192.168.1.1 -j DNAT --dport 10000:19999 -j DNAT --to-destination 192.168.1.1:20000-29999/5000

のようにスラッシュでオフセットを記述します。すると、変換先ポート範囲を0-indexedな配列として見たときに「変換前のポート番号からオフセットを引いたものを変換先ポート範囲の個数で割った余り」をindexとしたポート番号を(優先して)選んでくれるということのようです。この例であれば変換前が12000とするとそこから5000を引いて10000(変換先ポート範囲の個数)で割って0あまり7000なので(0-indexedで7000ということは7001番目であるところの)27000が選ばれるというわけです。

これはmanとか見ても書いていなくて、こことかここにあるコミットログみたいなやつとかソースコードを見るくらいしか手がかりがありませんが、手元ではちゃんと動きました。

ただ、少なくともiptablesではDNATにしか使えないようです。SNATでは同じ構文を使ってもエラーは出ませんが挙動はスラッシュ以降がないときと変わらないように見えます。

nftablesではSNATでもDNATでも使えません(ただしiptablesコマンドから設定するとなぜかちゃんと動く)。Linux Netfilter Devel — Re: [PATCH nftables 0/8] Support for shifted port-ranges in NATのように1年前くらいにパッチを送っている人がいることはわかりますが、大元のソースを見る限りまだ取り入れられていないようです。てかパッチの見方わからん…

netfilterの自体はSNATにも対応していそうに見えるので、カーネルモジュールとか書けば理論上はいける気がします。あるいはLinuxで制限コーンNAT(EIM/ADF)を動かす - turgenev’s blogみたいな自前のNATを使うのもありですね。

もちろん、別にずらした形での転送ではなくても構わないというケースも実際には多そうです。

「ポートの開放」の意味、市販ルーターの挙動

多くの市販ルーターでは「ポート変換」「ポート開放」「静的NAPT」「静的IPマスカレード」といった名前で、ルーターのポートにアクセスした際に内部の機器の別のポートにアクセスが通るように設定することができます。これがnetfilterにおけるどのような設定に対応するかを説明します。

ルーターとして稼働しているlinuxのnetfilterにおいて「ポートの開放」をするといったときに含意される可能性のある操作は概ね以下の3つだと思います。

Destination NAT(DNAT)により、宛先を変更する…たとえば、iptables -t nat -A PREROUTING -p tcp -d 1.1.1.1 --dport 10000:20000 -j DNAT --to-destination 192.168.1.1:10000-20000

②Source NAT(SNAT)により、送信元を変更する…たとえば、iptables -t nat -A POSTROUTING -p tcp -s 192.168.1.1 --sport 10000:20000 -j SNAT --to-source 1.1.1.1:10000-20000

③上記のパケットを許可…たとえば、iptables -A FORWARD -p tcp -d 192.168.1.1 --dport 10000:20000 -m state --state NEW,ESTABLISHED,RELATED -j ACCEPT(あるいは、ufw route allow 10000:20000/tcp

このうち①③は必須で、①がないとアクセスの転送が行われず、③がないとパケットがルーターの手前で止まってしまいます。

一方で、②は設定しなくても外側からのアクセスは内側に転送されるため通常のサーバー用途であれば問題なく動作します。手元のルーター(後述)では、NTTのHGWとBuffaloのものではSNATも適用されましたが、Atermのものは適用されていませんでした。

例1: NATよりも既存エントリが優先される

netfilterの挙動に慣れるため、例を見てみましょう。

例えば今、1.1.1.1:10000から192.168.1.1:5000にDNATが設定されているとします。このとき、1.1.1.1:10000を使用(バインド)して2.2.2.2:20000に向かってUDPパケットを送信したとします。するとこの2つの間でのエントリが作成されます。このエントリが存在する間に2.2.2.2:20000から1.1.1.1:10000に向かってUDPパケットが送信されると、(仮にアプリケーション上は最初のパケットと無関係だったとしても)それはDNATされずに1.1.1.1:10000にそのまま到達します。他の場所(例えば2.2.2.2:20001)から来たパケットはDNATされます。

SNATでも同様です。例えば1.1.1.1:10000から1.1.1.1:5000にSNATが設定されていたとしても、2.2.2.2:20000から1.1.1.1:10000に向かってUDPパケットが来たら、以後1.1.1.1:10000から2.2.2.2:20000に向かって出て行くパケットはSNATされずそのまま1.1.1.1:10000から出て行きます。

このように、通信相手との間に既にエントリがある場合はNATが使用されなくなることがあります。

TCPでは接続状態があるのでもう少し安定した挙動になります。同様にDNAT設定した上で1.1.1.1:10000から2.2.2.2:20000へ接続したとして、この接続が閉じた後のTIME_WAITの状態のときは、2.2.2.2:20000から1.1.1.1:10000に来た接続は問題なく192.168.1.1:5000にDNATされます。

ESTABLISHEDの状況でもどうなるのか調べたかったのですが、ここでいう2.2.2.2:20000側の動作を再現する(接続中のTCPコネクションと同じポートからその相手に対して全く別の接続要求を送る)のが普通のやり方ではできないのでやめました。明らかに従来の接続と別のパケットだと判別できるので多分DNATされると思いますが、そもそもINVALID扱いで拒否されるのかもしれません。このへんあまり理解しておらず…

例2: 外部からのパケットによるマッピングとの衝突

UDP通信では、未知のhost:portから自分のポートにパケットが送信されてきたときにもエントリが作成されます。さらに、この状態で内側からそのエンドポイントへの通信を開始しようとすると、競合を避けるためこのポートは使えません。

例えば、PC2の30000番から1.1.1.1:40000に対してUDPパケットを送信すると、40000番をリッスンしているアプリケーションが無かった(この場合ICMPのunreachableが返ることもある)としても、[UNREPLIED]という扱いで1.1.1.1:40000に関するエントリが登録されます。このときにPC1をルーターとして使用している別の端末のポート(例えば192.168.1.1:40000)がPC2の30000番にアクセスしようとすると、PC1の40000番は既に1.1.1.1:40000によって使用されているため競合し、40000番以外のポートが使用されます。もちろん、1.1.1.1:40000自体からパケットを送り返す分には(この既存のエントリに従って)そのまま40000番から出ていきます。

これは例えばLinuxルーターとして使用してUDPホールパンチングを行う際に問題になります。STUNサーバーとの通信時に割り当てられたポートを相手に通知しても、先にそのポートに相手からの通信が到達してしまうと、内側の端末がそのポートを通じて相手と通信することができなくなるからです。実際、判定アプリのリモートアクセスチェックツール|DiXiM.NETなどでは、filtering判定を先に行うため、意図した結果が出なくなります。

解決策としては、既存の接続と関連しない外部からのパケットをACCEPTしないDROPREJECTする)ことが有効です。端的に言えばファイアウォールufwなど)を有効にすると解決します。これにより、パケットがポート(上記の例なら1.1.1.1:40000)に到達する前にドロップされるため、エントリが作成されなくなります。

また、PC自体に対してマッピングが作成されてしまうことが問題なので、内側の端末に対して明示的にDNATを指定してやれば、そちらに向けてマッピングが作成されるようになり、内側からの通信がそのマッピングを利用できるようになります。もちろん、DNATを設定できるのは(ポートごとに)1つの宛先だけです。

この問題は以下のツイートで知りました。

分かってしまえば簡単ですが、知らないと結構ハマりそうです。

TCPでは、外部からのパケット(SYN)が来たとしてもこちらがSYN/ACKで応答しなければ接続自体が開始しないのでエントリが作られることもありません。

Linux(netfilter)搭載の市販ルーターでは原則としてここでのファイアウォールにあたる機能が有効なはずなので気にする必要はあまりありません。

暗黙のSNATルール

あまり知られていないと思いますが、明示的なSNATの対象になっていない新規接続でエントリの競合が発生した際にも、同じIPの間で暗黙的にSNATが行われます。感覚的には、iptables -t nat -A POSTROUTING -d 192.168.1.1 -j SNAT --to-source 192.168.1.1のようなルールが全てのIPについて設定されているような感じです。netfilterのドキュメントにもちゃんと記載されています(https://www.netfilter.org/documentation/HOWTO/NAT-HOWTO-6.html)。

例えば192.168.1.1:3000から192.168.1.1:4000にSNATするルールだけを追加し、192.168.1.1:3000から2.2.2.2:10000に通信したとします。このときに192.168.1.1:4000からも2.2.2.2:10000に通信しようとすると、これは特にSNATの対象になっていないにもかかわらず、(ポートの割り当てができずに通信が成立しないのではなく)別のランダムなポートにSNATされた上で外部に送信されます。

TCPのTIME_WAITの再利用と市販ルーターの挙動

この辺はどこまでnetfilterの話でどこまでカーネル本体の話なのかよくわからないところもあるのですが、一応わかるところまで書きます。

再び1.1.1.1でnetfilterが動いているとします。2.2.2.2:20000から1.1.1.1:10000にむけてTCP接続が成立し、それが切断されると、1.1.1.1:10000側には2.2.2.2:20000との間でTIME_WAITのエントリが残っています。

このときに1.1.1.1:10000から2.2.2.2:20000に向けて(つまりさっきと逆方向)TCP接続を開始すると、これは問題なく成立します。conntrack -Lで見てみると、先ほどのTIME_WAITのエントリがそのまま上書きされたような形になっていることがわかります。(一方で他のポートが1.1.1.1:10000を通って2.2.2.2:20000と通信することはできません。)

これはSNAT/DNATが介在していても変わりません。つまり1.1.1.1:10000と192.168.1.1:10000の間でDNAT/SNATが設定されていたとして同様に2.2.2.2:20000からの接続が切断された後に192.168.1.1:10000から2.2.2.2:20000へと接続した場合は問題なく成立します。

しかし、手元の(netfilterが稼働していると思われる)2つのルーター(下記の1と2)ではこのケースで通信が成立しませんでした。エントリが競合するためポートが割り当てられず通信が始まらないという感じの動作です。

原因はよくわかりません。

手元のルーターのNAT挙動

stunclientなどを使用してnetfilterが内部で動いている可能性がありそうな3台のルーターの動作を実際に細かく調べてみました。それぞれの詳細は以下の通りです。

ルーター1…NTTのホームゲートウェイであるRX-600KIで、OCNバーチャルコネクトによるMap-e接続。(多分Linux搭載?)

ルーター2…BuffaloのWSR-1166DHPL2で、PPPoE接続。Linux搭載(搭載しているOSは? | バッファロー)。

ルーター3…NECAterm WX1500HPで、ローカルルーター(WAN側IPをDHCPで取得)として使用。

これらは、以下のように動作しました。

  • デフォルトの割り当て先は、内側ポートと同じ番号が利用可能ならそれを使う
  • 同じ番号が(競合、あるいはMap-eで利用可能な1008個やiptablesのto-sourceの範囲に含まれていないために)使えなければ代替のポートを使用する
  • ルーター1ではポート番号を1008で割ったもの(具体的には、余りがnなら利用可能なポートのうちn番目(ただし最初が0番目とする)のもの)が最優先の代替として使用され、それも空いていなければ連番で増やしていって最初に空いていたものを使用する
  • ルーター2の代替ポートは規則性が不明だが、5000以下など、番号の小さいものが使われることが多い
  • ルーター3では1000番台などかなり小さいものが代替として使われることが多い
  • ルーター2では、競合が起こっていないはずの状況でも代替ポートに割り当てられることも結構ある気がする
  • 特にUDPでは、競合が起こって代替ポートに割り当てられた場合は、その後の同じ内側ポートから他の宛先への通信もそちらに割り当てられるようになる(ことが多い)
  • UDPでは既に送信履歴がある(Address, Port)からでないと通信を通さない(APDF)。TCPでは送信履歴にかかわらず外側からの新規通信(内側からの通信への応答ではないもの)は通さない(Connection Dependent Filtering)
  • TCPにおいては、同じ内側(Address, Port)から外側(Address, Port)へと通信してその終了直後に再び同じ宛先で通信を開始した場合などに、別のポートが使用される場合もある(Connection-Dependent Mapping的な挙動)。特にnetfilterで55500-55599以外のポートから発信した場合に顕著に見られたほか、ルーター2でも発生した。
  • 追記: ELECOMのWRC-1167FS-BをAtermと同様のローカルルーターモードで使用すると、挙動はほぼAtermのものと同等だった。
  • 追記2: BuffaloとAtermをそれぞれOCNバーチャルコネクトで使用した。Atermは、内側ポートに近いポート範囲を選んでから16で割った余りで決めているような感じ。Buffaloはランダムっぽい。

特にルーター1のポート割り当て動作(1008で割った余りとか連番で増やすとか)はiptablesで設定するのは無理だと思うので、したがって、netfilterを直接いじっているか、あるいはそもそもnetfilterではないという可能性もあるかもしれません。(しかし競合がない限り同じポートを使用するという基本的な挙動はnetfilterに類似しているように見えますが…)

まとめ

このconnection trackingの部分がLinuxルーターとしての性質のほとんどを決定しているにもかかわらず意外と情報の少ないところかと思うので、記事にまとめられてよかったかなと思います。質問などあればコメントにどうぞ。