EthereumのP2Pネットワークまわりの仕様について、まずノード探索の仕組みについて理解する。
Ethereumのノード探索の仕組み
Ethereumは分散ハッシュテーブル(DHT)の一種であるKademliaをベースにしたノード検出プロトコルを実装している(Kademlia自体は分散ノードがデータを取得・保存するためのUDPベースのプロトコルだが、Ethereumではノードの検出とルーティングのみに利用している)。
Ethereumのノードが最初にEthereumネットワークに参加する際は、まずソースコードにハードコードされているブートストラップノードにアクセスする。現時点でgethに登録されているmainnetのブートストラップノードは以下の8個で、いずれもAWSもしくはAzureの各地のリージョンでホストされてる。
"enode://d860a01f9722d78051619d1e2351aba3f43f943f6f00718d1b9baa4101932a1f5011f16bb2b1bb35db20d6fe28fa0bf09636d26a87d31de9ec6203eeedb1f666@18.138.108.67:30303", // bootnode-aws-ap-southeast-1-001 "enode://22a8232c3abc76a16ae9d6c3b164f98775fe226f0917b0ca871128a74a8e9630b458460865bab457221f1d448dd9791d24c4e5d88786180ac185df813a68d4de@3.209.45.79:30303", // bootnode-aws-us-east-1-001 "enode://ca6de62fce278f96aea6ec5a2daadb877e51651247cb96ee310a318def462913b653963c155a0ef6c7d50048bba6e6cea881130857413d9f50a621546b590758@34.255.23.113:30303", // bootnode-aws-eu-west-1-001 "enode://279944d8dcd428dffaa7436f25ca0ca43ae19e7bcf94a8fb7d1641651f92d121e972ac2e8f381414b80cc8e5555811c2ec6e1a99bb009b3f53c4c69923e11bd8@35.158.244.151:30303", // bootnode-aws-eu-central-1-001 "enode://8499da03c47d637b20eee24eec3c356c9a2e6148d6fe25ca195c7949ab8ec2c03e3556126b0d7ed644675e78c4318b08691b7b57de10e5f0d40d05b09238fa0a@52.187.207.27:30303", // bootnode-azure-australiaeast-001 "enode://103858bdb88756c71f15e9b5e09b56dc1be52f0a5021d46301dbbfb7e130029cc9d0d6f73f693bc29b665770fff7da4d34f3c6379fe12721b5d7a0bcb5ca1fc1@191.234.162.198:30303", // bootnode-azure-brazilsouth-001 "enode://715171f50508aba88aecd1250af392a45a330af91d7b90701c436b618c86aaa1589c9184561907bebbb56439b8f8787bc01f49a7c77276c58c1b09822d75e8e8@52.231.165.108:30303", // bootnode-azure-koreasouth-001 "enode://5d6d7cd20d6da4bb83a1d28cadb5d409b64edf314c0335df658c1a54e32c7c4a7ab7823d57c39b6a757556e68ff1df17c748b698544a55cb488b52479a92b60f@104.42.217.25:30303", // bootnode-azure-westus-001
Ethereumのノードを初めて起動するとまず↑のブートストラップノードのノードIDを、自身のルーティングテーブルに追加する。
ノードIDとノード間の距離
ノードIDはノードを識別するためのIDで、↑の値からも分かるように512 bitの楕円曲線secp256k1の公開鍵でもある。つまり楕円曲線の公開鍵のX座標、Y座(各32バイト)のhex値を連結したものがノードID。でも圧縮公開鍵フォーマットにすれば33バイトにできるけど、何で64バイトにしてるんだろう?
ノードが別のノードと接続することでネットワークが形成されていく訳だが、Kademliaでは接続対象のノードを選択する際に、自分との距離が近いノードを選択する仕組みになっている。そのため、各ノードはそれぞれピアのリストを保持しているが、各ノードが全く同じピアリストを保持する訳ではなく、各ノードが保持するピアのリストには距離による偏りがある。この距離は、ノードIDをそれぞれa, bとすると、Ethereumの場合*1それぞれのKeccak-256ハッシュ値を計算し、そのハッシュ値のビット単位のXORを計算し数値にしたものである。つまり↓
ノードa,b間の距離 = Keccak-256(a) XOR Keccak-256(b)
ルーティングテーブル
↑でブートストラップノードのノードIDを、自身のルーティングテーブルに追加すると書いたが、このルーティングテーブルによりピアのリストを管理する。このテーブルのピアリストが、自身がアウトバウンドピアの選択に使われる。ルーティングテーブルは要素を最大k
個保持できる複数のbucketで構成されることからk-bucket
と呼ばれる。Ethereumの場合、k = 16
で、bucketの数は256個(bucketのインデックスをi
とすると0 ≦ i < 256)*2。※ 上位のbucketはずっと空のままであることが分かり、Geth 1.8.0以降はbucketの数は17個まで減っている。
発見したノードの情報は、ノード間の距離に応じて各bucketに挿入される。bucket i
に格納されるノード情報は、ノード間の距離がまでの範囲のノードの情報になる。そのため、どのbucketに挿入するかは↑の距離の対数を計算すればいい。
ノードa,b間の対数距離 = floor( log2( Keccak-256(a) XOR Keccak-256(b) ) )
新しいノードが見つかると、↑の距離に基づき対象のbucketに挿入されるが、既にbucket内にk
個のノードが存在する場合は、bucket内でもっとも長い期間応答が確認されていないノードをピックアップし、PING
パケットを送信する。ノードから応答がない=PONG
が返ってこない場合、無効と判断され、そのノードはbucketから削除され、新しいノードのノード情報が追加される。
※ このルーティングテーブルの情報は永続化されないため、ノードが再起動と通常空になる。
初期のノード探索
- ノードは↑のルーティングテーブルからターゲット(自分のノードID)と最も近い
α
個*3のピアをピックアップする。初回起動時は最初に追加したブートストラップノードがあるだけだと思うので、その中から距離の近いものをα
個選ぶ形になる。 - 選択した
α
個のノードに対して、ターゲットノードID(自分のノードID)を指定してFindNode
パケットを送信する。 FindNode
を受信したノードは、自身のルーティングテーブルから指定されたターゲットノードIDに最も近いk
個のノードリストをピックアップし、それらをセットしたNeighbors
メッセージで応答する*4。k
個のノードリストを受け取ったら、このプロセスで新しく発見したノード情報(ノードID、IPアドレス、TCPポート、UDPポート)を自分のルーティングテーブルに追加する。
まだ問い合わせをしていないノードについて↑のプロセスを繰り返す。問い合わせの結果、既に確認済みの最も近いノードよりも近いノードを返さなくなったら(把握している最も近いk
個のノードにFIND_NODE
を再送信し、応答を得た結果、最も近いk
個のノードが変わらなければ)、初期のノード探索は終了する。
ノード探索後
上記のようなノード探索にはUDPが使われ、現在以下の6個のメッセージが定義されている。近隣ノードの探索が終わったら、検出したノードに接続し、ハンドシェイク、チェーンの同期を開始するが、これらはRLPxやDEVp2pプロトコルで行われる。RLPx以降の通信はTCP接続。
UDPメッセージ
現在サポートされているUDPメッセージは↓
- Ping:ピアに定期的に送られる接続の有効性を確認するためのメッセージ。
- Pong:Pingメッセージへの応答。
- FindNode:ピアにターゲットIDの近隣のピアリストを要求するためのメッセージ。
- Neighbors:FindNodeメッセージへの応答で、ターゲットIDの近隣のピアリストが含まれる。
- ENRRequest:ピアにノードのレコード情報(ENR)を要求するためのメッセージ。
- ENRResponse:ENRRequestメッセージへの応答で、現在のバージョンのノードのレコード情報(ENR)が含まれる。
エクリプス攻撃への対応
KademliaベースのEthereumのノード探索の仕組みをみてきたが、この仕組みについて、これまでいくつかのエクリプス攻撃に対する脆弱性が報告されている。
Low-Resource Eclipse Attacks on Ethereum’s Peer-to-Peer Network
この攻撃では、攻撃者が単一のIPアドレスと2台のマシンだけあれば攻撃可能になるという低コストの攻撃方法のペーパー。
https://eprint.iacr.org/2018/236.pdf
ピア接続スロットを独占する攻撃
攻撃方法はシンプルで、ノードが起動して近隣ノードを見つけアウトバウンドピアの接続を確立するまでの間に、攻撃者がターゲットノードに多数の接続を作成し、ノードのTCP接続ピアの最大数maxpeers
を独占する。こうなるとノードは新たなピアの接続ができなくなり、エクリプス攻撃が成立するというもの。つまり、一方的なインバウンド接続でノードが接続可能なピアのスロットを独占する攻撃。
攻撃への対策
この攻撃を無効化する方法は簡単で、インバウンドピアの接続数を制限すればいい。Geth v1.8.0ではインバウンドのピア数はmaxpeers
数の1/3に制限する実装がされている。
ルーティングテーブルのノード情報を独占する攻撃
上記の対策がされた場合でも可能な攻撃として、↑のペーパーでは、ノードIDを細工して攻撃するアプローチも挙げられている。ノード情報をbucketにマッピングするルールは↑のように明示的に決まっているので、攻撃者は自身が作成したノードIDが標的となるノードのルーティングテーブルのどのbucketに入るか簡単に予測できる。これを利用して、攻撃者はターゲットのルーティングテーブル内のbucketを満たすようなノードIDを多数生成し、ターゲットのルーティングテーブルを埋めることでアウトバウンドピアスロットを独占し、↑と同じ攻撃でインバウンドのピアスロットも独占すればエクリプス攻撃が成功する。
攻撃への対策
Geth 1.8.0では上記の攻撃に対して以下の対策が取られてる。
- IPアドレスの制限
ルーティングテーブルにノードを追加する際にIPアドレスの制限を加える。1つのbucketにつき、/24サブネットが同じIPアドレスのノードは2つまでしか許可されず、テーブル全体では/24サブネットが同じIPアドレスのノードは最大10個までに制限される。この対策により、攻撃を可能にするためにはより広い範囲のIPが必要になるので、これにより攻撃のハードルを上げるのが狙い。この制限はBitcoin Coreの/16プレフィックスルールと似ている。 - シードプロセスを常に実行
ルーティングテーブルが空でない場合もシードプロセスを常に実行する。こうすることで正直なアウトバウンドピアへの接続をする機会を増やす。 - リブート時の悪用時間枠の排除
↑の攻撃はターゲットに再起動させ、その間にDNSを使った探索(シードプロセス)が終わる前にUDPリスナーに対してPingメッセージを送信することでアウトバウンドのスロットを独占するものになるが、これをノードのシードプロセスが完了してからPingメッセージを処理するようにすることで、攻撃のハードルを上げる。
ネットワークからターゲットを消去する攻撃
これはNTPの攻撃が含まれるので難易度は上がる。↑のUDPメッセージには全てタイムスタンプが付与されていて、UDPメッセージを受信したノードは(リプレイ攻撃への対策として)そのタイムスタンプがローカルの時刻と比較し、20秒以上古かったらUDPメッセージをすべてドロップするようになっている。この振る舞いを悪用し、NTPを攻撃するなどしてターゲットノードのクロックに偽の時刻を設定する。具体的には20秒以上未来の時刻を設定する。すると、
- ターゲットノードが他のノードから受け取るUDPメッセージは全てドロップされるため、ターゲットは正直なノードを認識しなくなる。
- 結果、他の正直なノードは(ターゲットノードがレスポンスを返さないので)ターゲットノードを認識しなくなる。
このような状況ができると、攻撃者が自身のクロックをターゲットに合わせた上で接続すればエクリプス攻撃は成功する。
攻撃への対策
ペーパーではタイムスタンプの代わりにnonceを使う方法を提案しているが、現状のパケットフォーマットと互換性がないので採用されなかったみたい。
Eclipsing Ethereum Peers with False Friends
https://arxiv.org/pdf/1908.10141.pdf
これは↑の対策がされた中で、/24サブネットの異なる2つのホストのみで攻撃を可能とする攻撃方法のペーパー。
エクリプス攻撃のためには↑で出てきたように、アウトバウンドとインバウンドのピアを独占すればいい。
Geth 1.8.0でインバウンド接続の上限は制限されたが、インバウンドについてはIPアドレスの制限はないので、1つのIPを使ってターゲットノードのインバウンドスロットを埋める攻撃は可能。
アウトバウンドスロットを埋めるには必ずしも、これまでの攻撃のようにルーティングテーブルを全て埋める必要は実はない。アウトバウンド接続の候補を選択する際、ReadRandomNodes
関数でルーティングテーブルから対象ノードを選択するようになっているが、この関数はランダムに選択されたbucketの先頭のみを返すという挙動をしている。これはbucketの先頭のピアが他のプアよりもアクティブであるかレイテンシーが優れているため。ただアクティブかどうかは敵対者がPingをよく送ればいいだけなので悪用される可能性が高い。そのため攻撃者はルーティングテーブル全体を自分のピアで埋める必要はなく各バケットに1つだけノードを挿入できればいい。ルーティングテーブルのbucket数は現在17個なので、各bucketに1つずつ17個のbucketに挿入できればいい。Gethの/24サブネットのルール上、bucket全体で/24サブネットが同じIPは10個まで許可されるので、攻撃には/24サブネットが異なる2つのIPがあれば済む。
攻撃への対策
上記の対策としてGeth 1.9.0から
- Gethが接続するピアの総数を25から50に倍増。
ReadRandomNodes
関数がbucketの先頭ではなく、ランダムにノードを選択するよう変更。- 同じIPからのインバウンド接続に対しては30秒待機するよう変更。
という変更が加えられ↑の攻撃を不可能にする対策が取られている。
ただペーパーでは、Kademliaベースのノード探索より、Bitcoinで採用しているAddrmanのようなアプローチへの切り替えを推奨している。この点については、確かにノード探索のためにだけであればKademliaベースの仕組みを採用する必要もないようにも思える。