在一個共識協議中,最簡單的錯誤也會導致災難。

我準備開一個系列,講解我在go-ethereum(Geth 客戶端)(以太坊協議的正式Go 語言實現)中發現的Bug,本篇是第一篇。雖然閱讀這系列文章不需要你對Geth 有多深的理解,但懂得以太坊協議是怎麼運行的,會很有幫助。

這篇文章講的是Geth 客戶端叔塊驗證程序中的一個bug,傳入一個專門構造的叔塊後,該程序的行動是錯誤的。如果該漏洞被利用,會導致Geth 節點和Parity 節點發生分叉。

區塊與叔塊

每一條區塊鏈都會在運行中產生一條大家都認可的主鏈(canonical chain);而主鏈的辨識方法也是協議定義好的:最長的鏈,或者總工作量最大的鏈,等等。不過,網絡的延遲意味著,可能會有兩個區塊在同一時間生成。那麼,只有其中一個能最終成為主鏈的一部分,而另一個則必須被拋棄。

某些區塊鏈協議,比如比特幣,會完全無視掉這些被落敗的區塊,讓它們成為主鏈的“孤塊(orphan)”。另一些區塊鏈協議,比如以太坊,依然會獎勵挖出這些落敗區塊並努力傳播它的礦工。在以太坊的語境中,這些仍然成為了主鏈一部分的孤塊叫做“叔塊(uncle block)”。

但一個塊要成為一個有效的叔塊,還需滿足一些條件:(1)該區塊本身的所有內容都必須是有效的(根據正常的共識規則);(2)區塊與其意圖標記的叔塊,兩者的塊高度相差不超過6(一個叔塊挖出後,只有在未來的6 個區塊以內被標記為叔塊,才是有效的)。但是,這裡有個例外:雖然正常的區塊的時間戳間隔不應超過15 秒,但叔塊則無此限制。

關於整數的一個小插曲

大部分的編程語言都有依賴於平台的整數(platform-dependent integer)和定長整數(fixed-width integer)兩種概念。依賴於平台的整數可能是32 位或者64 位的(等等),取決於程序所在的編譯平台。在C/C++ 和Go 語言中,你可能會使用 uint,而在Rust 中,你會使用 usize。

但是,有些時候程序員想要保證其程序變量可以存儲64 位的數據,即使程序運行在32 位的平台上。這時候,程序員可以使用定長的整數類型。在C/C++ 中就是 uint64_t,Go 語言是 uint64,而在Rust 中是 u64。

使用這些語言自帶的整數型的好處是,它們都具備最高優先級(first-class citizen),使用起來都非常簡單。來看看這個支持64 位整數的Collatz Conjecture 實現:

func collatz(n uint64) uint64 { if n % 2 == 0 { return n / 2 } else { return 3 * n + 1 }}

但是,這個實現有個瑕疵:它不支持超過64 位的整數。因此,我們需要 大整數(big integers)。大多數語言都支持大整數,要么是用自帶的標準庫(比如Go 的 big.Int),要么是通過外部代碼庫(比如C/C++ 和Rust 都是如此)。

難搞的是,使用大整數有一個很大的缺點:很不靈活。我們用支持任意大整數類型的Collatz Conjecture 把上面的程序再實現一遍:

var big0 = big.NewInt(0)var big1 = big.NewInt(1)var big2 = big.NewInt(2)var big3 = big.NewInt(3)func collatzBig(n *big.Int) *big.Int { if new(big.Int).Mod(n, big2).Cmp(big0) == 0 { return new(big.Int).Div(n, big2) } else { v := new(big.Int). Mul(big3, n) v.Add(v, big1) return v }}顯然,64 位的版本既好寫,又好讀。所以,不意外的是,程序員都會盡可能使用簡單的整數型。

例外vs. 現實

在以太坊協議中,可以預期大部分數據都不會超過256 位,雖然某些整數字段的長度是任意的,無法有任何預期。重點是,區塊的時間戳 Hs 也定義為一個256 位的整數。

- 以太坊黃皮書,P6 -

Geth 團隊嘗試通過驗證叔塊的時間戳是小於 2^256 - 1 的整數來滿足這個定義。再次提醒,叔塊的出塊時間不受任何限制。

// Verify the header's timestampif uncle { if header.Time.Cmp(math.MaxBig256) > 0 { return errLargeBlockTime }} else { if header.Time.Cmp(big.NewInt(time.Now().Add(allowedFutureBlockTime). Unix())) > 0 { return consensus.ErrFutureBlock }}

- 來源 -

但是,接下來的代碼卻要將區塊時間戳強制調整為一個64 位的整數,以計算該區塊的正確難度。

// Verify the block's difficulty based in it's timestamp and parent's difficultyexpected := ethash.CalcDifficulty(chain, header.Time.Uint64(), parent)- 來源 -

如果Parity 也是一樣的做法,那也不會有什麼大問題。但是,Parity 的時間戳在 2^64 - 1 就已到達上限,不會再溢出了。

let mut blockheader = Header { parent_hash: r.val_at(0)?, uncles_hash: r.val_at(1)?, author: r.val_at(2)?, state_root: r.val_at(3)?, transactions_root: r. val_at(4)?, receipts_root: r.val_at(5)?, log_bloom: r.val_at(6)?, difficulty: r.val_at(7)?, number: r.val_at(8)?, gas_limit: r. val_at(9)?, gas_used: r.val_at(10)?, timestamp: cmp::min(r.val_at:: (11)?, u64::max_value().into()).as_u64(), extra_data: r.val_at(12)?, seal: vec![], hash: keccak(r.as_raw()).into (),};- 來源 -

也就是說,如果有個惡意的礦工,在所出的區塊里納入了一個叔塊,該叔塊的時間戳是584942419325-01-27 07:00:16 UTC,也就是unix 時間2^64 ,那麼Geth 會用Unix 時間0 來計算難度,而Parity 會用unix 時間2^64 - 1 來計算難度。結果會不一樣,所以其中一個客戶端會在驗證叔塊後從主鏈分裂出去。

Geth 團隊在 PR 19372 中修復了這個Bug,切換到所有時間戳都使用 unit64 。

結論

每一種參與同一個共識協議的客戶端,都必須有同樣的行動,因此,一些看起來完全無害的操作可能正是導致一半網絡相互隔離的罪魁禍首。

這同樣也表明,要發現一個影響巨大的bug,你並不需要很高的技術水平。如果你對這些東西感興趣,最好的辦法就是立即動手。

下一篇文章,我們會討論Geth 客戶端如何存儲構成以太坊的數據,以及一個手段高明的攻擊者可以如何規劃一個定時炸彈,在引爆時導致鏈硬分叉。

原文鏈接:

https://samczsun.com/the-block-mined-in-january-584942419325/

作者: samczsun

翻譯:

阿劍