昨天下班的時侯剛好在 etherscan 上查代碼,瞄了一眼君士坦丁堡硬分叉的倒計時,發現離激活剛好還差 10000 個區塊,抓緊了這 10 秒的時間,截圖留念。
今天上午,安全審計團隊 ChainSecurity 猝不及防的發布了一份漏洞分析簡報。為此,以太坊核心開發團隊觸發了緊急響應,正式宣布君士坦丁堡升級延期,重啟時間未定。
……回想昨天自己發朋友圈時的激動興奮,感覺被狠狠打了臉。
下午讀完 ChainSecurity 的簡報,寫的很干練,可惜對不熟悉 ETH 的朋友們不夠友好,需要梳理半天的邏輯。所以抖個膽,自己寫個更詳細的拆解,我也是新入行,有錯誤的話,見諒。
sstore 重入漏洞拆解分析
理解原合約
ChainSecurity 給的被攻擊案例很有代表性,完整的被攻擊合約點此查看。
這是個 “雙人共享收款錢包” 合約(以下簡稱:錢包合約),我們來舉個正常情況下的使用例子:
- 為地址 「小A」、「小B」 登記一個編號為 1 的共享錢袋
調用錢包合約功能:init(錢袋1 , 小A, 小B)
- 設定收到 ETH 后的分賬比例為 小A:小B = 7:3
調用錢包合約功能:updateSplit(錢袋1, 70)
// 即「小A」獲得 70%,「小B」獲得其余部分
- 「小C」向錢包合約地址的錢袋 1 支付 10ETH
「小C」調用錢包合約功能:deposit(錢袋1) + 10ETH
// 此次交互 tx 攜帶 10ETH 的金額
// 此時錢包合約地址余額:10ETH
// 其中,登記為「錢袋1」的余額:10ETH
- 清算 錢袋1 的余額,合約自動依照設定比例支付給「小A」、「小B」
調用錢包合約功能:splitFunds(錢袋1)
// 最終,以下兩個轉賬函數 (transfer) 將依次執行
A.transfer(「錢袋1余額」 * 「分賬比」 / 100);
B.transfer(「錢袋1余額」 * (100 - 「分賬比」) / 100);
//「小A」獲得 (10ETH * 70 / 100) = 7ETH
//「小B」獲得 (10ETH * 30 / 100) = 3ETH
So far, so good.
感嘆下以太坊的靈活性,公平公正的分帳,童叟無欺。
攻擊搭建
那么,哪里會有問題呢???
ChainSecurity 給出了攻擊的具體方式,完整的攻擊者合約點此查看
「黑客X」將此合約部署到鏈上后,可獲得一個攻擊者合約地址(以下簡稱:攻擊合約)。
我們繼續有請善良的「小A」「小B」「小C」,重新操作以上的 [步驟1~步驟3]。
- 「黑客X」登場,為「攻擊合約」與「黑客X」登記了錢袋2,并對此錢袋支付 10ETH。
目前的錢包合約狀態為:
此時錢包合約地址總余額:20ETH
其中,登記為「錢袋1」的余額:10ETH
登記為「錢袋2」的余額:10ETH
// 錢袋1:屬于「小A」與「小B」
// 錢袋2:屬于「攻擊合約」與「黑客X」
- 「黑客X」調用攻擊合約的攻擊函數。攻擊代碼段如下,注意,此函數位于「攻擊合約」內:
function attack(address victim) {
// victim 為被攻擊的錢包合約地址
PaymentSharer x = PaymentSharer(victim);
// 繼承錢包合約的各個函數
x.updateSplit(2, 100);
// 子步驟 1:錢袋2的「分賬比」設置為「攻擊合約」獲取 100%
x.splitFunds(2);
// 子步驟 2:清算 錢袋2 的余額
// 攻擊達成:「攻擊合約」與「黑客X」各獲得 10ETH
}
目前的錢包合約狀態為:
此時錢包合約地址總余額:0ETH【他人余額被異常提取】
其中,登記為「錢袋1」的余額:10ETH
【此為賬簿數組中的記賬參數,非真實余額,目前已被黑客侵吞,無法實際兌付】
登記為「錢袋2」的余額:0ETH
// 錢袋1:屬于「小A」與「小B」
// 錢袋2:屬于「攻擊合約」與「黑客X」
充值 10ETH,提現 20ETH,Happy~
。。。。。。
。。。。。
。。。。
。。。
。。
。
等等。。。啥玩意兒?這就攻擊達成了???你啥都沒解釋啊!!!
別急,我們這就來拆解攻擊邏輯:)
攻擊邏輯拆解圖
由于涉及合約間交互,做個一圖流:
按照標號逐步看,也可以結合代碼。因該寫的比較通俗了。
關鍵點:上圖[流程 3]與[流程 6]的兩次轉賬 transfer()
間,直接默認 “不會發生分賬系數的變化” ,沒有額外的狀態檢查。結果被人利用合約漏洞,插入[流程 3-4]后重入,實現了記賬總額 200% 的異常提款。
可能還有同學有疑問,為什么 sstore
的 Gas 消耗 5000 時(以太坊現行邏輯)就沒出過問題?代碼不是一樣么,也跑的通啊。
這里還有個額外的知識點,為了讓轉賬函數 transfer()
更具擴展性,交易內額外觸發的 transfer()
操作,默認會帶上 2300 Gas 的“零錢”處理 fallback 中可能存在的后續操作(拆解圖中的 3*)。
以太坊現行邏輯中,單次 sstore
消耗 5000 Gas,“零錢”根本不夠扣。合約邏輯雖然不嚴密,但系統的資源限制事實上給大家兜了底。
君士坦丁堡更新,會令特殊條件下的 sstore
消耗大幅降低到 1700 Gas 的“可能性閾值”以下(用 call
觸發還需要消耗 600 Gas),兜底已經失效,變成了真正的攻擊入口。
總結
這是以太坊核心層的漏洞么?
我并不這么認為,作為合約的編寫者,需要應對的是各種復雜而晦澀的邏輯陷阱。這一漏洞應當在合約編寫時就有所防范,做好異常處理,而不是寄希望于“一個預置參數肯定不會變化,所以某些事情不用考慮,絕不可能發生”、“別人都這么做,所以我就 Ctrl-CV 一套”。
這是以太坊核心開發者的過錯么?
sstore 的 Gas 計算調整早在 2018 年 8 月 1 日便列入了協議改進提案(EIP-1283),于 11 月被接受為正式改進,如此大幅降低消耗至 1700 Gas 可觸發閾值以下的調整,理應更慎重的考慮舊合約的兼容性、安全性。
“無法篡改的技術負債”、“永遠的前向兼容”。So good. So bad.