DROP vs REJECT論争、そしてWindowsとLinuxのファイアウォールの動作の違いについて

概要

この記事では、IP通信における不要なパケットへの2通りの対処法であるDROPREJECTWindowsLinuxファイアウォールにおいてどのように使われているか説明し、Linuxの動作をWindowsに近づけるための(おそらく英語圏含めほとんどあまり知られていない)具体的な方法なども紹介します。

DROP vs REJECTとは

全世界がインターネットで接続された現代では、いつ、どこから、どんなパケットが自宅に届くかわかりません。素性のわからない"招かれざるパケット"が自宅に届いたとき、我々には2つの選択肢があります。1つは単にそのパケットを無視(破棄)すること、もう1つは相手に「これは受け入れられません」と通知する(した上で破棄する)ことです。

Linuxにおいてパケットの扱いを設定するiptables(や、その後継のnftables)では、前者(無視)にDROP、後者(明示的な拒絶応答)にREJECTという言葉を使うため、以降ではこの2つの用語を説明に用います。

日本製のルーターのパケットフィルタ設定では「無視」「拒否」のような用語が使われており、同じLinuxでもufw(後述)はDROPのかわりにdenyという単語を使っています。従って、DROP/REJECTという用語がIPの仕様で定められているとかいうわけではないと思います。多分。

で、このDROPREJECTのどちらがセキュリティ的に優れているか?というのが、どうやらネットワーク界隈でときおり議論になるトピックのようです。

大前提として、DROPREJECTも攻撃者のパケットの内容を一切無視するという点では変わらず、この2つのどちらを採用するかによる安全性の違いというのは、インターネットとの接続に使用する各種ソフトウェアの安全性の違いに比べれば微々たるものです。実際に何か攻撃を受けてしまったとして、「DROPだったから攻撃を受けた」「REJECTにしておけばよかった…」みたいな話になることはあまりないでしょう。とはいえ、ネットワークの情報をできるだけ秘匿したいという心理的な面も含めて多少は安全性に影響する部分もあり、またトピックとしてもそれなりに面白いと思います。

REJECTの方法の分類、TCPUDP

DROPは文字通り来たパケットを単に無視するというそれだけなのですが、REJECTに関しては「これは受け入れられません」という拒絶応答をどういう形式のメッセージで返すかという違いがあります。

拒絶応答に用いられるプロトコルICMPというもので、筆者もあまりよくわかっていませんがIPと同じL3層のプロトコルで、pingにも用いられているものです。

このICMPの拒否メッセージには以下のようなものがあります。

icmp-net-unreachable
icmp-host-unreachable
icmp-port-unreachable
icmp-proto-unreachable
icmp-net-prohibited
icmp-host-prohibited
icmp-admin-prohibited

例えば、icmp-host-unreachableは、同じサブネット内の存在しないIPアドレスpingしたときとかに(自分自身から)返ってきます。閉じている(アプリケーションがリッスンしていない)UDPポートにアクセスしようとしたときはicmp-port-unreachableが返ってきます。これはWindowsでもLinuxでも同じです。ほかのはよくわかりませんが、まあ使い道がどこかにあるのでしょう。

UDPではなくTCPの場合は、上記のどれかを返してもいいのですが、それ以外にTCPのRST(リセット)パケットを送るという選択肢があります。ICMPはTCPとは全く別のプロトコルで、ポート番号の概念も無いのですが、TCPのRSTならTCP内でやりとりが完結するので、むしろ分かりやすいかもしれません。WindowsLinuxで閉じているTCPポートにアクセスしたときにはTCPのRSTが返ります。

