公鏈安全之以太坊君士坦丁堡重入漏洞分析
前言
在上週,以太坊準備進行君士坦丁堡硬分叉的前一日被披露出來了一則漏洞,該漏洞由新啟動的 EIP 1283 引起, 漏洞危害準確的說應該是一種可能會讓一些合約存在重入漏洞的隱患,而不是一定會使合約產生重入漏洞 。
該漏洞在被發現之後以太坊基金會立馬宣佈了停止硬分叉,並商議擇日再啟動以太坊君士坦丁堡硬分叉。
EIP 1283
EIP的全稱是Ethereum Improvement Proposals(以太坊改進提案),誰都可以上去提一些對以太坊的改進提案,不過必須得嚴謹、正式,以太坊君士坦丁堡這次漏洞就是由一個EIP引起的,這個EIP的編號是1283,詳情地址如下:
https://eips.ethereum.org/EIPS/eip-1283
該提案是針對SSTORE操作碼的,該操作碼主要用於合約持久化儲存資料,EIP1283為SSTORE操作碼設計了更加合理的gas收費方式。
其中定義了三個概念:
1. 儲存槽的原始值(original):在當前事務發生回滾(revert)後會存在的值叫原始值。
2. 儲存槽的當前值(current):在使用SSTORE操作碼之前存在的值叫當前值。
3. 儲存槽的新值(new):在使用SSTORE操作碼之後存在的值叫新值。
然後以這三個概念為基礎,設計瞭如下處理邏輯:
如果 當前值 等於 新值 (這是無操作),則扣除 200 gas 。
如果 當前值 不等於 新值
如果 原始值 等於 當前值 (此儲存槽未被當前執行上下文更改)
如果 原始值 為 0,則扣除 20000 gas 。
否則,扣除 5000 gas 。如果 新值 為 0,則在 退款計數器 中增加 15000 gas (退款計數器中記錄的gas會退還給使用者)。
如果 原始值 不等於 當前值 (代表此儲存槽”髒”了),則扣除 200 gas 。
如果 原始值 不為0
如果 當前值 為 0(也表示 新值 不為0),請從退款計數器中減少 15000 gas 。
如果 新值 為 0(也表示 當前值 不為0),請向退款計數器中增加 15000 gas 。
如果 原始值 等於 新值 (此儲存槽已重置)
如果 原始值 為 0, 19800 gas 。
否則,則在退款計數器中增加 4800 gas 。
根據如上的邏輯可以發現,當使用SSTORE操作碼的時候如果不改變任何值的時候,只消耗 200 gas。如果改變了值最終又重置為0的話也只消耗20000 + 200 - 19800 = 400 gas。
而在之前EIP 1087的邏輯中如果使用SSTORE操作碼改變了值最終又重置為0的話需要消耗20000 + 5000 - 10000 = 15000 gas。
顯然EIP 1283的處理邏輯比EIP 1087更加合理,也更加便宜,但是問題就在這裡。
漏洞分析
重入漏洞是指在同一筆交易中因兩個合約互相呼叫而導致合約進行重複轉賬的一種現象,其產生的根源是沒有使轉賬作為事務的最後一個步驟。
比如說,如果在轉賬之後再進行狀態變更的話就很容易重入漏洞,最經典的一起事件就是The DAO事件,所以最安全的做法是一筆事務中只有一筆轉賬,且在轉賬之前做好所有狀態變更,轉賬作為最後一個操作進行,如果以這種標準來實現的話,是不會受EIP 1283影響的,所以這就是為什麼說EIP 1283 只是可能使某些合約產生重入漏洞隱患。
那麼,什麼樣的合約容易產生這種隱患?請看以下Demo。
這是一個模擬資金共享服務的合約,資金餘額由deposits變數儲存,然後由splits變數儲存分配比例。
比如有一筆資金需要a和b共同分配
-
首先呼叫init函式儲存雙方的錢包地址
-
呼叫deposit函式向通道充錢
-
呼叫updateSplit函式來改變通道的分配率
-
執行splitFunds函式分配資金
如果1號通道的分配率是99,那麼執行splitFunds函式的時候給a分配通道中99%的資金,給b分配1%的資金。
該合約大概業務就是這樣,在 EIP 1283 生效之前,該合約是沒有重入漏洞的, EIP 1283 生效才會存在重入漏洞。
前面提到過了, 在EIP 1283中如果將一個值更改後又重置為0 ,那麼只消耗400 gas 。
再看看是怎麼實現按比例分配的:
所以我們可以將a賬戶設定為我們的惡意合約,在合約的fallback函式中呼叫updateSplit函式來改變通道的分配率,使兩個地址都能分到超過通道餘額總量的幣.
比如說我先給a賬戶分配100%的通道餘額,再在a賬戶合約fallback函式中改變通道分配律,又給b賬戶分配100%的餘額,這樣就成功套出了雙倍的錢,而且攻擊者可以一直套,直到掏空為止。
攻擊者Demo:
Ps:為了節約gas,fallback函式中使用內聯彙編來模擬呼叫updateSplit函式。
呼叫attack函式即可觸發重入漏洞。
為什麼說要EIP 1283生效才會產生漏洞呢,因為該合約使用transfer進行轉賬,transfer轉賬最多消耗2300 gas,在EIP 1283生效之前對變數進行更改再重置至少需要15000 gas,而生效後只需要400 gas,2300 gas上限已經足夠做一些事情了。
漏洞復現
關於該漏洞的復現,ChainSecurity已經在Github上公開了。
先clone下來
git clone https://github.com/ChainSecurity/constantinople-reentrancy.git
然後README裡面會告訴你怎麼復現,不過在此之前先得把環境裝好,需要環境:
-
nodejs(stable)
-
npm
-
truffle:npm install -g truffle
-
ganache-cli@beta:npm i -g ganache-cli@beta
不同的系統有不同的環境搭建方式,這裡不再贅述,有了以上環境就可以進行復現了,執行以下命令:
ganache-cli --hardfork=constantinople truffle test
執行結果:
在進行攻擊之後成功增加攻擊賬戶內的餘額,復現完畢。
修復方案
修復方案預計應該會在以太坊君士坦丁堡中刪除與EIP 1283有關的更新,目前以太坊開發者還在協商解決,不過筆者認為合約安全最終還是要合約來解決,不能依賴於公鏈本身,就像前面說的,只要合約採用的是最安全的寫法便可以避免這次君士坦丁堡分叉帶來的問題。
而且目前還沒有檢測出來有合約正好會觸發這個重入漏洞,但不排除這種可能性。
參考連結
https://medium.com/chainsecurity/constantinople-enables-new-reentrancy-attack-ace4088297d9
https://github.com/ChainSecurity/constantinople-reentrancy