Cloudflareは本日、Spectrumの提供を開始します。Spectrumは、TCPベースのプロトコル向けのCloudflareの新たな機能で、DDoS攻撃に対する防御、ロードバランシング、およびコンテンツ高速化を実現します。

13334109713_0b32435032_z
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アドレスの1つでポート8080にバインドすることができます。

nc -l 192.0.2.1 8080

IPv6を動作させるのは少し難しくなります。

ip route add local 2001:db8::/64 dev lo

残念ながら、これらのアタッチされたv6IPアドレスには、v4の例のようにバインドすることはできません。これを機能させるには、さらに上の権限が必要な IP_FREEBIND ソケットオプションを使用する必要があります。完全性を確保するためにsysctl net.ipv6.ip_nonlocal_bindを使う方法もありますが、ここで触れることはお勧めしません。

このAnyIPのトリックを使用すると、数百万のIPアドレスを各サーバーにローカルに割り当てることができます。

$ 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
...

すべてのポートへのバインド

2つ目の大きな問題は、任意のポート番号に対してTCPソケットを開く機能です。Linuxおよび、一般にBSDソケットAPIをサポートしている任意のシステムでは、特定のTCPポート番号へのバインドは、単一のバインドシステム呼び出しでしか行うことができません。単一操作で複数ポートへのバインドは不可能です。

単純に考えると、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ファイアウォール
  • ネットワークスタック

これらは概念的に異なるものです。IPファイアウォールは通常、ステートレスなソフトウェアです(ここでは、conntrackとIPフラグメントの再構築は無視します)。ファイアウォールはIPパケットを分析し、それらを受け入れるかドロップするかを決定します。注意:この層では、アプリケーションやソケットではなく、パケットとポート番号を話題にしています。

次に、ネットワークスタックがあります。ここには多くの状態が維持されています。ネットワークスタックの主なタスクは、受信するIPパケットをソケットにディスパッチすることで、パケットはユーザー空間のアプリケーションによって処理されます。ネットワークスタックは、ユーザー空間と共有される抽象化を管理しています。TCPフローを再構成し、ルーティングを処理し、ローカルなIPアドレスを認識します。

魔法の粉

upload-1
出典:こちらもYouTube

ある時点で、TPROXY iptablesモジュールに偶然行き当たりました。公式ドキュメントは見落としてしまいやすいです。

TPROXY
このターゲットは、PREROUTINGチェーンおよび
ここからしか呼び出せないユーザー定義のチェーン内の
mangleテーブル内でのみ、有効です。 パケットヘッダーを変えることなく、
パケットをローカルソケットにリダイレクトします。また、
マーク値を変更して、高度なルーティングルールで
使用することもできます。

カーネルには、別に、次のドキュメントもあります。

考えるほど、もっと知りたくなりました。

つまり...TPROXYは実際に何をするのかということです。

魔法のトリックを明らかにする

TPROXYコードは驚くほど単純です。

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);

声に出して読み上げます。ファイアウォールの一部であるiptablesモジュールでは、私たちはinet_lookup_listenerと呼んでいます。この機能はsrc/dst port/IP 4-tupleを使い、接続を受け入れることのできるリスニングソケットを戻します。これは、ネットワークスタックのソケットディスパッチのコア機能です。

繰り返します。ファイアウォールコードはソケットディスパッチルーチンを呼び出します。

この後、TPROXYは実際にソケットディスパッチを行います

skb->sk = sk;

この行は、 socket struct sockをインバウンドパケットに割り当てて、ディスパッチを完了します。

帽子からウサギを取り出す

3649474619_3b800400e9_z-1
CC BY-SA 2.0 画像作者Angela Boothroyd

TPROXYで武装することで、bind-to-all-portsのトリックをきわめて容易に実行することができます。構成を次に示します。

# AnyIPで192.0.2.0/24をローカルにルーティングされるように設定します。
# このネットワークに使用される送信元IPを明示的に指定し、
# ローカルでの接続時は127.0.0.0/8の範囲にする必要があります。
# それ以外の場合はフォワードとバックワードの両方のトラフィックにTPROXYルールが
# 一致するため、これが必要です。これで捕捉させたいのは、
# フォワードトラフィックのみです。
sudo ip route add local 192.0.2.0/24 dev lo src 127.0.0.1

# 魔法のTPROXYルーティングを設定
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

この設定に加えて、魔法のIP_TRANSPARENTソケットオプションでTCPサーバーを起動する必要があります。次の例は、tcp://127.0.0.1:1234でリッスンする必要があります。IP_TRANSPARENTのマニュアルページには、次のように掲載されています。

IP_TRANSPARENT(Linux 2.6.24以降)
このブール値オプションを設定すると、透過プロキシが
このソケットで有効になります。 このソケットオプションは、呼び出しているアプリ
ケーションを非ローカルのIPアドレスにバインドして、
外部アドレスをローカルエンドポイントとして両方を
クライアントおよびサーバーとして動作させることができます。 注:これには、外部アドレスに向かうパケットを
TPROXYボックス経由でルーティングする方法でルーティングを
セットアップする必要があります(つまり、システムが
IP_TRANSPARENTソケットオプションを採用しているアプリケーションをホストしている)。
このソケットオプションを有効にするには、スーパーユーザー特権が必要です。
(CAP_NET_ADMIN機能)。

iptables TPROXYターゲットによるTProxyのリダイレクトにも、
このオプションをリダイレクトされたソケットに設定しておく必要があります。

シンプルなPythonサーバーを次に示します。

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()

サーバーの実行後は、任意のIPアドレスからサーバーに接続できます。

$ nc -v 192.0.2.1 9999
Connection to 192.0.2.1 9999 port [tcp/*] succeeded!
hello world

最も重要なのは、サーバーは接続をレポートし、誰もそのIPアドレスおよびポートをリッスンしていないにも関わらず、192.0.2.1ポート9999にダイレクトされたことです。

$ 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

いかがでしょう!これが、conntrackを使用せずにLinux上の任意のポートにバインドする方法です。

これがすべてです

この記事では、元々透過的プロキシ用に設計されたものを少し違う用途で使用するための、目立たないiptablesモジュールの使用方法を説明しました。このモジュールを借りて、標準のBSDソケットAPIを使用して不可能と思われたことを実行することができ、カスタムのカーネルによるパッチを使う必要性も回避することができます。

TPROXYモジュールは非常に珍しく、Linuxのファイアウォールの文脈では基本的にLinuxのネットワークスタックが実行することを実行します。公式ドキュメントが幾分不足しており、このモジュールの本当の力を理解しているLinuxユーザーはそれほど多くないと思われます。

TPROXYにより、vanillaカーネル上で当社のSpectrum製品がスムーズに動作すると言って良いでしょう。。また、iptablesとネットワークスタックについて理解することが、いかに重要かについても覚えておくとよいでしょう。


低レベルのソケットの作業に興味をお持ちですか?ロンドン、オースティン、サンフランシスコの世界的に有名なチーム、ポーランドのワルシャワの精鋭部隊に、ぜひご参加ください。


  1. IPアドレスを、appropriate rp_filterおよびBGP構成でループバックインタフェースに割り当てることで、任意のIP範囲を当社エッジサーバーで処理することができます。↩︎