これら以外だとping応答など一部で使用できるecho-replyというのがあるらしい(https://www.asahi-net.or.jp/~aa4t-nngk/ipttut/output/rejecttarget.html)ですが、REJECTの方法の選択肢としては概ね上記の通りです。

Webで使われているHTTPはTCPが使われているので、REJECTの挙動はブラウザで手軽に試せます。たとえばlocalhost:33333とか適当なポート番号を打ってみると、直後(Windowsなら2秒後くらい)に「接続が拒否されました」というような応答が返ってくるはずです。

DROPも試してみる

即座に拒否が返ってくるREJECTの雰囲気は先ほど試したので、次はDROPがどんな感じなのか試してみましょう。例えばブラウザにgoogle.com:8000とか適当なポート番号を付けて打ってみると、ぐるぐると読み込み状態になるだけでいっこうに反応がなく、十秒以上ののちにブラウザがようやく諦めて「アクセスできません」「タイムアウトしました」「応答時間が長すぎます」的な表示になります。つまりDROPだと応答が一切返ってこないので、単に(回線の遅延などで)読み込みに時間がかかっているのかそれとも無視されているのかが判別できないというわけです。

アクセスしている側からすると、REJECTは「すぐに拒絶が返ってくる」、DROPは「時間がかかっているのか無視されているのか分からない」という感じになります。

ポートスキャン入門: TCP

REJECTDROPか、あるいはポートが開いているのかといったことを判断するのにいちいちブラウザを使っていたのでは手間ですし、そもそもブラウザはHTTPにしか対応していないので、専用のツールを導入しましょう。ここでは広く使われているnmapを使用します。

nmapを使えば、各ポートがそれぞれ外部からのパケットにどのように応答するかを確かめることができます。攻撃者はこのようなツールを使って65535個のポートをしらみつぶしに調べて何か攻撃のとっかかりがないか探すことがあり、このような行為をポートスキャンといいます。従って、他人のIPアドレスにむけてむやみにnmapを使うと攻撃の準備と見なされるおそれがありますが、今回は自分のPCの動作確認なので問題ありません。

nmapのインストール方法は省略します。Linuxではsudo apt install nmapです。Windows版もあるようです。

まずはlocalhost対象に、TCPからやってみましょう。確認のために、適当な番号でHTTPサーバーを立てておきましょう。

python -m http.server 8001 

それからnmapで8001を含む3つのポートをスキャンしてみます。結果が以下です。

$ nmap -p 8000-8002 -sT localhost -Pn
Starting Nmap 7.80 ( https://nmap.org ) at 2023-11-27 16:26 JST
Nmap scan report for localhost (127.0.0.1)
Host is up (0.000097s latency).

PORT     STATE  SERVICE
8000/tcp closed http-alt
8001/tcp open   vcom-tunnel
8002/tcp closed teradataordbms

Nmap done: 1 IP address (1 host up) scanned in 0.02 seconds

HTTPポートを立てた8001だけがopen、それ以外はclosedと判断されています。ちなみに"SERVICE"の"http-alt"みたいなのは、「そのポートで一般によく使われるサービス」を参考に示しているだけで、実際に起動しているアプリケーションとは何も関係ありません。

nmapのオプションの-sTというのがTCPでスキャンすることを表しています。ちなみに、TCPスキャンの中には、root権限が必要なかわりに高速なSYNスキャンという別の方式もあり、こちらは-sTのかわりに-sSを指定します。-Pnは多分おまじないみたいなやつで、今回扱う範囲だとつけておいたほうがうまくいきます。

次に、DROPの例としてさっきのgoogle.comの例もやってみましょう。(以降、nmapの出力の余計な部分は削ります)

$ nmap -p 8000-8002 -sT google.com -Pn
PORT     STATE    SERVICE
8000/tcp filtered http-alt
8001/tcp filtered vcom-tunnel
8002/tcp filtered teradataordbms

このように、open/closedとは異なるfilteredというキーワードが表示されます。これは、応答が一定時間内に返ってこなかったことを示します(フィルタという用語は、パケットを選別する機構一般のことを指す?)。つまりDROPされたということです。

UDPでポートスキャン: 開放していてもDROPになる?

次はUDPでも試してみましょう。UDPで動くサービスということで、netcat(nc)を使ってみます。

nc -lu -p 9001

ちなみに、この-luのuがUDPを表しています。つまりTCPでも使えるということです。httpよりこちらのほうが手軽かも…。

で、nmapでスキャンすると次のようになります。UDPでのスキャンにはroot権限が必要です。

$ sudo nmap -p 9000-9002 -sU localhost
PORT     STATE         SERVICE
9000/udp closed        cslistener
9001/udp open|filtered etlservicemgr
9002/udp closed        dynamid

先ほどと似ていますが、openではなくopen|filteredという表示になっています。これはopenかfilteredのどちらであるか判断できなかったということです。次にgoogle.comの例もやってみます。

$ sudo nmap -p 8000-8002 -sU google.com -Pn
PORT     STATE         SERVICE
8000/udp open|filtered irdmi
8001/udp open|filtered vcom-tunnel
8002/udp open|filtered teradataordbms

やはり全てopen|filteredとなっています。

これはUDPTCPの動作の違いによります。TCPは双方向の接続に基づくプロトコルであり、3ウェイ・ハンドシェイクというプロセスで接続の準備を完了してからきっちり順番を決めてパケットを送っていきます。一方、UDPはもっと無秩序で、順番が乱れたりロストしたりするのも構わず事前準備なしにひたすらパケットを送り付けるというプロトコルです。この特性上、UDPで起動しているサービスは、意図した形式・内容のパケットが送られてこなければ何も応答しないこともできます。従ってnmapは、送ったパケットがルーターなど外側で弾かれたのか、それともアプリケーションに届いたのにもかかわらず応答がなかったのか判別ができないのです。いわゆるポート開放確認系のツールがTCPにしか対応していないのはこのためです。UDPで正しくポートが開放されているか確認するには実際に対応したアプリケーションで接続してみるしかありません。

TCPでは、通信内容(認証情報とか)で態度を変えようと思っても、内容のある通信を始める前に必ず形式的なハンドシェイクが必要なので、このような「openかfilteredかわからない」ような状態を作り出すことはどうやってもできません。

これはある意味ではUDPTCPより優れている点とも言えます。実際、UDPを使用するVPNであるWireguardなどはこのような点でセキュアであるという意見もあります(参考: WireGuard概要まとめ)。ただし、実際にWireguardで通信を行う際には宛先IPとポートは平文で見えてしまうので、使用するネットワーク経路を完全に信用できない限りは、誰にもポート番号が分からないようにするのは難しいかと思います。また当然ですがWireguardのセキュリティの99%以上はポート番号がバレないことではなく内容の暗号化が強力であることに由来します。

Windowsファイアウォール

DROP/REJECTの概念にも慣れてきたところで、次は実際のネットワーク機器のファイアウォールの動作について説明します。まずはWindowsです。

Windowsファイアウォールは基本的にDROPを採用しています(Windowsファイアウォールは「受信」と「送信」に分かれていますが、この記事に関連するのは主に「受信」のほうです)。また、アプリケーションごとに許可/ブロックを設定できるのが特徴で、条件に一致したアプリケーションに対するパケットのみがファイアウォールを通過できます。言い換えれば、アプリケーションが起動しているときのみポートが開放され、それ以外は全てDROP、というのがWindowsの動作です。

手元にWindowsを含む2台のPCがあればnmapで試すことができます。何も起動していないポートは、TCPならfiltered、UDPならopen|filteredと表示され、TCPのほうは起動した瞬間にopenに変わり、UDPは変わらないあるいは(アプリケーションが通信を返せば)openになるはずです。

この「許可されたアプリケーションが起動した瞬間に切り替わる」的な動作が具体的にどのように実装されているのかなどの詳細はわかりませんが、Windowsはとりあえずこんな感じです。あと、今回の議論には関係ありませんが「プライベート ネットワーク」「パブリック ネットワーク」の区別があるのも慣れると結構便利です。

Linuxファイアウォール

Linuxはそもそもデフォルトではファイアウォールが無効の場合が多いです。手元にデフォルト状態のLinuxAndroidでも可)を含む2台の端末があったらnmapでテストしてみましょう。おそらく、何も起動していないポートはtcpでもudpでもclosedと表示されるはずです。起動したらそれぞれopenとopen|filtered(またはopen)に変わります。

しかし、もちろんファイアウォールを有効にすることもできます。広く使われているのはufwで、これは内部でiptablesあるいはnftables(コマンド名はnft)を用いているのでそちらで設定しても構いません。ufwもデフォルトではDROPを使用します(前述の通りufw用語としては"deny")。これはユーザーが好きに変更することもでき、--reject-with icmp-port-unreachableや--reject-with tcp-reset(こちらはTCP限定)というオプションで各種応答を返すよう設定できます。

ufwにはアプリケーションごとのルール設定はなく、使用するポートごとに通過の可否を設定します。

  • 追記: OpenSnitchなどはアプリケーションごとの設定ができそうですが、試していません。

許可する場合は、ufw allow 8080/tcpのようなコマンドを使うことになります

WindowsLinuxの違い

ここまでの説明だと、アプリケーションで判断するかポートで判断するかというところが違うだけでWindowsLinuxファイアウォールの動作は(ufwのdenyを使うとすれば)あまり変わらないのでは?と思われるかもしれませんが、実はアプリケーションが起動していないときの動作に大きな違いがあります。

Windowsファイアウォールは、起動中のアプリケーションを見てパケットを通過させるかどうかを判断するので、ポートがどのアプリケーションにも使われていないときはそのポートへの通信はいつものようにDROPされるだけです。

しかし、(ufwなどの一般に使われている)Linuxファイアウォールは、「8080番ポートへのtcp通信を許可します」というようにポートに対して固定的に設定されます。つまり、8080番ポートがアプリケーションに使用されているかどうかとは関係なく常にポートが開放されます。するとどうなるかというと、アプリケーションが起動しているときはWindowsと変わりませんが、起動していないときはDROPされず、ポートが使用できないことを示すメッセージが返ることになります。すなわちUDPであればicmp port-unreachable、TCPであればRSTパケットが返ります。この挙動はファイアウォールで設定されたものではなく、Linuxカーネルによる標準的な応答方法であり、直接的に変える方法は無さそうです

参考までに、以下のページでもやはり無理と書いてあります。

iptables - DROP packets (instead of REJECT) from specific port when no application is listening? - Server Fault

Replying to SYN with RST,ACK when port is closed. Solutions? [SOLVED] / Networking, Server, and Protection / Arch Linux Forums

これは、ファイアウォールの基本的な挙動としてDROPを採用している場合、あまり望ましい状況ではありません。というのも、外部から使いたいサービスのために一部のポートを開放した状態で、かつサービスが起動されていないとき、開放されているポートは他のポートとは異なる挙動をすることが外部から観測できてしまうからです。TCPの場合は、起動している場合はどうせ外部からわかってしまうのでまだいいのですが、UDPだと「openだとしても外部からはfilteredとの区別がつかない」というせっかくのメリットが、アプリケーションの停止時には失われてしまいます

TCPに関してだけなら--reject-with tcp-resetを設定することで「開放されているもののlistenされていないポート」と「開放していないポート」の挙動は揃えられますが、UDPでは同様の発想で--reject-with icmp-port-unreachableにするとこんどはアプリケーションが起動しているときに特定ポートだけ反応が変わってしまいます。

別の方法として、アプリケーションの起動中にだけ動的にポートを開放するようにスクリプトを組むことはできるかと思いますが、Windowsファイアウォールが「listenしているアプリケーションがいなければ無視」というシンプルな挙動をそのまま実現しているのに比べると筋の悪そうな方法です。

ということで、この記事で言いたかったことの一つは、Linuxファイアウォールって実は重大な欠陥品じゃね?ということです。アプリケーションごとのルール設定ができないのはしょうがないとしても、ファイアウォールの一環として「開放されているもののlistenされていないポート」に来た(自分自身以外からの)パケットへの応答をDROP/REJECTで設定できる機能を最低限提供するべきではないでしょうか?

追記: Windowsのstealth mode

後から知ったのですが、Windowsのみに存在する「閉じているポートへのパケットには応答せずDROPする」という動作は、stealth modeという名前の付いたれっきとした機能だったようです。レジストリから無効にすることもできるようです。

Stealth Mode in Windows Firewall with Advanced Security | Microsoft Learn

隠しモードを無効にする方法 - Windows Server | Microsoft Learn

ただ、Windowsではそもそも開放するポートを限定しないので、無効にした場合の動作がよくわかりません。全ポートがreject応答をするようになって、ファイアウォールは「許可されていないアプリケーションへの通信だけはブロックする」という役割になるのでしょうか。その場合DROPREJECTのどっちになるんでしょう…?

各種ルーター

汎用のコンピュータであるWindowsLinuxと共に、ネットワークの構成要素として避けて通れないルーターの動作についても説明します。

といっても、廉価な市販品も含め、ルーターにはLinuxベースのOSが入っていることも多く、PCと厳密に区別する意味はあまりありません。逆に、汎用PCとして使っているLinuxマシンをルーターとして使うこともできます(ただ、ethernetポートが2つついてないとさすがに厳しいかな?)。その場合は、Linuxで設定できる基本的な内容は設定できるかと思います。

ただ、一般的な家庭にあるルーターの動作という点でいえば、外部からの不要パケットは全てDROPに設定され、この部分を変更できないものがほとんどだと思います。パケットフィルタ設定はありますが、それはあくまでDMZやポート転送などでルーターの内側に入ってくるパケットへの対処を決めるものであり、無関係なポートに来るパケットへの反応は変更できません。

また、当然ながら、ポート転送(開放)設定で内側の機器のポートを外側に晒す場合、内側のポートが実際に稼働しているかどうかで挙動を変えるような(Windowsみたいな)ことはしてくれません。(高機能なルーターなら絶対に無いとは言い切れませんが…)

従って、ルーター経由でポートをインターネットに公開することを考えるなら、内側の機器の動作もルーターに揃えるのが望ましいということになります。ルーターDROPで動いているなら、ポートを晒しているPCもそのポートはできるだけDROP的な動作にしたほうが目立たなくて済みますし、REJECTの場合も同じです。

DROP vs REJECT、結局どっち?

ここまで見てきたところで、結局DROPREJECTどっちがいいの?という話に戻りましょう。いくつかの既存の議論も載せておきます。

パケットフィルタリングで不正パケットをDROPするかREJECTするか問題 - 新・日々録 by TRASH BOX@Eel わかりやすく、詳しい。良記事。ホストの存在自体を隠すのはどうせ無理という話が丁寧に書いてある。

iptablesはDROPすべきか、REJECTか。tcp-resetという手も - のめうブログ

iptablesは DROP , REJECT or --reject-with tcp-rest - Note to Self 上のブログを参考にしてそう

(以下は英語)

Drop versus Reject

IPtables: TCP-Reject vs DROP : linux

iptables REJECT vs DROP | todisco.de

[ale] iptables: DROP vs. REJECT --reject-with tcp-reset

Closed Ports Vs Stealth Ports, Drop Rules Vs Reject Rules - Which is better | Ron's Tech Tips

linux - REJECT vs DROP when using iptables - Server Fault

ufw Linux firewall difference between reject and deny - Stack Overflow

ip - Is it better to set -j REJECT or -j DROP in iptables? - Unix & Linux Stack Exchange

SANS - Internet Storm Center - Cooperative Cyber Threat Monitor And Alert System

Revisit REJECT vs DROP in #507 · Issue #2217 · fail2ban/fail2ban · GitHub

Is it better a CLOSED port or a FILTERED port to for DoS attack protection? : AskNetsec

linux - Why does iptables accept packets on a given port, when it is closed? - Unix & Linux Stack Exchange

[ubuntu] Is "Stealth" more secure than "closed" ports?

これらの記事と今までの内容をまとめると、大体以下のようになるかと思います。筆者の意見もあります。

  • DROPを支持する意見
    • 攻撃者がポートスキャンを行う際にタイムアウトを待つ(1秒程度のオーダー)必要があるので、時間をかけさせることができる
    • 応答パケットを返さないので、PCへの負荷・回線への負荷・一部のDDoS攻撃への耐性の面で優れる
    • UDPの場合、DROPで統一していれば稼働中であってもアプリケーションの存在を完全に隠蔽できる
    • そもそも市販ルーターだとたいていDROP一択(なので、内側でもそれに従うしかない)
    • 単純にみんな使っているので無難(REJECTすると、積極的にネットワーク設定をいじる中上級ユーザーと思われて狙われるかも?的な)
    • google.comだってDROPしてたわけだし…
  • REJECTを支持する意見
    • 応答が一瞬で返ってくるので、悪意のないユーザーを不必要に待たせることがない
    • TCPの場合、tcp-resetでREJECTすることで、Linuxの「ポートが開放されているもののアプリケーションがいない」状態を模倣できる

まず最初に重要なのは、一番外側の(インターネットに晒されている)機器の動作に揃えなければいけないということです。攻撃者というのは大抵はLAN内ではなくLAN外から入ってくるので、外側とのゲートウェイである市販ルーターなどの動作に従って、ルーター経由でサービスを公開しているPCの動作も決める必要があります。となると、一般家庭ではDROP動作をするルーターがほとんどなので、全体をDROPで統一するしかないということになります。外からのアクセス経路がなくLAN内からでないとアクセスできないPCではこの限りではありませんが、わざわざ変える理由もないでしょう。

さらに、UDP/TCPの各プロトコルごとに見ていきます。

まずUDPについてはDROPが明確に有利なのではないかと思います。DROPで統一することで、稼働中のアプリケーションの存在を隠蔽できるというメリットがある一方で、REJECTを使うことによるメリットはあまり見当たりません。

一方、TCPについては、REJECTも悪くはないですが、やはりDROPのほうが無難なのは間違いないと思います。ポートが開いていれば確実にバレてしまうので、それ以外のポートがclosedだろうがfilteredだろうが、使っているポートを知られるかどうかという点では変わりません。でも、それだったら省エネで済むしスキャン時間も稼げるDROPのほうが幾分マシです。REJECTに明確なメリットは見当たりません。

REJECTを支持する意見のうち、「悪意のないユーザーを不必要に待たせることがない」については、そもそも悪意のないユーザーが開放していないポートにアクセスしてくることは通常ないので、あまり説得力がありません。

2番目の「tcp-resetによるREJECTに統一すれば、全てが単なる"閉じているポート"に見える」というのも、いくつかのサイトに書いてあり、一見それっぽいですが、今まで書いてきた通り、これはあくまで「Linuxの挙動を前提にしたメリット」でしかありません。Linuxは、開放に設定したポートにアプリケーションがいない場合にtcp-resetを返してしまうので、やむを得ず周りをそれに揃えたというだけの話です。Windowsであれば、アプリケーションがいない場合はポートも閉じているのでtcp-resetが返ることもなくDROPされ、それで問題なく全ポートの挙動が揃います。tcp-resetが優れているというより、tcp-resetを使わせるLinuxが悪いです。

UDPの場合もicmp-port-unreachableを使えば閉じているポートを模倣できますが、こちらはアプリケーションが起動しているポートだけが目立ってしまうという欠点があるのは既に述べた通りです。

以上の通り、基本的には全てDROPで統一すべきであるというのが筆者としての結論です。Linuxだとアプリケーションが閉じているときにそのアプリケーション用のポートの挙動が変わってしまいますが、それはLinuxの責任です。この後、これを何とかする方法を紹介します。

最後に、もう一度言っておきますが、DROP vs REJECTのどちらを選ぶかよりも、実際に脆弱なアプリケーションをインターネットに晒さないことが一番大事です。

Linuxの挙動をWindowsに近づける

ここまでで、我々はDROPを採用すべきであることがわかりました。Linuxを使う場合は、挙動をそれに適合させるための工夫が必要です。

現状の問題点は、「開放されているもののlistenされていない」ポートにアクセスがあったら、TCPならTCPのRSTパケット、UDPならICMPのport-unreachableが送り返されてしまうということでした。それなら、これらのパケットが送り返されることを防止できれば、それは単なる無視(=DROP)と同じ動作になります。

前述の通り、これらのパケットをLinuxカーネルが生成して送り出そうとするのを止めることはできませんが、生成されたパケットが実際にLinuxマシンから外に出ていこうとするタイミングで止めることは実は可能です。

このためにはufwのバックエンドでもあったiptables、あるいは最近のLinuxならnftablesを使います。筆者のUbuntuはnftablesを使っているようだったのでそちらを使いますがiptablesでも本質は変わらないと思います。

TCPなら、特定ポートから出ていくTCP RSTパケットを全滅させるルール、UDPなら特定のポートに関するicmp-port-unreachableパケットを全滅させるルールを追加すれば目的は達成できます。

ここで問題なのは、閉じているポートに来たアクセスへの応答以外の用途でこれらのパケットが使われることはないのか?ということです。他の用途で使われるにもかかわらず全て一律に止めてしまったら副作用が出るかもしれません。

ただ、まずicmp-port-unreachableは、その名の通りポートに到達できないときのエラーメッセージとしてしか使われず、プロトコルもICMPなので、これを止めたところで正当なUDP通信に影響することはありません。TCPのRSTに関しては、ソフトウェアによって送信されることもありますが、それは強制的に接続を切断しなければならないような異常事態が発生したときだけで、通常の接続終了(HTTPでページ読み込みが完了したとかゲームのサーバーからキックするとか)ではFINパケットなどが使われるのでRSTが使えなくても問題ありません。さらに、ポートが閉じている際のRSTパケットにはsequence番号が0であるという特徴がある(sequence番号が0ならポートが閉じているかというとそうとは限らないかもしれないが多分大体はそうっぽい)ので、これを活かしてポートが閉じている際のRSTだけをより限定的に禁止することが可能になります。

ただ、それでも従来のネットワーク環境への影響はできるだけ少なくしたいところです。そこで今回は、外部に公開するための専用のIPアドレスを設けて、そこから出ていくRSTやicmp-port-unreachableを禁止するというやり方でやってみます。

IPアドレスの追加と各種パケットの禁止

ここでは、PCのもともとのIPアドレスが192.168.1.2で、追加するIPアドレスが192.168.1.3であるとします。NIC名はeno1とします。PCの外側にはDROPに設定されたルーター(ポート開放機能あり)があるとします。

まず、以下のようなコマンドでIPアドレスを追加します。

sudo ip addr add 192.168.1.3/24 dev eno1 label eno1:1

再起動すると消えてしまうので、永続化する場合は/etc/network/if-up.dとかに書いてください。IP追加に関して詳しくは筆者の過去記事IPoE/PPPoE併用時(など)に一つの端末から同時に複数の接続経路を利用する - turgenev’s blogや他サイトもご参照ください。

次に、192.168.1.3から出ていくicmp-port-unreachableとTCPのresetを禁止します。TCPポートとしては8000を使うことにします。もちろん、全ポート対象や範囲指定も可能です。

まず、IP(v4)関連ルール追加用にテーブルを新規作成します(正直nftのベストプラクティスがよくわかっていないのですが、既存テーブルへの追加でも構いません)。

sudo nft add table ip my_ip_table 

次に、ここにpostroutingのfilterを設定するためのchainを追加します。

sudo nft add chain ip my_ip_table postrouting_filter "{type filter hook postrouting priority 700; }"

ここでpriorityを700と指定しているのがちょっと重要で、nftablesのルールというのはpriorityの値が小さいものから順に適用されていきます。47.3. nftables テーブル、チェーン、およびルールの作成および管理 Red Hat Enterprise Linux 8 | Red Hat Customer Portalにある通り、デフォルト設定で使われている値は-300から300くらいまでの範囲なので、300より大きい値(700は適当に選びました)を指定しておくことで、このfilterが最後に適用されるようにできます。

ちなみに、ここではpostroutingのhookを使っていますが、outputを使っても似たようなことができます。ただ、outputは少し適用タイミングが早く、パケットが生成されるときに適用されるので、後述のようにSNATをかけた後のパケットも対象にしたい場合は送信直前に適用されるpostroutingを使う必要があります。

そして、実際のルールを設定します。まずTCPです。

sudo nft add rule ip my_ip_table postrouting_filter ip saddr 192.168.1.3 tcp sport 8000 "tcp flags & rst == rst tcp sequence == 0 drop"

tcpポート8000から出ていくパケットのうちrstフラグを持っていて(flags & rstはビット演算)、かつsequenceが0であるものdropさせています。なお、特にsequence番号での振り分けルールはインターネット上にほとんど例が載っておらず、manページを読まないと書き方がわかりませんでした。構文がいくつかあり、"tcp flags & rst == rst @th,32,32 {0} drop"みたいにth(transport heading)からのビット数で指定する方法でも同じように動きます。iptablesの後に来るものは何か?: nftables - 赤帽エンジニアブログにあるような[payload load 4b @ transport header + 4 => reg 1] [cmp eq reg 1 0x00000000]みたいな構文(バイト数指定なので32ではなく4)もあるようですがこちらは正確な書き方がわからず手元では使えませんでした。

次にUDPです。

sudo nft add rule ip my_ip_table postrouting_filter ip saddr 192.168.1.3 icmp type destination-unreachable icmp code port-unreachable @th,240,16 {8000, 8080} drop

このようにtypeとcodeを用いてパケットの種別を指定した上で、先ほどのようにヘッダからの位置で元のUDP通信のdestination portが載っている部分を指定することで8000と8080ポートに関するport unreachableをdropさせています。この記法はほぼ何でもできてかなり強力ですね。なおICMPの仕様については6.5 ICMP Port Unreachable Error | TCP/IP Illustrated, Vol. 1: The Protocols (Addison-Wesley Professional Computing Series)を参考にしました。また、内側(元のUDP通信)のIPヘッダサイズが20であることを仮定しています。

今回は送信先を限定せずRSTやport-unreachableを一律に禁止したので、効果は自分自身からでも確かめることができます。

$ nmap -p 7999-8001 -sT 192.168.1.3 -Pn
PORT     STATE  SERVICE
7999/tcp closed irdmi2
8000/tcp filtered http-alt
8001/tcp closed vcom-tunnel

このように、192.168.1.3に対してnmapしたときはポート8000だけがfilteredとなっているはずです。192.168.1.2に対して実行したときは全てclosedになることを確かめてください。

同様に、UDPについても、192.168.1.3に対して実行したときは全てopen|filteredになることを確かめてください。(同じく7999-8001を指定していますが、何でも構いません)

$ sudo nmap -p 7999-8001 -sU 192.168.1.3 -Pn
7999/udp open|filtered irdmi2
8000/udp open|filtered irdmi
8001/udp open|filtered vcom-tunnel

これで、TCPの192.168.1.3:8000やUDPの192.168.1.3:*を外部から見えるようにしたとしてもアプリケーションが起動していないときはDROPの動作をするようになります。

自分自身やLAN内からアクセスしたときにDROPさせるかどうかなど、場合によって多少変えるべきところはあるかもしれませんが、基本的にはこれでLinuxの動作をほぼWindowsと同様にできます。

HTTPサーバーで試す

TCPのRSTパケットが正しくフィルタされているか確かめるため、ページ読み込みに5秒かかるHTTPサーバーを用意して実験してみました。サーバーはpythonで書きます。

from http.server import BaseHTTPRequestHandler, HTTPServer
import time

class SimpleHTTPRequestHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        # 5秒遅延
        time.sleep(5)
        # HTTPレスポンスヘッダを送信
        self.send_response(200)
        self.end_headers()
        # レスポンスボディを送信
        self.wfile.write(b'Hello, world!')
# サーバーを設定し、起動
port = 8000
httpd = HTTPServer(('', port), SimpleHTTPRequestHandler)
print(f"Server running on port {port}")
httpd.serve_forever()

そして、tcpdumpでパケットをキャプチャします。

sudo tcpdump -i any tcp port 8000

この状態で、例えばブラウザから192.168.1.3:8000にアクセスし、5秒の読み込み時間の途中でpythonを強制終了すると(sequenceが0でない)RSTパケットが送られているのがわかると思います。一方で、サーバーが起動していない(ポートが閉じている)ときのパケットは先ほどのルールで全てdropされ、tcpdumpには一切表示されません。サーバー停止時は、ブラウザ側ではずっと読み込み中の表示になります。

netcatで試す

こちらの方がより挙動がわかりやすいので追記しました。

netcat(ncコマンド)をTCPモードで使ったとき、接続をサーバー側から終了する(サーバーに対してCtrl+Cを送信する)と、クライアント側ではただちにコマンドが終了することはありませんが、その後にもう一度データを送信すると、サーバー側からRSTが送信され、これによりクライアント側の実行も終了します。このRSTは既存の接続に関連したものであるためsequence番号は0ではありません。(このへんの挙動について詳しくはtcpの仕様上、接続先がコネクションをcloseしているかはパケットを一度は実際に送るまでわからないよという話 #C - Qiitaに載っています)

従って、sequence番号が0のRSTだけDROPさせた場合は、上記の通りクライアント側の実行は終了しますが、その後にもう一度サーバーに接続しようとすると(サーバーは実行されていないので)sequence番号が0のRSTが生成されて、これはDROPされるので何も返ってきません。比較的良い感じの動作といえます。

一方ですべてのRSTをDROPさせてしまうと、サーバー側が終了した後にクライアントでデータを送信しても何も返ってこないためクライアント側の実行がしばらく(CLOSE_WAITのタイムアウトになるまで)終了しません。これはちょっと微妙な動作という気がします。

Tailscaleをうまく動かす

実は、この記事を書いた当初の目的はVPNサービスのTailscaleのために開放しているポートが(Tailscaleの停止時に)closedに見えてしまうのを防止するためでした。ここまでの設定でそれはできたのですが、このままだとTailscaleのUDPによるダイレクト接続がうまく成立しません。なぜなら、Tailscaleはoutgoing IPとしてメインの192.168.1.2のほうのIPを使おうとするため、外からの通信とうまくマッチしなくなってしまうからです。

Tailscaleに限らず、単に受動的にサービスを公開するだけでなく能動的に外部に出ていこうとするようなソフトウェアだと同様の問題が起こる可能性があります。

対処法としては、192.168.1.2と192.168.1.3の間でDNAT/SNATをかけて、外部との通信を全て192.168.1.3経由にしてしまいます。さっき作ったmy_ip_tableの中に、postroutingとprerouting向けのnatタイプのチェインを追加し、それぞれに対してルールを追加します。ここではポート番号としてTailscaleのデフォルトの41641を使用しています。

sudo nft add chain ip my_ip_table postrouting_nat "{type nat hook postrouting priority srcnat;}"
sudo nft add chain ip my_ip_table prerouting_nat "{type nat hook prerouting priority dstnat;}"
sudo nft add rule ip my_ip_table postrouting_nat ip saddr 192.168.1.2 udp sport 41641 snat to 192.168.1.3:41641
sudo nft add rule ip my_ip_table prerouting_nat ip daddr 192.168.1.3 udp dport 41641 dnat to 192.168.1.2:41641

これで、あとはルーター側の41641番ポートを192.168.1.3:41641にマッピングすればTailscaleのポートが公開された状態になります。起動中も停止中も外部からポート番号を推測されることはありません。

なお、192.168.1.2:41641は直接的には使えなくなってしまいますが、LAN内端末との通信でも192.168.1.3:41641を使ってちゃんと接続してくれるようです。問題があれば、snatルールからLAN内(プライベートIP)との通信を除外するなどの方法で対処できるかと思います。

設定結果、永続化など

今まで述べたnftコマンドでの設定内容をまとめて確認するため、sudo nft list rulesetで確認するとmy_ip_tableの内容は以下のようになっていると思います。

table ip my_ip_table {
        chain postrouting_filter {
                type filter hook postrouting priority 700; policy accept;
                ip saddr 192.168.1.3 icmp code port-unreachable drop
                ip saddr 192.168.1.3 tcp sport 8000 tcp flags & rst == rst tcp sequence 0 drop
        }
        chain postrouting_nat {
                type nat hook postrouting priority srcnat; policy accept;
                ip saddr 192.168.1.2 udp sport 41641 snat to 192.168.1.3:41641
        }
        chain prerouting_nat {
                type nat hook prerouting priority dstnat; policy accept;
                ip daddr 192.168.1.3 udp dport 41641 dnat to 192.168.1.2:41641
        }
}

もし意図した通りに動いていない場合はsudo nft list rulesetの結果をこれと見比べてみましょう。

IPアドレスの追加と同様、nftによる設定も再起動後には消えてしまうので永続化する必要があります。

まず、nftablesサービスを有効化します(sudo systemctl enable nftables)。これはシステム起動時にnftablesの設定ファイル(ubuntuなら/etc/nftables.conf)の読み込みを行うOneShotタイプのサービスです(常時稼働のデーモンではない)。その上で、nftables.confに先ほどのmy_ip_tableの内容をそのまま追加します。あるいはincludeを使ってファイルを分けることもできるようです。検索すると、sudo nft list rulesetの結果をそのままnftables.confに書き込むものが多く出てきますが、tailscaleなどによって動的に挿入されたルールなどが混入してしまうため、個人的には元ファイルを直接いじった方がいい気がします。

結論

ファイアウォールの挙動としてDROP vs REJECTのどちらが良いかという論争がありますが、多数派がDROPであること、UDPのサービスを隠蔽できることなどからDROPを選択すべきです。Linuxファイアウォールには、閉じているポートの動作がDROPにならないというWindowsファイアウォールにはない欠陥がありますが、これはiptables/nftablesで適切にルールを設定することでおそらく通常使用にほとんど影響を与えずに解決することができます。