價格分析

tBTC安全漏洞完整披露 去中心化錨定幣為何步履艱難?

寫在前面:從備受矚目的主網上線,到突發的緊急暫停,tBTC只用了大約2天的時間,那它到底發生了什麼?由Keep團隊撰寫的事件回顧報告,詳細指出了漏洞所在以及相關的發現經過,同時,這也暴露出了tBTC系統複雜設計所帶來的挑戰。

(圖片來自:tuchong.com)

以下是報告譯文:

2020年5月18日上午(UTC時間),在以太坊和比特幣主網進行了大約48小時的測試之後,Keep團隊決定觸發TBTCSystem合約允許的10天緊急存款暫停,該團隊發現,當某些類型的比特幣地址用於贖回時,存款合約的贖回流中出現了一個重大問題,它會使開放存款的簽名者保證金面臨清算風險,因此決定觸發這一暫停。

實際上,支持開放存款的保證金(以及部分未償TBTC供應)完全屬於一個單獨的運營商,該運營商很早就加入了系統,在測試過程中,其與Keep團隊經常保持溝通。在暫停後不久,團隊提出用1.005 BTC 兌1 TBTC的比率交換TBTC,以恢復TBTC的供應,從而使該地址的供應恢復了99.83%。團隊會觸發對開放存款的有控制贖回,以釋放支持這些存款的剩餘保證金。 Keep團隊還將協調清除系統中任何剩餘的未使用ETH,儘管它們不存在風險。

tBTC系統的大致設計

首先,先為大家簡單介紹一下tBTC系統的設計(譯者吐槽:雖然是簡介,但實際並不簡單)。在tBTC系統中,有權訪問BTC的人(即存款人)可以在以太坊區塊鏈上打開一個tBTC存款合約。這個存款是一個智能合約,它與網絡中的3個簽名者進行交互,這些簽名者共同生成和控制一個比特幣錢包(沒有單一的簽名者可以訪問該錢包)。在打開一個存款合約時,存款人從幾個可用的lot大小中選擇一個(譯者註:最初的設計為1BTC,目前有了多種選擇),一旦存款生成了該錢包地址,將相應數量的BTC發送到該比特幣地址。然後,存款人向存款智能合約提交證明,證明BTC轉賬已經發生了,並且能夠鑄造出等量的TBTC(以太坊ERC-20代幣)。這允許BTC持有者進入tBTC系統,然後使用其TBTC代幣餘額與支持它的智能合約交互。

為了向以太坊區塊鏈正確證明比特幣網絡上的交易,tBTC系統採用了一種中繼(relay),將有關比特幣區塊的足夠數據傳送到以太坊智能合約,以確認比特幣交易已累積了一定數量的確認。這是用來確保交易(a)存在於比特幣鏈上,並且(b)被充分確認有合理的確定性,即比特幣區塊鏈的分叉將無法移除它。

為確保控制錢包的簽名者,不能以未經授權的方式非法轉讓他們共同持有的BTC,他們必須要提供相當於存款BTC 150%價值的ETH保證金。這些保證金由存款智能合約持有,直至存款被贖回。

當TBTC持有者有意換回BTC時,他們可以通過一個稱為贖回的過程來完成。贖回操作允許以太坊用戶(贖回者)支付lot大小的資金加上少量的簽名費,然後其指定一個比特幣地址,並授權3個簽名者共同生成一個簽名來完成比特幣交易,將BTC從存款地址轉移至指定地址。這允許TBTC持有人退出tBTC系統,回到比特幣的區塊鏈,同時將簽名者的保證金返回到各自的可用資金池以支持新存款。然後,簽名者瓜分掉由贖回人支付的簽名費用。

事件時間線

Keep團隊認為這次事件起源於部署tBTC時,該過程是在2020年3月15日15:52 (UTC時間)完成的,當時創建了tBTC系統的抽籤池。不過,該抽籤池的部署本身並沒有使任何資金處於風險之中,它是用於隨機選擇有足夠保證金支持的簽名者。簽名者必須通過將ETH放入保證金合約來選擇使用這個抽籤池。然後,該合約需要簽名者的額外授權,以便能夠使用他們注入到保證金合約中的資金。最後,簽名者必須在抽籤池中進行註冊。

在接下來的3天左右的時間裡,有幾位簽名者提供了保證金,並授權了抽籤池,但其中只有3位簽名者在池中進行了註冊,以在存款開放期間使用,而這3位簽名者實際都由同一個人控制,在Keep團隊測試存款和贖回時,他們在做好準備的情況下伸出了援助之手,幫助Keep進行了測試。

