作者:NIKLAS GÖGGE
來源:https://brink.dev/blog/2026/01/07/fuzzamoto-introduction/
大概一年以前,我開始開發 “Fuzzamoto”,這是一種比特幣全節點實現的模糊測試(fuzz testing)工具。在這一系列博客中,我會分享我的經驗、間接和在此期間遇到的開放問題。第一篇會介紹創建 Fuzzamato 這個項目背後的動機,並概述其設計和架構。在後續篇章中,我將深入其確定性、測試案例的生成和突變測試(mutation)、發現的 bug、對超出覆蓋範圍的模糊測試的反饋,等等。
動機
迄今為止,Bitcoin Core 是網絡上受到最廣泛採用的比特幣協議實現,因此,它的 bug 可能會造成災難性的後果。也正因此,Bitcoin Core 擁有非常保守的開發文化,這在一定程度上是必要的,也是一個優點(顯然是它沒有許多嚴重 bug 的一個重要原因),但也會帶來挫敗感、消耗感以及重大項目上的緩慢進展。
即時你只是想提升測試水平、重構現有的代碼以讓它變得更容易測試、添加測試項目,你也可能面對巨大的阻力。這個項目的資源是非常緊缺的,複雜的 PR 對於審核人來說根本沒有吸引力,因為在初始化同步時間上加速 20%、P2P 協議的特性以及其它微小的變更只需更少的時間和經歷來審核。讓事情惡化的是,如果你不得不重構代碼來添加更多測試,重構過程自身有可能引入 bug 。這就變成了一個雞生蛋蛋生雞的問題 —— 測試將降低重構的風險,可以添加測試需要重構。
這些重構通常會解耦各個模塊,將代碼分割到單獨的模塊中,或者添加接口,讓模擬(mocking)能夠實現,這對於 進程內的/持久模式的 模糊測試來說,通常是必要的。比如說,如果被測試的組件依賴於硬盤 I/O,模糊測試速度會較慢,拓展性也差,所以你會想要模擬它(硬盤 I/O)以加快速度。(譯者注:“模擬” 在這裡指的是創建虛擬對象來代替實際上的依賴項。)
在這一階段,對 Bitcoin Core 的測試的重大提升(發現嚴重的既有 bug 或者防止 bug 繼續擴大)依然是很有可能做到的。比如說,跟驗證區塊、驗證交易和緻密區塊的 P2P 代碼是沒有經過模糊測試的,關於鏈重組和其它相似的更加內聚的代碼路徑也是一樣。為了能夠以傳統的進程內控制來高效地測試這些代碼,需要變更關鍵的代碼(共識、P2P,……),同樣是沒有得到應有測試程度的代碼。
我可以說,從我的個人經驗來看,為這些重構獲得支持是痛苦的,而且我已經失去維護 PR 的興趣幾個月甚至幾年了;這些重構的好處似乎常常被更高的特性流失率(churn rate of features)和微小的變更蓋過。(譯者注:“特性流失率” 指的是一段時間裡停止使用某一特性的用戶的比例。)
面對這種情況,顯然,Bitcoin Core 將從無需給保守的審核流程帶來不必要的負擔、不需要 Bitcoin Core 自身的任何變更的測試中獲得好處,這樣的測試能夠縮小剩餘的測試空白並減少未來的風險。最終的願景是一種外部的測試工具,它儘可能以生產環境中的二進制文件為輸入,以 bug 為輸出。
Bitcoin Core 的功能測試(functional test)曾經是最接近這個想法的東西,因為它們生成完整的節點,並通過節點的外部接口(RPC、P2P、IPC 等等)進行測試。編寫功能測試不需要重構,最多隻需要添加新的 RPC 方法,從而能夠內省軟件的部分狀態。雖然這些測試達成了較高的代碼覆蓋率並找出了大量 bug,它們並不是基於屬性(property)的測試,並不能自動暴露邊緣案例(edge cases)。舉個例子,功能測試的單行補丁(line patch)沒有發現 CVE-2024-35202,它實際上是通過重構和編寫新的模糊測試而發現的(順帶說一句,這些測試和重構,都從來沒有合併到 Bitcoin Core 中)。如果那些功能測試是基於屬性的測試,那也許它們能夠發現這個問題。
在意識到這些之後,我問我自己:我們能不能擁有 “功能性的模糊測試” 呢?跟功能測試擁有同樣的測試概念,但不是使用硬編碼的測試場景並安排好預期結果,而是使用模糊測試,在系統層面測試屬性。這就是 Fuzzamoto 背後的理念:完全系統化的(full-system)、模糊測試驅動的模擬。
設計
抽象地說,使用 Fuzzamoto 的模糊測試包含作為測試目標的全節點後臺進程(比如,bitcoind、btcd,等等),一個測試工具(在給定一個模糊輸入的前提下,控制測試執行),以及一個模糊測試引擎(用於產生提供給工具來執行的輸入)。

