採用內存安全語言有助於 RPC 節點避免許多基於內存破壞漏洞的攻擊,但仍需要經過精心設計和審查。
(前情提要:IOSG:由加密社群驅動的「漏洞賞金、懸賞競賽」可行嗎? )
(背景補充:深度分析 Sui 公鏈最新漏洞「倉鼠滾輪」為何被評為嚴重級? )
CertiK 的 Skyfall 團隊最近在 Aptos、StarCoin 和 Sui 等多個區塊鏈中發現了基於 Rust 的 RPC 節點的多個漏洞。由於 RPC 節點是連線 dApp 和底層區塊鏈的關鍵基礎設施元件,其穩健性對於無縫操作至關重要。區塊鏈設計者都知道穩定 RPC 服務的重要性,因此他們採用 Rust 等記憶體安全語言來規避可能破壞 RPC 節點的常見漏洞。
採用記憶體安全語言(如 Rust)有助於 RPC 節點避免許多基於記憶體破壞漏洞的攻擊。然而,通過最近的審計,我們發現即使是記憶體安全的 Rust 實現,如果沒有經過精心設計和審查,也很容易受到某些安全威脅的影響,從而破壞 RPC 服務的可用性。
本文我們將通過實際案例介紹我們對一系列漏洞的發現。
區塊鏈 RPC 節點作用
區塊鏈的遠端過程呼叫(RPC)服務是 Layer 1 區塊鏈的核心基礎設施元件。它為使用者提供重要的 API 前端,並作為通向後端區塊鏈網路的閘道器。然而,區塊鏈 RPC 服務與傳統的 RPC 服務不同,它方便使用者互動無需身份驗證。服務的持續可用性至關重要,任何服務中斷都會嚴重影響底層區塊鏈的可用性。
審計角度:傳統 RPC 伺服器 VS 區塊鏈 RPC 伺服器
對傳統 RPC 伺服器的審計主要集中在輸入驗證、授權 / 認證、跨站請求偽造 / 伺服器端請求偽造(CSRF/SSRF)、注入漏洞(如 SQL 注入、命令注入)和資訊洩露等方面進檢查。
然而,區塊鏈 RPC 伺服器的情況有所不同。只要交易是簽名的,就不需要在 RPC 層對發起請求的客戶端進行身份驗證。作為區塊鏈的前端,RPC 服務的一個主要目標是保證其可用性。如果它失效,使用者就無法與區塊鏈互動,從而阻礙查詢鏈上資料、提交交易或釋出合約功能。
因此,區塊鏈 RPC 伺服器最脆弱的方面是 「可用性」。如果伺服器當機,使用者就失去了與區塊鏈互動的能力。更嚴重的是,一些攻擊會在鏈上擴散,影響大量節點,甚至導致整個網路癱瘓。
為何新區塊鏈會採用記憶體安全的 RPC
一些著名的 Layer 1 區塊鏈,如 Aptos 和 Sui,使用記憶體安全程式語言 Rust 實現其 RPC 服務。得益於其強大的安全性和編譯時嚴格的檢查,Rust 幾乎可以使程式免受記憶體破壞漏洞的影響,如堆疊溢位、和空指標解引用和釋放後重引用等漏洞。
為了進一步確保程式碼庫的安全,開發人員需嚴格遵循最佳實踐,例如不引入不安全程式碼。在原始碼中使用 #![forbid (unsafe_code)] 確保阻攔過濾不安全的程式碼。
為了防止整數溢位,開發人員通常使用 checked_add、checked_sub、saturating_add、saturating_sub 等函式,而不是簡單的加法和減法(+、-)。通過設定適當的超時、請求大小限制和請求項數限制來緩解資源耗盡。
Layer 1 區塊鏈中記憶體安全 RPC 威脅
儘管不存在傳統意義上記憶體不安全的漏洞,但 RPC 節點會暴露在攻擊者容易操縱的輸入中。在記憶體安全 RPC 實現中,有幾種情況會導致拒絕服務。例如,記憶體放大可能會耗盡服務的記憶體,而邏輯問題可能會引入無限迴圈。此外,競態條件也可能構成威脅,併發操作可能會出現意外的事件序列,從而使系統處於未定義的狀態。此外,管理不當的依賴關係和第三方庫可能會給系統帶來未知漏洞。
在這篇文章中,我們的目的是讓人們關注可以觸發 Rust 執行時保護的更直接的方式,從而導致服務自行中止。
顯式的 Rust Panic: 一種直接終止 RPC 服務的方法
開發人員可以有意或無意地引入顯式 panic 程式碼。這些程式碼主要用於處理意外或異常情況。一些常見的例子包括:
- assert!():當必須滿足一個條件時使用該 macro。如果斷言的條件失敗,程式將 panic,表明程式碼中存在嚴重錯誤。
- panic!():當程式遇到無法恢復的錯誤且無法繼續執行時呼叫該函式。
- unreachable!():當一段程式碼不應該被執行時使用該 macro。如果該 macro 被呼叫,則表示存在嚴重的邏輯錯誤。
- unimplemented!() 和 todo!():這些巨集是尚未實現功能的佔位符。如果達到該值,程式將崩潰。
- unwrap ():該方法用於 Option 或 Result 型別,當遇到 Err 變數或 None 時會導致程式當機。
漏洞一:觸發 Move Verifier 中的 assert!
Aptos 區塊鏈採用 Move 位元組碼驗證器,通過對位元組碼的抽象解釋進行引用安全分析。execute () 函式是 TransferFunctions trait 實現的一部分,模擬基本塊中位元組碼指令的執行。
函式 execute_inner () 的任務是解釋當前位元組碼指令並相應地更新狀態。如果我們已經執行到基本塊中的最後一條指令,如 index == last_index 所示,函式將呼叫 assert!(self.stack.is_empty ()) 以確保棧為空。此行為背後的意圖是保證所有操作都是平衡的,這也意味著每次入棧都有相應的出棧。
在正常的執行流程中,棧在抽象解釋過程中始終是平衡的。堆疊平衡檢查器(Stack Balance Checker)保證了這一點,它在解釋之前對位元組碼進行了驗證。然而,一旦我們將視角擴充套件到抽象直譯器的範圍,就會發現堆疊平衡假設並不總是有效的。
抽象直譯器的核心是在基本塊級別中模擬位元組碼。在其最初的實現中,在 execute_block 過程中,遇到錯誤會提示分析過程記錄錯誤,並繼續執行控制流圖中的下一個塊。這可能會造成一種情況:執行塊中的錯誤會導致堆疊不平衡。如果在這種情況下繼續執行,就會在堆疊不為空的情況下進行 assert! 檢查,從而引發 panic。
這就使得攻擊者有機可趁。攻擊者可通過在 execute_block () 中設計特定的位元組碼來觸發錯誤,隨後 execute () 有可能在堆疊不為空的情況下執行 assert,從而導致 assert 檢查失敗。這將進一步導致 panic 並終止 RPC 服務,從而影響其可用性。
為防止出現這種情況,已實施的修復中,確保了在 execute_block 函式首次出現錯誤時會停止整個分析過程,進而避免了因錯誤導致堆疊不平衡而繼續分析時可能發生的後續崩潰風險。這一修改消除了可能引起 panic 的情況,並有助於提高抽象直譯器的健壯性和安全性。
漏洞二:觸發 StarCoin 中的 panic!
Starcoin 區塊鏈有自己的 Move 實現分叉。在這個 Move repo 中,Struct 型別的建構函式中存在一個 panic! 如果提供的 StructDefinition 擁有 Native 欄位資訊,就會顯式觸發這個 panic!。
這種潛在風險存在於重新發布模組的過程中。如果被發布的模組已經存在於資料儲存中,則需要對現有模組和攻擊者控制的輸入模組進行模組規範化處理。在這個過程中,”normalized::Module::new” 函式會從攻擊者控制的輸入模組中構建模組結構,從而觸發 “panic!”。
通過從客戶端提交特製的有效載荷,可以觸發該 panic。因此,惡意行為者可以破壞 RPC 服務的可用性。
Starcoin 的更新引入了一個新的行為來處理 Native 情況。現在,它不會引起 panic,而是返回一個空的 ec。這減少了使用者提交資料引起 panic 的可能性。
隱式 Rust Panic: 一種容易被忽視的終止 RPC 服務的方法
顯式 panic 在原始碼中很容易識別,而隱式 panic 則更可能被開發人員忽略。隱式 panic 通常發生在使用標準或第三方庫提供的 API 時。開發人員需要徹底閱讀和理解 API 文件,否則他們的 Rust 程式可能會意外停止。
讓我們以 Rust STD 中的 BTreeMap 為例。BTreeMap 是一種常用的資料結構,它以排序的二叉樹形式組織鍵值對。BTreeMap 提供了兩種通過鍵值檢索值的方法:get (&self, key: &Q) 和 index (&self, key: &Q)。
方法 get (&self, key: &Q) 使用鍵檢索值並返回一個 Option。Option 可以是 Some (&V),如果 key 存在,則返回值的引用,如果在 BTreeMap 中沒有找到 key,則返回 None。
另一方面,index (&self, key: &Q) 直接返回鍵對應的值的引用。然而,它有一個很大的風險:如果鍵不存在於 BTreeMap 中,它會觸發隱式 panic。如果處理不當,程式可能會意外崩潰,從而成為一個潛在漏洞。
事實上,index (&self, key: &Q) 方法是 std::ops::Index trait 的底層實現。該特質為不可變上下文中的索引操作(即 container [index])提供了方便的語法糖。開發者可以直接使用 btree_map [key],呼叫 index (&self, key: &Q) 方法。然而,他們可能會忽略這樣一個事實:如果找不到 key,這種用法可能會觸發 panic,從而對程式的穩定性造成隱性威脅。
漏洞三:在 Sui RPC 中觸發隱式 panic
Sui 模組釋出例程允許使用者通過 RPC 提交模組有效載荷。在將請求轉發給後端驗證網路進行位元組碼驗證之前,RPC 處理程式使用 SuiCommand::Publish 函式直接反彙編接收到的模組。
在這個反彙編過程中,提交模組中的 code_unit 部分被用來構建一個 VMControlFlowGraph。該構建過程包括建立基本塊,這些塊儲存在一個名為 “‘blocks'” 的 BTreeMap 中。該過程包括建立和操作該 Map,在某些條件下,隱式 panic 會在這裡觸發。
下面是一段簡化的程式碼:
在該程式碼中,通過遍歷程式碼併為每個程式碼單元建立一個新的基本塊來建立一個新的 VMControlFlowGraph。基本塊儲存在一個名為 block 的 BTreeMap 中。
在對堆疊進行迭代的迴圈中,使用 block [&block] 對塊圖進行索引,堆疊已經用 ENTRY_BLOCK_ID 進行了初始化。這裡的假設是,在 block 反射中至少存在一個 ENTRY_BLOCK_ID。
然而,這一假設並不總是成立的。例如,如果提交的程式碼是空的,那麼在 「建立基本塊」 過程之後,「塊反射」 仍然是空的。當程式碼稍後嘗試使用 & blocks [&block].successors 中的 for succ 遍歷塊反射時,如果未找到 key,可能會引起隱式 panic。這是因為 blocks [&block] 表示式本質上是對 index () 方法的呼叫,如前所述,如果鍵不存在於 BTreeMap 中,index () 方法將導致 panic。
擁有遠端訪問許可權的攻擊者可以通過提交帶有空 code_unit 欄位的畸形模組有效載荷來利用該函式的漏洞。這個簡單的 RPC 請求會導致整個 JSON-RPC 程式崩潰。如果攻擊者以最小的代價持續傳送此類畸形有效載荷,就會導致服務持續中斷。在區塊鏈網路中,這意味著網路可能無法確認新的交易,從而導致拒絕服務(DoS)情況。網路功能和使用者對系統的信任將受到嚴重影響。
值得注意的是,Move Bytecode Verifier 中的 CodeUnitVerifier 負責確保 code_unit 部分絕不為空。然而,操作順序使 RPC 處理程式暴露於潛在的漏洞中。這是因為驗證過程是在 Validator 節點上進行的,而該節點是在 RPC 處理輸入模組之後的一個階段。
針對這一問題,Sui 通過移除模組釋出 RPC 例程中的反彙編功能來解決該漏洞。這是防止 RPC 服務處理潛在危險、未經驗證的位元組碼的有效方法。
此外,值得注意的是,與物件查詢相關的其他 RPC 方法也包含反彙編功能,但它們不容易受到使用空程式碼單元的攻擊。這是因為它們總是在查詢和反彙編現有的已釋出模組。已釋出的模組必須已經過驗證,因此,在構建 VMControlFlowGraph 時,非空程式碼單元的假設始終成立。
對開發人員的建議
在瞭解了顯式和隱式 panic 對區塊鏈中 RPC 服務穩定性的威脅後,開發人員必須掌握預防或降低這些風險的策略。這些策略可以降低服務意外中斷的可能性,提高系統的彈性。因此 CertiK 的專家團隊提出以下建議,並作為 Rust 程式設計的最佳實踐為大家列出。
Rust Panic Abstraction: 儘可能考慮使用 Rust 的 catch_unwind 函式來捕獲 panic,並將其轉換為錯誤資訊。這可以防止整個程式崩潰,並允許開發人員以可控的方式處理錯誤。
謹慎使用 API:隱式 panic 通常是由於濫用標準或第三方庫提供的 API 而發生的。因此,充分理解 API 並學會適當處理潛在錯誤至關重要。開發人員要始終假設 API 可能會失效,併為這種情況做好準備。
適當的錯誤處理:使用 Result 和 Option 型別進行錯誤處理,而非求助於 panic。前者提供了一種更可控的方式來處理錯誤和特殊情況。
新增文件和註釋:確保程式碼文件齊全,並在關鍵部分(包括可能發生 panic 的部分)添加註釋。這將幫助其他開發人員瞭解潛在風險並有效處理。
總結
基於 Rust 的 RPC 節點在 Aptos、StarCoin 和 Sui 等區塊鏈系統中扮演著重要的角色。由於它們用於連線 DApp 和底層區塊鏈,因此它們的可靠性對於區塊鏈系統的平穩執行至關重要。儘管這些系統使用的是記憶體安全語言 Rust,但仍然存在設計不當的風險。CertiK 的研究團隊通過現實世界中的例子探討了這些風險,也足以證明了記憶體安全程式設計中需要謹慎和細緻的設計。
📍相關報導📍
技術開倒車?但是很有趣!詳解以太坊銘文協議「Ethscriptions」