存款可通過https://dapp.tbtc.network/上的alpha版dApp看到,目前限制為0.001 BTC存款大小。三位簽名者提供的ETH保證金也對可鑄造的TBTC數量設定了一個上限,因為每一筆TBTC存款,需要以1.5倍價值的ETH進行擔保。在調查潛在問題時,該dApp在2020年5月15日晚上短暫撤下,但在了解到問題後,團隊便在5月16日重新啟用。此外,社區中的幾位成員設置了dApp的本地版本,並使用它們來開設大於0.001 BTC lot大小的存款。

5月18日2:29(UTC時間),控制這3個簽名者的運營商試圖贖回他們已開設的存款,但發現無法完成這個兌換過程。

他們聯繫了團隊的一個成員,其轉達了另一位團隊成員注意到的一個問題,指出以太坊區塊鏈上的高gas價格導致中繼對比特幣狀態的更新落後了幾個區塊。 Keep團隊告知該運營者很可能是其所看到的問題,而該中繼開始使用更高的gas價格,並在UTC時間 3:07趕上了進度。

在UTC時間3:13,該運營商表示他們仍然無法完成贖回操作,然後Keep團隊使用了本地版本的dApp來調查問題,此時觀察到存款合約中存在的一個漏洞——“Tx sends value to wrong pubkeyhash。” 該漏洞表明,dApp構建的用於向以太坊區塊鏈顯示贖回已成功完成的證據,是不正確的。特別是,該證據未能成功證明交易將存款的BTC發送到正確的贖回者地址。

經過30分鐘的調查,Keep團隊懷疑該問題不一定是出在dApp的客戶端證明中,而是在以太坊區塊鏈上用於贖回的特定比特幣地址及其可證明性。在核實這一懷疑之前,Matt Luongo通知了涉及到的3位當事人的其中兩位,讓他們準備就緒,以防萬一。之後,Keep團隊調查了智能合約中存在的問題,並確定了潛在的保證金威脅問題。由於簽名者的保證金在沒有贖回證明的情況下,只能在6小時後才能扣押,因此Keep團隊決定繼續調查並確認合約問題,然後再採取進一步的行動。

在UTC時間4:43,Matt通知了James Prestwich,讓其對該發現進行證實,而後者撰寫了tBTC 合約的大部分內容,其具有豐富的比特幣開發經驗,並且他還是相關bitcoin-spv及中繼庫的作者。 James在UTC時間 5:02證實了這一發現,此後,Keep團隊立即開始將託管的dApp URL重定向到tBTC主頁,以防止新的存款被打開。

在UTC時間5:18,Keep團隊在確認問題的存在,並意識到合約無法簡單通過外部修復之後,決定觸發tBTC系統合約中可用的一次性10天緊急暫停。此功能可暫停為期10天的新存款,但不會影響任何已打開的存款。為了安全起見,觸發任何tBTC系統更新的過程,需要錢包團隊3名技術成員中的2名,通過手動的方式創建以太坊交易,然後使用氣隙系統簽署交易信息,最後將交易和簽名提交到以太坊區塊鏈。而該過程是在UTC時間5:45完成的。

在第二天早晨(東部時間),Keep團隊意識到,儘管託管dApp的登錄頁面已重定向至tBTC主頁,但託管dApp上的其他頁面(例如特定的存款頁面),卻沒有這個操作。與其冒著任何現有存款無意中觸發贖回錯誤的風險,還不如將託管dApp的其餘頁面重定向到UTC時間14:11的tBTC主頁。

技術問題描述

問題本身的根源,在於證明贖回交易事實上已在比特幣區塊鏈上進行的過程。在正常情況下,為比特幣交易提供有效簽名的簽名者,可能會立即釋放保證金,讓贖回者負責在比特幣區塊鏈上廣播該交易。然而,如果tBTC系統在這一階段解除了簽名者的經濟義務,則簽名者將有機會進行作惡。

因此,tBTC系統僅在簽名者出示有效簽名並證明交易在比特幣區塊鏈上被接受後,才會釋放簽名者保證金。

證明在比特幣區塊鏈上的贖回交易已得到充分確認的證據,適用於一些健全性檢查。其中之一是驗證比特幣交易是否將簽名者共同控制的資金,發送到請求的贖回地址。這些檢查由redemptionTransactionChecks函數執行的:

