COLDCARD 熵值失效事件:一次靜默的 RNG 軟體回退如何令用戶損失 3,800 萬美元

重點總結
- 在 COLDCARD Mk3 上產生並使用過助記詞的用戶:請立即在另一個錢包上重新產生助記詞,並將資金遷移到新錢包。
- 在 COLDCARD Mk4 或 Mk5 上產生並使用過助記詞的用戶:若產生原始助記詞的韌體版本低於 5.6.0,請立即在另一個錢包上重新產生助記詞,並將資金遷移到新錢包。
- 在 COLDCARD Q 上產生並使用過助記詞的用戶:若產生原始助記詞的韌體版本低於 1.5.0Q,請立即在另一個錢包上重新產生助記詞,並將資金遷移到新錢包。
- OneKey 硬體錢包用戶不受此漏洞影響,可以安心繼續使用。
在密碼學裡,「隨機」是一個嚴苛的指標。它是可以被量化的東西,也是必須在真實算力面前站得住腳的東西。
一組 12 個單詞的助記詞,背後是一個 128 位元的隨機數,並依照 BIP-39 規範編碼成人類可讀的單詞。這個數字有多難以預測,直接決定了錢包能否抵禦暴力破解。128 位元代表 2¹²⁸ 種可能。把地球上所有算力加在一起,直到宇宙終結你也枚舉不完。
因此理論上,硬體錢包在這件事上應該極為嚴謹。必須有一個專用的真隨機數產生器(TRNG),從電路噪聲中擷取物理世界的隨機性,搭配一枚通過 EAL 認證的安全元件(SE),最後再用一套成熟的演算法將這一切充分攪拌在一起。
2026 年 7 月 30 日,約 594 BTC(約 3,800 萬美元)在不到半小時內,被從將近 500 個 COLDCARD 錢包中一掃而空。翌日,製造商 Coinkite 在《Technical Deep Dive into the Entropy Issue》[1] 中確認了根本原因:自 2021 年 3 月的一次韌體改動起,一個建置設定錯誤一直悄悄地令程式碼回退到 MicroPython 內建的軟體偽隨機數產生器(PRNG)。
後果因型號而異。Coinkite 的初步估算是,Mk3 只剩下約 40 位元的有效搜尋空間,而 Mk4、Mk5 與 Q 約為 72 位元,對比 128 位元的設計目標。40 位元大約是 1.1 兆種可能,聽起來仍是天文數字,但對於一個租得起 GPU 叢集的攻擊者而言,它已經從「物理上不可能」下降為一個在期望上可行的工程專案。從 128 到 40,搜尋空間縮小了 2⁸⁸ 倍,也就是 3,000 億兆倍。
這宗 3,800 萬美元的盜竊,把這個「期望」變成了既成事實。
註:OneKey 用戶的資金是安全的。OneKey 的程式碼完全不涉及此問題,也沒有使用任何相關的實作或依賴。完整聲明:https://x.com/OneKeyHQ/status/2083106588022509974?s=20
TL;DR
- 在 COLDCARD Mk3 上產生並使用過助記詞的用戶:請立即在另一個錢包上重新產生助記詞,並將資金遷移到新錢包。
- 在 COLDCARD Mk4 或 Mk5 上產生並使用過助記詞的用戶:若產生原始助記詞的韌體版本低於 5.6.0,請立即在另一個錢包上重新產生助記詞,並將資金遷移到新錢包。
- 在 COLDCARD Q 上產生並使用過助記詞的用戶:若產生原始助記詞的韌體版本低於 1.5.0Q,請立即在另一個錢包上重新產生助記詞,並將資金遷移到新錢包。
- OneKey 硬體錢包用戶不受此漏洞影響,可以安心繼續使用。
接下來是硬核的技術部分 👇
根本原因:一次靜默的軟體回退
從硬體 RNG 介面到 libNgU
2021 年的密碼學堆疊遷移,把錢包助記詞的產生方式從 ckcc.rng_bytes() 改成了 ngu.random.bytes() [6]:
ngu.random.bytes() → libNgU rng_get() → 它最終連結到的那個 rng_get 符號
風險不在函式名稱或輸出長度,而在於究竟是哪個目標檔(object file)實際提供了最後那個 rng_get()。
#ifndef 只檢查有沒有定義,不檢查它的值
目標開發板設定把 MICROPY_HW_ENABLE_RNG 定義為 0,意即 MicroPython 的硬體 RNG 路徑並未啟用。
但當時 libNgU 使用的是 [8]:
#ifndef MICROPY_HW_ENABLE_RNG
#error ...
#endif
#ifndef 只測試巨集是否存在。一個被定義為 0 的巨集依然是「已定義」,所以那個本應攔截錯誤建置的 #error 從未觸發。
一個同名符號讓錯誤的實作溜進了最終韌體
當硬體 RNG 未啟用時,MicroPython 仍會以相同名稱提供一個 rng_get(),但那是 Yasmarang 軟體 PRNG 的回退版本 [9]。
這造就了完整的失效鏈:
巨集存在,但其值為 0 → libNgU 的建置防護不會觸發 → 韌體仍然成功編譯 → 同名的 rng_get 解析到 MicroPython 的回退版本 → 助記詞產生始終沒有收到它本應獲得的硬體 RNG 輸入
逐一來看,這條鏈上的每一環都只是一個小疏忽。串在一起,卻讓建置系統的把關形同虛設:一個安全的實作存在於原始碼或二進位檔中,並不代表那個攸關安全的呼叫真的會抵達它。
混合多個熵源,無法彌補缺失的熵
回退版本的初始狀態涉及部分 UID、SysTick、RTC 時間與次秒級狀態,之後 Yasmarang 以決定性方式推進它。libNgU 還會把它與另一條從固定初始狀態開始的 Yasmarang 資料流混合。
兩條可被重建或枚舉的決定性資料流,無論是互相 XOR 還是丟進 SHA-256,都不會因此獲得任何新的物理不可預測性。雜湊可以整理(condition)輸入,卻無法把一個有限的候選集合擴張成一個真正的 2^256 未知空間。
40 位元與 72 位元到底代表什麼
Coinkite 所用的有效搜尋空間估算,並不是實測、經認證的熵值。這兩個數字描述的是攻擊者必須逐一處理的候選狀態空間的大小。它們不是對最小熵(min-entropy)的量測,不會換算成平均猜測次數,更不會得出某個具體的攻擊時間或成本。
真實的攻擊成本取決於一連串前提條件:攻擊者是否知道或能猜出裝置 UID;能否縮小 RTC、SysTick 與開機時間的範圍;在助記詞產生前發生了多少次 RNG 呼叫;手上是否有 xpub、位址或公鑰可以離線驗證候選;以及執行 BIP-39/BIP-32 推導與並行硬體的成本。
所以準確的說法是:在 Coinkite 目前的初步攻擊假設下,Mk3 的有效搜尋空間估計約為 40 位元,Mk4/Q/Mk5 約為 72 位元。這並不等同於「每個 Mk3 錢包都恰好有 40 位元的熵」,也不等同於「較新的型號已被證明具有 72 位元的密碼學安全性」,更不等同於「任何一台普通電腦都能在固定時間內還原每一個錢包」。
為何 Mk4、Mk5 與 Q 也受影響
Mk5 與 Q 主機板上的兩枚安全元件(來源:coldcard.com)
Mk5 與 Q 主機板上的兩枚安全元件(來源:coldcard.com)
Mk4、Mk5 與 Q 的確會從兩枚安全元件擷取隨機材料,但修復前的實作並未把這些輸出全部餵進一個密碼學 DRBG。
公開原始碼顯示,在對安全元件材料做雜湊之後,它只取了四個位元組,傳給 ngu.random.reseed(),僅替換了 Yasmarang 的一個 32 位元狀態字(state word)。
因此,當回退狀態的其餘部分與呼叫軌跡都固定時:
安全元件 reseed 可區分的輸出資料流數量 ≤ 2³²
這並不與官方「約 72 位元」相矛盾。72 位元的模型同樣把 UID、計時器、RTC 與呼叫歷史等狀態中的不確定性計算在內。Block Engineering 給出的寬鬆組合上界落在 2^73.3 以下,而他們也明確表示,這並非對 73 位元密碼學安全性的認證 [10]。
換句話說:Mk3 是最壞情況,因為它根本沒有任何安全 reseed。Mk4/Q/Mk5 上的安全元件輸入降低了風險,但四個位元組的 reseed 無法還原設計原本要達成的 128 位元安全性。
官方公告與實際影響範圍
Coinkite 目前的公告從 Mk3 4.0.1 起算。但不可變的 v4.0.0 原始碼已經包含相關的熵值產生路徑 [7],而 4.0.0 與 4.0.1 之間並沒有對應的 RNG 修復。
Block Engineering 走得更遠,把 Mk2/Mk3 v4.0.0–v4.1.9 也納入其原始碼分析 [10]。
所以要分開看兩個層次:
- Coinkite 的官方範圍:Mk3 4.0.1+;
- 原始碼所支持的技術範圍:Mk2/Mk3 v4.0.0–v4.1.9。
你不能說 Coinkite 已官方確認 Mk2 或 v4.0.0。但你也不能因為公告沒有點名它們,就推斷它們是安全的。
修復是如何運作的
Mk4/Mk5 5.6.0 與 Q 1.5.0Q 的緊急修復,不只是改了一個條件式,還加入了建置層級的強制檢查 [3][4][5]:
- 排除 MicroPython 的回退 RNG 物件;
- 確保開發板層級的物件提供全域的
rng_get(); - 使用
nm檢查目標檔中的符號; - 若回退版本仍然匯出該符號,或開發板層級的物件未正確提供
rng_get(),就直接讓建置失敗。
用戶應該怎麼做
Mk4、Mk5 與 Q
- 先更新到 5.6.0 或更新版本(Mk4/Mk5),或 1.5.0Q 或更新版本(Q)。
- 在已修復的韌體上產生一組全新的助記詞。
- 記錄備份並完成一次還原驗證。
- 在裝置螢幕上核對錢包指紋與收款位址。
- 在遷移其餘資金之前,先發送一筆小額測試交易。
- 在每一筆轉帳都確認之前,保留舊備份,但停止再向舊錢包收款。
Mk3
目前 Mk3 尚無已釋出的修復韌體,4.1.9 也不是修復版本。用戶不應等待日後可能推出的韌體,因為任何新韌體都無法為既有的助記詞增添熵值。
請遵循 Coinkite 目前的遷移指引:在可信路徑上產生一組新助記詞,驗證備份、位址與一筆小額交易,然後遷移你的資產。
兩個不同的骰子門檻
骰子有兩個數字:50 和 99。它們服務於兩個不同的目標,不能互換使用。
在原始助記詞產生時額外加入 50 次以上的骰子。 若在助記詞首次產生時,混入了至少 50 次公平、獨立、私密、且從未被記錄、儲存或洩露的六面骰結果,Coinkite 表示,僅就這個 RNG 問題而言,它不認為該助記詞處於風險之中 [11]。
50 × log₂(6) ≈ 129.25 位元
這是針對此次特定事件的約 128 位元門檻,而不是對你整個操作流程的絕對安全證明。
在空白 Mk3 上的 Dice Roll Import 流程。 Coinkite 還提供了另一種流程:在執行 4.1.9 的空白 Mk3 上,選擇 Import Existing > Dice Rolls,並輸入至少 99 次公平的骰子結果。這條專用路徑直接消耗骰子序列,不會使用受影響的裝置產生器。
99 × log₂(6) ≈ 255.91 位元
99 是官方流程中的門檻;若從字面上的「至少 256 位元」標準反推,你會需要 100 次。
通行密碼是另一道獨立防線,而非修復手段
一個強壯、獨一無二、隨機且保密的 BIP-39 通行密碼(passphrase)會推導出一個不同的錢包,並能增加一個獨立的搜尋因子;裝置 PIN 碼只控制本地存取,完全不參與 BIP-39 根金鑰的推導 [12]。
但通行密碼無法彌補助記詞從一開始就不具備的熵。短通行密碼、名言佳句、可預測的模式與重複使用的密碼,仍然可能被猜中。一個打字錯誤也會產生一個看似有效、實則完全不同的錢包,所以你必須核對指紋,並保留一份你已測試過還原的可靠備份。
結語
這次事件暴露的核心問題是:一個攸關安全的實作,其實際可達性(reachability)從未由建置系統來強制保證。「有人把某個巨集寫錯了」遠遠不足以描述它。
硬體錢包與其他金鑰產生系統,至少應該:
- 驗證一個安全 API 最終連結到的是哪個目標檔與哪個符號,而不只是檢查函式簽章;
- 讓能力檢查同時驗證巨集的存在與其數值;
- 讓攸關安全的回退「故障即關閉」(fail closed),絕不靜默地提供一個普通的 PRNG;
- 讓 CI 檢查目標檔組成、連結映射(link map)、符號來源與端到端的資料流;
- 透過測試證明真正的硬體熵抵達了最終的助記詞,而不只是檢查輸出長度、是否非零或是否無重複;
- 撰寫能清楚區分「產生助記詞的版本、當前韌體、修復版本、以及舊助記詞該如何處理」的公告。
COLDCARD 的開源,正是外部研究者得以重建這條呼叫鏈的原因,而 Coinkite 也已發布正式的技術報告與修復版本。
宣稱一個品牌「絕對安全」毫無價值。真正有價值的,是把「隨機性從何而來、最終連結到什麼、在失效時流向何處、以及如何在出貨產品中驗證這一切」變成一個你可以持續反覆檢查的系統性質。
References
[1] Coinkite: Technical Deep Dive into the Entropy Issue [2] Coinkite: Mk3 Security Advisory [3] Coldcard 5.6.0 / 1.5.0Q fix commit [4] Coldcard 5.6.0 release commit [5] Coldcard 1.5.0Q release commit [6] 2021 libNgU seed generation migration commit [7] Official v4.0.0 source snapshot [8] libNgU random.c [9] MicroPython commit introducing the fallback [10] Block Engineering technical analysis [11] Coldcard dice roll math [12] Coldcard passphrase documentation [13] OneKey trezorcrypto.random source branch [14] OneKey seed generation logic [15] OneKey standard Makefile [16] OneKey SCons config






