Cloudflareは本日、Spectrumの提供を開始します。Spectrumは、TCPベースのプロトコル向けのCloudflareの新たな機能で、DDoS攻撃に対する防御、ロードバランシング、およびコンテンツ高速化を実現します。
CC BY-SA 2.0 画像作者Staffan Vilcans
Spectrumの構築を開始した直後、私たちは大きな技術的障害にぶつかりました。Spectrumでは、有効なTCPポート(1~65535)で接続を受け入れる必要があります。当社のLinuxエッジサーバーでは、「_任意の_ポート番号で着信接続を受け入れる」ことは不可能です。これはLinux固有の制限ではなく、ほとんどのオペレーティングシステムのネットワークアプリケーションの基礎である、BSDソケットAPIの特性です。実は、Spectrumを提供するために解決しなければならなかった、以下の2つの互いに関わりあう問題がありました。
1~65535のすべてのポート番号でTCP接続を受け入れる方法
単一Linuxサーバーに、きわめて多数のIPアドレスでの接続を受け入れるよう設定する方法(当社のエニーキャスト範囲内には数千のIPアドレスが存在)
サーバーへの数百万IPの割り当て
Cloudflareでは、すべてのエッジサーバーがほぼ同じ設定になっています。当社の初期の頃には、ループバックネットワークインタフェースに一定の/32(および/128)IPアドレスを割り当てていました[1]。IPアドレスの単位が数十のうちは上手くいきましたが、会社の成長に伴って規模の拡張に失敗しました。
同時に「AnyIP」のトリックも問題になりました。AnyIPを使用すると、一定のIPアドレスから拡張し、IPプレフィックス(サブネット)全体のループバックインタフェースへの割り当てが可能になります。すでにAnyIPは広く利用されており、皆さんのコンピューターにはループバックインタフェースに127.0.0.0/8が割り当てられています。ユーザーのコンピューター側から見た場合、127.0.0.1から127.255.255.254までのすべてのIPアドレスはローカルマシンに属しています。
このトリックは、127.0.0.1/8ブロック以外にも適用可能です。192.0.2.0/24の範囲全体をローカルに割り当てたものとして扱うには、次を実行します。
ip route add local 192.0.2.0/24 dev lo
ip route add local 192.0.2.0/24 dev lo
nc -l 192.0.2.1 8080
次に、これらのIPアドレスの1つでポート8080にバインドすることができます。
ip route add local 2001:db8::/64 dev lo
nc -l 192.0.2.1 8080
IPv6を動作させるのは少し難しくなります。
$ ip addr show
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536
inet 1.1.1.0/24 scope global lo
valid_lft forever preferred_lft forever
inet 104.16.0.0/16 scope global lo
valid_lft forever preferred_lft forever
...
ip route add local 2001:db8::/64 dev lo
残念ながら、これらのアタッチされたv6IPアドレスには、v4の例のようにバインドすることはできません。これを機能させるには、さらに上の権限が必要な IP_FREEBIND
ソケットオプションを使用する必要があります。完全性を確保するためにsysctl net.ipv6.ip_nonlocal_bind
を使う方法もありますが、ここで触れることはお勧めしません。
このAnyIPのトリックを使用すると、数百万のIPアドレスを各サーバーにローカルに割り当てることができます。
すべてのポートへのバインド
2つ目の大きな問題は、任意のポート番号に対してTCPソケットを開く機能です。Linuxおよび、一般にBSDソケットAPIをサポートしている任意のシステムでは、特定のTCPポート番号へのバインドは、単一のバインドシステム呼び出しでしか行うことができません。単一操作で複数ポートへのバインドは不可能です。
/* Yes, really, this is all you need. */
#define INET_LHTABLE_SIZE 32
単純に考えると、65535のポートに、1回ずつ、65535回バインドすることになります。確かにこのような方法もあったかもしれませんが、これは次のような酷い結果につながります。
内部的には、Linuxカーネルは、正確に32バケットを使用して、ポート番号でインデックスを指定したリスニングソケットをハッシュテーブルLHTABLEに保存します。
/* はい、本当に、これが必要なすべてです。 */
#define INET_LHTABLE_SIZE 32
65,000のポートを開いていたら、このテーブルのルックアップには長い長い時間がかかったでしょう。ハッシュテーブルの各バケットには2,000のアイテムが入っているからです。
問題を解決するもう1つの方法は、iptablesの豊富なNAT機能の使用です。一部の特定のアドレス/ポートへの着信パケットの宛先を書き換えて、アプリケーションをそれにバインドさせることができます。
しかし、これにはiptablesのconntrackモジュールを有効にする必要があるため、この方法は採りませんでした。過去にはパフォーマンスエッジケースがあり、conntrackは当社が被った大規模なDDoS攻撃の一部には対処できませんでした。
さらに、NATアプローチでは、宛先IPアドレス情報が失われる可能性があります。これを緩和するためにあまり知られていないSO_ORIGINAL_DSTソケットオプションもありますが、あまり使いたくなるようなコードではありません。
幸い、65000のポートすべてをバインドするもしくは conntrack
を使用する以外の方法で、目標を達成する方法があります。
ファイアウォールで対応
先に進む前に、オペレーティングシステム上でのネットワークパケットの流れについて復習しておきましょう。
一般に、受信パケットの経路には次の2つの異なる層があります。
IPファイアウォール
ネットワークスタック
TPROXY
This target is only valid in the mangle table, in the
PREROUTING chain and user-defined chains which are only
called from this chain. It redirects the packet to a local
socket without changing the packet header in any way. It can
also change the mark value which can then be used in
advanced routing rules.
これらは概念的に異なるものです。IPファイアウォールは通常、ステートレスなソフトウェアです(ここでは、conntrackとIPフラグメントの再構築は無視します)。ファイアウォールはIPパケットを分析し、それらを受け入れるかドロップするかを決定します。注意:この層では、_アプリケーションやソケットではなく、パケットとポート番号_を話題にしています。
次に、ネットワークスタックがあります。ここには多くの状態が維持されています。ネットワークスタックの主なタスクは、受信するIPパケットを_ソケット_にディスパッチすることで、パケットはユーザー空間の_アプリケーション_によって処理されます。ネットワークスタックは、ユーザー空間と共有される抽象化を管理しています。TCPフローを再構成し、ルーティングを処理し、ローカルなIPアドレスを認識します。
魔法の粉
出典:こちらもYouTube
ある時点で、TPROXY iptablesモジュールに偶然行き当たりました。公式ドキュメントは見落としてしまいやすいです。
カーネルには、別に、次のドキュメントもあります。
case NFT_LOOKUP_LISTENER:
sk = inet_lookup_listener(net, &tcp_hashinfo, skb,
ip_hdrlen(skb) +
__tcp_hdrlen(tcph),
saddr, sport,
daddr, dport,
in->ifindex, 0);
考えるほど、もっと知りたくなりました。
つまり...TPROXYは実際に何を_する_のかということです。
skb->sk = sk;
魔法のトリックを明らかにする
TPROXYコードは驚くほど単純です。
声に出して読み上げます。ファイアウォールの一部であるiptablesモジュールでは、私たちはinet_lookup_listenerと呼んでいます。この機能はsrc/dst port/IP 4-tupleを使い、接続を受け入れることのできるリスニングソケットを戻します。これは、ネットワークスタックのソケットディスパッチのコア機能です。
繰り返します。ファイアウォールコードはソケットディスパッチルーチンを呼び出します。
# Set 192.0.2.0/24 to be routed locally with AnyIP.
# Make it explicit that the source IP used for this network
# when connecting locally should be in 127.0.0.0/8 range.
# This is needed since otherwise the TPROXY rule would match
# both forward and backward traffic. We want it to catch
# forward traffic only.
sudo ip route add local 192.0.2.0/24 dev lo src 127.0.0.1
# Set the magical TPROXY routing
sudo iptables -t mangle -I PREROUTING \
-d 192.0.2.0/24 -p tcp \
-j TPROXY --on-port=1234 --on-ip=127.0.0.1
この後、TPROXYは実際にソケットディスパッチを_行います_。
IP_TRANSPARENT (since Linux 2.6.24)
Setting this boolean option enables transparent proxying on
this socket. This socket option allows the calling applica‐
tion to bind to a nonlocal IP address and operate both as a
client and a server with the foreign address as the local
end‐point. NOTE: this requires that routing be set up in
a way that packets going to the foreign address are routed
through the TProxy box (i.e., the system hosting the
application that employs the IP_TRANSPARENT socket option).
Enabling this socket option requires superuser privileges
(the CAP_NET_ADMIN capability).
TProxy redirection with the iptables TPROXY target also
requires that this option be set on the redirected socket.
skb->sk = sk;
import socket
IP_TRANSPARENT = 19
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.setsockopt(socket.IPPROTO_IP, IP_TRANSPARENT, 1)
s.bind(('127.0.0.1', 1234))
s.listen(32)
print("[+] Bound to tcp://127.0.0.1:1234")
while True:
c, (r_ip, r_port) = s.accept()
l_ip, l_port = c.getsockname()
print("[ ] Connection from tcp://%s:%d to tcp://%s:%d" % (r_ip, r_port, l_ip, l_port))
c.send(b"hello world\n")
c.close()
この行は、 socket struct sock
をインバウンドパケットに割り当てて、ディスパッチを完了します。
$ nc -v 192.0.2.1 9999
Connection to 192.0.2.1 9999 port [tcp/*] succeeded!
hello world
帽子からウサギを取り出す
$ sudo python3 transparent2.py
[+] Bound to tcp://127.0.0.1:1234
[ ] Connection from tcp://127.0.0.1:60036 to tcp://192.0.2.1:9999
CC BY-SA 2.0 画像作者Angela Boothroyd
TPROXYで武装することで、bind-to-all-portsのトリックをきわめて容易に実行することができます。構成を次に示します。
この設定に加えて、魔法のIP_TRANSPARENTソケットオプションでTCPサーバーを起動する必要があります。次の例は、tcp://127.0.0.1:1234でリッスンする必要があります。IP_TRANSPARENTのマニュアルページには、次のように掲載されています。
シンプルなPythonサーバーを次に示します。
サーバーの実行後は、任意のIPアドレスからサーバーに接続できます。
最も重要なのは、サーバーは接続をレポートし、_誰もそのIPアドレスおよびポートをリッスンしていないにも関わらず、_192.0.2.1ポート9999にダイレクトされたことです。
いかがでしょう!これが、conntrackを使用せずにLinux上の任意のポートに_バインドする方法です。_
これがすべてです
この記事では、元々透過的プロキシ用に設計されたものを少し違う用途で使用するための、目立たないiptablesモジュールの使用方法を説明しました。このモジュールを借りて、標準のBSDソケットAPIを使用して不可能と思われたことを実行することができ、カスタムのカーネルによるパッチを使う必要性も回避することができます。
TPROXYモジュールは非常に珍しく、Linuxのファイアウォールの文脈では基本的にLinuxのネットワークスタックが実行することを実行します。公式ドキュメントが幾分不足しており、このモジュールの本当の力を理解しているLinuxユーザーはそれほど多くないと思われます。
TPROXYにより、vanillaカーネル上で当社のSpectrum製品がスムーズに動作すると言って良いでしょう。。また、iptablesとネットワークスタックについて理解することが、いかに重要かについても覚えておくとよいでしょう。
低レベルのソケットの作業に興味をお持ちですか?ロンドン、オースティン、サンフランシスコの世界的に有名なチーム、ポーランドのワルシャワの精鋭部隊に、ぜひご参加ください。
IPアドレスを、appropriate rp_filterおよびBGP構成でループバックインタフェースに割り当てることで、任意のIP範囲を当社エッジサーバーで処理することができます。↩︎