functionredemptionTransactionChecks(
DepositUtils.Depositstorage_d,
bytesmemory_txInputVector,
bytesmemory_txOutputVector
)publicviewreturns(uint256){
require(
_txInputVector.validateVin(),
「invalidinputvectorprovided」
);
require(
_txOutputVector.validateVout(),
「invalidoutputvectorprovided」
);
bytesmemory_input=
_txInputVector.slice(1,_txInputVector.length-1);
bytesmemory_output=
_txOutputVector.slice(1,_txOutputVector.length-1);
require(
keccak256(_input.extractOutpoint())==
keccak256(_d.utxoOutpoint),
「TxspendsthewrongUTXO」
);
require(
keccak256(_output.slice(8,3).concat(_output.extractHash()))==
keccak256(abi.encodePacked(_d.redeemerOutputScript)),
「Txsendsvaluetowrongpubkeyhash」
);
return(uint256(_output.extractValue()));
}

而觀察到的錯誤是在最後一次檢查中,“Tx sends value to wrong pubkeyhash” 。

require(
keccak256(_output.slice(8,3).concat(_output.extractHash()))==
keccak256(abi.encodePacked(_d.redeemerOutputScript)),
「Txsendsvaluetowrongpubkeyhash」
);

比特幣有幾種類型的輸出腳本。最常見的類型有:pay to pubkeyhash (p2pkh)、pay to scripthash (p2sh)、pay to witness pubkeyhash (p2wpkh)以及pay to witness scripthash (p2wsh),我們將這些稱為標準輸出類型。地址代表一個20或32字節的哈希、一個校驗以及有關輸出腳本類型的信息。類型信息用於將哈希插入標準模板,這將創建相應的輸出腳本。

輸出腳本的長度不同。所有輸出腳本都以1字節作為前綴,例如,標準的p2pkh輸出腳本的長度為25字節,或26字節(計算其長度前綴)。輸出的值表示為8字節的小端(little-endian)整數,在輸出腳本之前立即序列化。因此,標準輸出的長度在(8 +1 + 22 =)31到(8 +1 + 34 =)43個字節之間。

BTCUtils.extractHash()從標準輸出中提取哈希。它通過檢查輸出腳本的前綴和後綴來確定哈希的位置。如果輸出腳本是非標準的,則返回空的bytearray(字節數組)。

我們已經可以看到一些模式。所有舊類型腳本都有後綴,而所有見證類型(witness)都沒有。除p2pkh以外的所有類型腳本都將在輸出的第十個字節開始哈希,該字節位於索引:

(_output.slice(8,3).concat(_output.extractHash()))

該表達式佔用字節8、9和10,並連接哈希。對於見證(witness)類型,字節8是長度前綴,而字節9和10是模板前綴,因此很容易看出,將它們連接到哈希將產生(長度前綴)輸出腳本。但是,對於p2sh地址,此表達式不會附加模板後綴。對於p2pkh地址,它將僅提取前綴的2個字節,並且(同樣)不附加後綴。這意味著表達式會修改舊輸出腳本,並且永遠不會輸出有效的舊輸出腳本。

bytesmemory_modifiedLegacyOutputScript=
(_output.slice(8,3).concat(_output.extractHash()));
require(
keccak256(_modifiedLegacyOutputScript)==
keccak256(abi.encodePacked(_d.redeemerOutputScript))
);

此代碼等效於已部署的代碼。在意外修改舊版腳本之後,它會將它們與未修改的舊版輸出腳本進行比較。當_d.redeemerOutputScript是舊版腳本時,此等式將始終失敗,並且交易將始終被還原。

這個錯誤既不會損害贖回者,也不會損害存款者的利益,也就是說,用戶的資金是安全的。實際上,由於此代碼驗證了贖回證明,因此它只在贖回者收到BTC後運行。

但是,由於系統無法驗證贖回是否成功,簽名者保證金可以像贖回失敗一樣被扣押。特別是,如果在簽名者提供該筆交易的簽名後6小時,尚未證明存款合約發生了贖回交易,則贖回人可通知合約贖回證明已超時。

而贖回證明超時通知,會被視為簽名者中止的信號,這意味著簽名者未滿足系統要求,但係統不將其視為惡意。在贖回期間,這意味著系統無法驗證贖回人是否收到了他們的資金。在這種情況下,系統會沒收簽名者的保證金,並將全部保證金作為補償發送給贖回者。採取這種方法是為了防止以下情況:簽名者在請求的贖回交易上生成簽名,但又合謀在另一筆交易上生成簽名,然後在確認正確的交易之前競相確認其交易。

在正常情況下,產生不良交易的簽名者,可能會遭受懲罰,但這次發生的情況,顯然並不是正常的。

