Eclipse Attacks 日蝕攻擊 : 區塊鏈世界的中間人攻擊 (一)
在傳統的client-server網路架構中,存在著所謂的中間人攻擊:攻擊者可以攔截通訊雙方的通話並插入新的內容。而在區塊鏈的世界中,也存在著類似的攻擊手法,稱為日蝕攻擊。本文將以波士頓大學團隊的一篇論文為基礎,對日蝕攻擊進行一個基本的介紹。
這篇介紹文預計分成兩部分,第一部分(即本文)將會對Ethereum的P2P做一個簡單的介紹 ,如此才能了解日蝕攻擊為何能夠生效。第二部分將介紹波士頓大學團隊論文中關於日蝕攻擊的實現原理。
在撰寫此篇文章的時候,最新geth版本為1.8.0,而日蝕攻擊論文的實驗對象為geth 1.6.6 ,本文所描述的各種Ethereum環境 ,若無特別提及 ,則視作與日蝕攻擊論文一致 。
除此之外,Ethereum的重大改版在即,此文中所描述的諸多Ethereum P2P特性都可能面臨巨大的改變,請多加注意。
Ethereum的P2P特色
根據日蝕攻擊此篇論文的整理 ,Ethereum的P2P有以下幾點特色(A~G):
A. 基於Kademlia設計的P2P協定
Kademlia是一種用於檔案共享的結構化P2P協定(關於Kademlia的詳細介紹,可參考此文),其目標為有效率的儲存及定位檔案。BitTorrent以及eMule等檔案共享工具就是基於Kademlia設計而成。
在Kademlia的協定中,每一個檔案以及每一個節點都被賦予一個唯一的識別碼(ID),這個ID是以長度為b的位元組所形成(在原始的Kademlia中,b=160)。此ID會用來計算所謂的距離參數(d,distance,個人習慣形容為相異度), d值越大表示距離越遠(相異度越大),計算方式是對兩個ID(ID₁及ID₂)進行異或運算(XOR),其定義如下:
d = ID₁ ⊕ ID₂
一個簡單範例如下:
ID₁ = 10011...01
ID₂ = 10011...10
i.e. ID₁及ID₂的前158個位元皆一致d = ID₁ ⊕ ID₂
= 10011...01 ⊕ 10011...10
= 00000...11
= 3
另外,在每一個Kademlia的節點裡,包含一種稱之為桶(bucket)的資料結構。桶裡所儲存的是網路中的節點資訊,桶的數量即為識別碼的長度(b),每一個桶裡最多會儲存k個節點的資訊,因此這種資料結構又被稱為k桶(k-bucket),如下圖:
Ethereum沿用了Kademlia的XOR運算及bucket概念,另外在Ethereum的設計中,尋找檔案的機制不再需要,只留下更新節點資訊的機制 ,詳細內容可參考下面敘述(E. 節點資訊的更新方式)。
在此篇論文中,作者提到無法理解Ethereum的P2P協定為何要使用Kademlia。
由於Ethereum中的底層資料結構(chaindata等資料),並未分割成不同的小片段去作分散式儲存,因此也就無法利用到Kademlia中尋找檔案的特性。
個人猜測或許是Ethereum當初在設計開發時,曾經有考慮要將底層資料結構進行分散式儲存?此點還待高手協助解惑。
B. ECDSA公鑰作為節點ID
Ethereum以ECDSA公鑰作為節點ID,其ID長度為512bits。同一台機器(同一個IP)可以運行數個Ethereum節點,亦即一個IP可以產生數個節點ID,這是日蝕攻擊能夠生效的因素之一。
C. UDP協定與TCP協定負責不同層級的訊息交換
在Ethereum中,P2P資訊的交換是藉由UDP來進行,而區塊資訊的交換則是在TCP上進行。
Ethereum UDP所傳遞的資訊可分為四種:ping ,pong ,findnode以及neighbor。
- ping and pong:ping訊息用來嘗試探知某個節點,若是該節點接受到ping訊息即會返回pong訊息。
- findnode and neighbor:findnode訊息會附帶一個節點ID(假設為t),findnode將會向另一個節點(假設為x)詢問距離t最近的節點資訊 ,亦即詢問x的節點列表中距離t最近的節點。x會將這些距離t最近的節點資訊 ,以neighbor訊息返回 ,最多會返回16筆節點資訊。
為了防禦重放攻擊(replay attacks),UDP傳遞的這些資訊,都會加上時間戳記(timestamp),當節點發現某一個UDP訊息其時間比本機時間還老舊(比本機時間早20秒),就會丟棄該訊息。
Ethereum的TCP連線可分為兩種 ,一種是由本機節點發出請求給其他節點的outgoing連線 ,另一種是其他節點發起請求給本機節點的incoming連線。一個Ethereum節點同一時間最多可允許25個TCP連線存在(maxpeers),而在geth 1.8.0版之前,這25個TCP連線可以全部都是incoming連線 ,這也是日蝕攻擊能夠生效的其中一個因素。
D. 儲存P2P節點資訊的資料結構
Ethereum有兩種儲存節點資訊的資料結構 ,一種用於長期儲存,日蝕攻擊論文的作者稱之為db。db會將節點資訊持久化的儲存於硬碟內 ,因此每次節點重啟時 ,db的資料仍會保存。db內儲存了數筆節點的資訊 ,每一筆資訊包含以下的欄位:
- nodeIP:節點IP
- tcpPort:TCP Port number
- udpPort:UDP Port number
- latestPing:最近一次嘗試接觸該節點的時間(亦即ping訊息送出的時間)
- latestPong:最近一次該節點回應的時間(亦即收到pong訊息的時間)
- failedTimes:以findnode嘗試詢問該節點卻失敗的次數
以上欄位名稱並非Ethereum geth原始碼中所定義的名稱 ,只是為了方便本文後續說明而定義的名稱。
Ethereum本機節點會定期檢查db內的每筆節點資訊 ,若是某節點的latestPong與當下時間間隔已經超過一天,則將該節點從db中移除。
另一種結構是短期儲存 ,稱之為table,每次重啟節點時 ,table都會是空的。table含有256個bucket ,每個bucket又包含16筆其他節點的資料 ,每筆資料的欄位如下:
- nodeID:節點ID
- nodeIP:節點IP
- tcpPort:TCP Port number
- udpPort:UDP Port number
bucket內的每筆節點資料都會按照加入順序而排序,當一個bukcet已經額滿卻還有新節點資料要加入的時候,本機節點會傳送ping訊息給bukcet中最早加入的節點,若是最早加入的節點沒有回應pong訊息,則移除最早加入的節點,並將新節點加入,反之則丟棄新節點。
為了決定某一個節點資訊要放到哪一個bucket之中,需配合logdist函式來進行計算,過程如下,其中ID˪及IDₒ是logdist函式的輸入參數:
H˪ = SHA3(ID˪) ,where ID˪ is the ID of local nodeHₒ = SHA3(IDₒ) ,where IDₒ is the ID of other noder = similarity(H˪,Hₒ) ,where 0 ≤ r ≤ 255i.e. r is number of most significant bits that H˪ and Hₒ are the same
經過上面的計算得到r值後,就可算出節點會被儲存在編號為bucket_num(等於256﹣r)的bucket之內。由於logdist函式的特性 ,節點在不同bucket_num的分配會呈現高度偏移(highly-skewed)的狀況,即任一節點有較高機率被分配到bucket_num較大的bucket中 ,其機率分佈如下式:
Pᵣ = 1 / ( 2^(r+1) ) ,where 0 ≤ r ≤ 255 , bucket_num = 256﹣r
根據上式 ,機率分佈圖如下:
理論上table結構最大可容納4096筆節點資料(一個bucket最多容納16筆 ,共有256個bucket) ,但是由於這種高度偏移狀況,實際上table大部分的時間只會儲存少量的節點資料(大約150~200筆,參考下圖 ,出自日蝕攻擊論文)。
E. 節點資訊的更新方式
Ethereum綜合了下述的預設節點資訊以及運算模式,以新增或更新其節點資訊紀錄(table及db) :
1. 預設節點(Bootstrap nodes)
Ethereum內建(hardcoded)了六筆預設節點資訊。當一個Ethereum節點初始化並第一次啟動時 ,db沒有儲存任何資料 ,此時就會用到預設節點資訊。
2. 聯結更新運算(Bonding)
聯結更新運算是本機節點嘗試新增或更新某一個遠端節點資訊的過程(db以及table),其具體過程如下:
step1-a: 檢查該遠端節點是否已存在於db中 step1-b: 檢查failedTimes是否為0 step1-c: 檢查latestPong是否小於24小時 step2: 若是第一個步驟的三個條件都成立,則將該節點存入table中對應的bucket,
具體存入過程請參考上述D.儲存P2P節點資訊的資料結構。
若是有任一條件不成立,則進到step3。step3: 嘗試傳送ping訊息給該節點,若收到該節點的pong訊息,則於db加入或更新該節
點資訊,並且將該節點存入table中對應的bucket。
3. ping訊息觸發運算(Unsolicited pings)
當本機節點收到一個遠端節點傳送過來的ping訊息時 ,除了回應pong訊息給該遠端節點,本機節點也會針對該遠端節點執行上述的聯結更新運算(Bonding)。
4. 尋訪運算(Lookup)
尋訪運算即針對某一個目標節點找尋其最近節點,並且紀錄這些最近節點,步驟如下:
step1: 給定一個目標節點,稱之為t。step2: 從本機節點table中的所有節點中選出16個最接近t(根據logdist函式)的節點,
這16個節點組成"待訪節點列表"。step3: 本機分別去訪問(findnode)"待訪節點列表"的每個節點,每一個被訪問的節點會再
各自返回多個(最多16個)更接近t的節點(neighbor)。step4: step3完成後,本機可得到數個新節點(最多256個),接著對這數個新節點進行聯結
更新運算(bonding)。step5: 從step2所選出的16個節點加上step4完成聯結更新運算的新節點(最多16+256
個)中再度組成新的待訪節點列表,並取代step2原本的待訪節點列表。step6: 本機不斷重複step2 ~ step5,直到節點資訊不再變化,表示尋訪機制完成(當
step5新的待訪節點列表與step2原本的待訪節點列表相同時,視作不再變化),此
時會將這組最新的待訪節點列表放到一個lookup_buffer的資料結構中。
F. 種子運算(Seeding)
種子運算皆由本機自主觸發,是以近期之內保持在線上的節點資訊作為更新table資訊的依據,具體行為是將預設節點以及db之中的節點資訊複製到table中 ,有三種情況會觸發種子運算:
- 當Ethereum節點啟動時
- 每一小時定期觸發
- 尋訪運算(Lookup)啟動時
種子運算的具體步驟如下:
step1: 本機節點檢查是否table為空;若為空,才繼續執行後續步驟。step2: 本機節點對六筆預設節點執行聯結更新運算。step3: 從本機db中,取出latestPong小於或等於120小時的一組節點,若這一組節點的數
量少於30個,則全部保留;若數量多於30個,則隨機留下其中30個.接著本機節點針
對這些節點去執行聯結更新運算。step4: 本機節點以自己為目標執行尋訪運算。
G. Outgoing TCP連線的建立
Ethereum節點會持續建立Outgoing TCP連線,Outgoing TCP連線數最多可達13個(0.5×(1+maxpeers))。Ethereum節點會準備兩種執行緒:
第一種執行緒是discover_task,此執行緒之任務是以一個隨機節點為目標去進行尋訪運算以持續更新節點資訊。
第二種執行緒是dial_task,此執行緒之任務用於對某個目標節點建立TCP連線 ,嘗試建立連線前 ,首先會檢查幾個條件:
- 該節點不在黑名單內
- 與該節點的TCP連線尚未建立
- 是否正在對該節點進行撥號(dial);若否,則成立
- 是否最近曾對該節點進行撥號;若否,則成立
整體而言,Ethereum會由lookup_buffer(經由尋訪運算得出)及table取得節點資訊 ,再嘗試去和這些節點建立TCP連線。
綜合上述,可以整理出Ethereum更新節點資訊以及連結節點的過程,如下圖:
以上是關於Ethereum P2P的簡介 ,而日蝕攻擊便是針對Ethereum建立節點資訊和連結節點的過程進行攻擊。在下一篇文章中 ,我們將會介紹日蝕攻擊是如何根據Ethereum P2P中潛藏的弱點而進行攻擊。