快照模糊測試
一個需要解決的顯然挑戰是,這種設計的一個天真實現會導致目標節點在模糊測試執行後積累狀態(從而影響後續測試),從而導致非確定性(non-determinism)。我會在本系列的後續博客中更深入地探討確定性及其挑戰,但簡而言之:為了讓模擬測試是高效的,我們希望測試案例的執行是確定性的,即,給定相同的輸入,測試的動作應該是一樣的。
Fuzzamato 使用完全系統化的快照模糊測試來解決這個狀態難題。它的原理是:在一個特殊的虛擬機內運行目標節點和測試工具;這個虛擬機有能力為其所有狀態(內存、CPU 狀態、設備,……)生成一個快照,並快速將自身重置到這個狀態。當前,Fuzzamoto 使用 Nyx 作為虛擬機後端,但理論上,任何擁有類似能力的後端都可以工作。
這讓我們可以避免每次都創建和拆除狀態(這都是昂貴的開銷)。我們可以直接在一開始建立需要的狀態,然後取得快照、開始模糊測試,在每一次執行的結束時刻,將虛擬機快速重置回初始狀態。具體來說,對於比特幣全節點的模糊測試,這讓我們可以(舉個例子)預先挖出一條區塊鏈、提供成熟的(可以立即測試花費的)coinbase 輸出。
我們將在本系列博客的下一篇文章中講解 Nyx 的技術細節:它是如何工作的,Fuzzamoto 如何使用它;以及覆蓋指引(coverage guidance)在這個模式下如何工作。
場景
Fuzzamoto 中的模糊測試工具可被稱為 “場景”,負責快照狀態的建立、控制模糊輸入執行並將結果報告給測試引擎。每一個場景都需要實現兩種函數:
- 場景的創建和快照狀態的建立,即,生成目標全節點進程,並將節點帶到運行模糊測試所需的狀態。
- 測試案例的執行,即,在前面創建好的狀態下執行一個測試案例。
測試 HTTP 服務端、錢包遷移、RPC 接口和具體的 P2P 協議流(比如緻密區塊轉發)各有具體的場景。它們都取裸字節作為輸入,並使用 Arbitrary 將這些字節解析為一個結構化的測試輸入,然後對目標執行這些測試輸入。因為輸入是一個通用的字節數組,我們可以使用 AFL++ 來模糊測試這些場景,因為它為使用 Nyx 的快照模糊測試提供了支持。
就在我為測試多種 P2P 協議流(交易轉發、緻密區塊轉發、鏈重組,……)而開發獨立的場景時,我突然想到,在它們之間形成重疊是有好處的。舉個例子,為了測試緻密區塊轉發或是鏈重組,假設同時也向受試的節點提交不同形狀和類型的交易(就像在交易轉發的測試場景中那樣)就會觸發 bug,這並不離譜。因此,思路就轉變成了開發一個非常廣泛的場景來測試節點整個 P2P 界面,一網打盡。正是為了模糊測試這個場景,Fuzzamoto 包含了一個定製化的基於 LibAFL 的模糊測試引擎,這我們也會放到本系列的後續篇章來詳談。
初步成功
我編寫出來的第一批場景之一,目標是 Bitcoin Core 的 RPC,尤其是以有趣的方式合併 RPC 的各種相關的和不相關的結果。這個場景會以模糊測試引擎選定的順序調用 RPC,然後將模糊輸入的一部分解析為 RPC 輸入,或者從一個由以往的 RPC 返回的值形成的池子中挑選。比如說,如果測試調用了 generatetoaddress,它可能後面會將這個 PRC 所返回的區塊哈希值傳遞給其它 RPC 作為輸入,或者從模糊輸入中消去一個哈希值。
這個場景成功發現了區塊索引數據結構中的 bug,這個 bug 只有在同時使用 invalidateblock 和 reconsiderblock RPC 時才會現象(它們都只能在測試模式下使用)。
bitcoind: validation.cpp:5392: void ChainstateManager::CheckBlockIndex(): Assertion '(pindex->nStatus & BLOCK_FAILED_MASK) == 0' failed.雖然從安全性的角度看,這個 bug 也沒有什麼大不了(只能通過測試模式限用的 RPC 來觸發),它凸顯了完全系統化方法的強大之處:它會立即顯明,這是一個 bug ,而不是一個假陽性(即:後臺進程會崩潰,這是絕對不該發生的),並且,快照模糊測試帶來了高效的狀態重置(如不使用快照,本來只有通過重構才能做到)。
同一個 bug 也被其他重構了代碼併為區塊索引代碼編寫了模糊測試的貢獻者發現了。而添加這個工作到代碼庫中的 PR,在被合併之前已經打開了整整一年!
因為 Fuzzamoto 測試的運行層級跟功能測試相同,我們可以將任何 Fuzzamoto 的測試案例轉化為一個 Bitcoin Core 功能測試(只要 bug 是確定性可以復現的,就可以做到)。比如說,下面這個功能測試就復現了區塊索引 bug:
from test_framework.test_framework import BitcoinTestFrameworkclass CheckBlockIndexBug(BitcoinTestFramework): def set_test_params(self): self.setup_clean_chain = True self.num_nodes = 1 def run_test(self): self.generatetoaddress(self.nodes[0], 1, "2N9hLwkSqr1cPQAPxbrGVUjxyjD11G2e1he"); hashes = self.generatetoaddress(self.nodes[0], 1, "2N9hLwkSqr1cPQAPxbrGVUjxyjD11G2e1he"); self.generatetoaddress(self.nodes[0], 1, "2N2CmnxjBbPTHrawgG2FkTuBLcJtEzA86sF"); res = self.nodes[0].gettxoutsetinfo() self.generatetoaddress(self.nodes[0], 3, "2N9hLwkSqr1cPQAPxbrGVUjxyjD11G2e1he"); self.log.info(self.nodes[0].invalidateblock(res["bestblock"])) self.generatetoaddress(self.nodes[0], 3, "2N9hLwkSqr1cPQAPxbrGVUjxyjD11G2e1he"); self.nodes[0].reconsiderblock(hashes[0]) self.nodes[0].invalidateblock(hashes[0]) self.log.info(self.nodes[0].reconsiderblock(res["bestblock"]))if __name__ == '__main__': CheckBlockIndexBug(__file__).main()這樣一來,處理這個 bug 的開發者們,就無需建立 Fuzzamoto,只需使用他們熟悉的工具來調試這個問題。
關於迄今為止發現和公開的問題的去競爭清單,請看這個項目的 readme 的炫耀部分。
本系列的下一篇將介紹圍繞非確定性的考慮。