tBTC的代碼是如何過審的?

tBTC的初始設計將贖回限制為p2wpkh地址,並在贖回過程中強制執行此限制。但在2月初,Keep團隊的工程主管Antonio提出了一項更改,放寬了贖回交易允許的輸出腳本(不僅僅是p2wpkh)。在贖回期間,這是為了允許存款接受任意比特幣輸出腳本,讓贖回者能夠靈活地接受他們喜歡使用的任何錢包。而有問題的代碼在更改之後被保留了下來。

上面的commit消息指出:“結果通過了所有當前測試,但repo中尚未對非p2wpkh輸出腳本進行測試。”

這一點在接下來的幾個月內沒有改變。

其他觀察

上面的問題不是贖回代碼中存在的唯一問題,實際上,即使證明代碼正確無誤,由於有關OP_VERIF和OP_VERNOTIF的共識規則,惡意的贖回者仍可能會指定一個產生無效比特幣交易的輸出腳本。這將迫使交易永遠不會包含在比特幣區塊中。在這種情況下,能夠確認交易的輸出腳本是無關緊要的,贖回方將能夠保證收到保證金(同時將BTC留給簽名者)。也就是說,除了贖回後的錯誤驗證外,在請求贖回時還缺少驗證。因此,將來的版本必須只支持標準地址類型。

值得注意的是,如果贖回者指定了將導致無效比特幣交易的輸出腳本,他們就能夠拿到簽名者的保證金,而簽名者則只能控制BTC存款,但由於簽名者保證金150%的超額抵押,這意味著惡意贖回者仍將從這種情況中獲益。

這個錯誤的驗證代碼也存在於無效的代碼路徑中,其在錯誤修正PR中已被刪除。

總結錯誤

最終,總結下這次事件當中的幾個問題:

  1. 首先,Keep團隊未能在新commit提出之後進行更多的測試。因此,團隊錯過了在開發過程中抓住這個問題的機會。

  2. 在基於dApp的手動質量檢查過程中,Keep團隊沒有驗證UI中的成功兌換是否導致了鏈上的關閉存款。結果導致團隊錯過了在手動質量檢查過程中發現問題的機會。

  3. Keep團隊沒有在贖回的入口點充分考慮輸入驗證。這是系統中相對較少的完全由用戶控制的數據片段之一,因此應該是輸入驗證的首要考慮因素。

  4. Keep團隊沒有花費足夠的時間為單元測試生成比特幣測試向量。

Keep團隊已經做了什麼?

  1. James Prestwich已在GitHub上發布了一個PR,並提供了建議的修復程序。在合併該修復程序之前,Keep團隊會在接下來的幾天進行測試。

  2. Keep團隊已調整了計劃中的Trail of Bits審計範圍;

  3. Keep團隊已將發現的問題及修復方案與其之前的審計方ConsenSys Diligence和現在的審計方Trail of Bits進行了溝通,以確認問題,並讓他們提供進一步的反饋。

  4. 通過提供1.005 BTC-1 TBTC的比率來兌換未贖回的TBTC,Keep團隊恢復了99.83%的TBTC供應,他們將使用有抵押的TBTC贖回未結存款,並釋放抵押的ETH。

接下來要做什麼?

除了要進行技術和流程改進之外,在未來的幾天,KEEP團隊還將宣佈如何重部署tBTC系統。

—————————————————————————————————————————————————— ————————————————————

以上就是Keep團隊對這次漏洞事件的完整解釋,對此,比特幣代碼維護者Pieter Wuille也和相關人員進行了討論,並確認了問題所在。

此外,也有人建議Keep團隊與經驗豐富的比特幣開發者接觸,讓他們對比特幣的邏輯部分進行雙重甚至三重審核,而僅僅是以太坊合約部分的審核是不夠的。

而這一建議,也得到了Keep創始人的肯定。

下面簡單談談個人的一些看法:

  1. 為了追求最大化的去中心化,tBTC系統的設計毫無疑問是非常複雜的,這導致潛在的安全問題會有很多,這次的事件並不讓人意外;

  2. 短期內,願意去嘗試tBTC的用戶其實非常少,其正式在以太坊主網上線後,只有3名簽名者完成了註冊步驟,這與高門檻及協議處於初期階段有關;

  3. 系統確實很去中心化,關於問題的報告也寫得非常詳細;

  4. 短期內,一些較中心化的比特幣錨定幣(例如WBTC)會發展得較快,tBTC需要時間和更多的努力來證明自己;