By:小白@慢霧安全團隊

背景概述

上次我們了解了什麼是溢出漏洞和如何預防和發現它。這次我們要了解的是solidity 中自帶的函數—— selfdestruct 自毀函數。

前置知識

我們先來了解solidity 中能夠轉賬的操作都有哪些:

1. transfer:轉賬出錯會拋出異常後面代碼不執行;

2. send:轉賬出錯不會拋出異常只返回true/false 後面代碼繼續執行;

3. call.value().gas()():轉賬出錯不會拋出異常只返回true/false 後面代碼繼續執行,且使用call 函數進行轉賬容易發生重入攻擊(這裡可查閱:智能合約安全審計-入門篇之重入漏洞)。

上面三種都需要目標接收轉賬才能成功將代幣轉入目標地址,下面我們來看一個不需要接受就能給合約轉賬的函數:自毀函數。

自毀函數由以太坊智能合約提供,用於銷毀區塊鏈上的合約系統。當合約執行自毀操作時,合約賬戶上剩餘的以太幣會發送給指定的目標,然後其存儲和代碼從狀態中被移除。然而,自毀函數也是一把雙刃劍,一方面它可以使開發人員能夠從以太坊中刪除智能合約並在緊急情況下轉移以太幣。另一方面自毀函數也可能成為攻擊者的利用工具,攻擊者可以利用該函數向目標合約“強制轉賬”從而影響目標合約的正常功能(比如開發者使用address(this).balance 來取合約中的代幣餘額就可能會被攻擊)。今天我們就來看一個攻擊者利用自毀函數的強制轉賬特性對智能合約發起攻擊導目標合約癱瘓的案例。

漏洞示例

下面我們來看目標合約:

// SPDX-License-Identifier: MITpragma solidity ^0.8.10;contract EtherGame { uint public targetAmount = 7 ether; address public winner; function deposit() public payable { require(msg.value == 1 ether, 'You can only send 1 Ether'); uint balance = address(this).balance; require(balance <= targetAmount, 'Game is over'); if (balance == targetAmount) { winner = msg.sender; } } function claimReward() public { require(msg.sender == winner, 'Not winner'); (bool sent, ) = msg.sender.call{value: address(this).balance}(''); require(sent, 'Failed to send Ether'); }}

漏洞分析

EtherGame 合約實現的功能是一個遊戲,我們這裡可以稱它為“幸運七”。玩家每次向EtherGame 合約中打入一個以太,第七個成功打入以太的玩家將成為winner。 winner 可以提取合約中的7 個以太。

玩家每次玩遊戲時都會調用EtherGame.deposit 函數向合約中先打入一個以太,隨後函數會檢查合約中的餘額(balance)是否小於等於7 ,只有合約中的餘額小於等於7 時才能繼續否則將回滾。合約中的餘額(balance)是通過address(this).balance 取到的,這就意味著我們只要有辦法在產生winner 之前改變EtherGame 合約中的餘額讓他等於7 就會使該合約癱瘓。這樣我們的攻擊方向就明確了,只要我們強制給EtherGame 合約打入一筆以太讓該合約中的餘額大於或等於7 這樣後面的玩家將無法通過EtherGame.deposit 的檢查,從而使EtherGame 合約癱瘓,永遠無法產生winner。

但是EtherGame.deposit 函數中存在驗證:require(msg.value == 1 ether, 'You can only send 1 Ether'),這裡要求我們每次只能打一個以太進去,所以通過正常路徑是不可能一次向EtherGame 打入大於1 枚的以太的,但是我們又需要打入大於1 枚的以太到EtherGame 合約中,所以需要找到另外的路徑,來將以太轉入到EtherGame 合約中。

這裡就要請出我們今天的主角:自毀函數——selfdestruct。從前置知識中我們可以看到,當合約執行自毀操作時,合約賬戶上剩餘的以太幣會發送給指定的目標,我們可以構造一個攻擊合約,然後觸發selfdestruct 函數讓攻擊合約自毀,攻擊合約中的以太就會發送給目標合約。這樣我們就可以一次向EtherGame 合約中打入多枚以太,而不通過EtherGame.deposit 函數,從而完成攻擊。

舉個例子:在極端情況下,如果已經有六個玩家參與了遊戲且成功向合約中各自打入了1 個以太,此時合約中有6 枚以太,這樣我們只需要用selfdestruct 強制打入一枚以太,而不走EtherGame.deposit 的邏輯,就會導致EtherGame 合約記賬錯誤, 從而導致合約癱瘓(DoS),就會造成合約中的6 枚以太無法取出,因為此時還沒有誕生出winner。 (當然也可以通過EtherGame.deposit 將以太轉入到合約中,這樣是可以成為winner 然後取出合約中的7 枚以太,不過這種情況我們就先不做討論,本篇僅討論selfdestruct 的本身的機制可能帶來的攻擊面)。

下面我們來看攻擊合約:

攻擊合約

contract Attack { EtherGame etherGame; constructor(EtherGame _etherGame) { etherGame = EtherGame(_etherGame); } function attack() public payable { address payable addr = payable(address(etherGame)); selfdestruct(addr); }

這裡我們還是引用三個角色來講解攻擊合約的攻擊過程(以下過程純屬虛構,目的是為了幫助大家更好的理解攻擊過程,請勿較真!)

玩家一:Alice

玩家二:Bob

攻擊者:Eve

1. 開發者部署EtherGame 合約;

2. 玩家Alice 決定玩遊戲,她這輩子玩遊戲從來沒贏過,她覺得這個遊戲可以讓她體驗一次當winner 的快感,所以她決定連續調用EtherGame.deposit 存入7 個以太這樣她就一定是winner!正當她操作到第六次眼看還有一次今成功的時候,意外發生了(此時合約中已經有Alice 存入的6 個以太了);

3. 攻擊者Eve 部署Attack 合約並在構造函數中傳入EtherGame 合約的地址;

4. 攻擊者Eve 調用Attack.attack 並設置msg.value = 1 ,函數觸發selfdestruct 將這1 個以太強制打入EtherGame 合約中。此時EtherGame 合約中有7 個以太(分別為Alice 的六個以太和攻擊者剛剛打入的1 個以太);

5. 這時玩家Bob 也決定玩遊戲,存入1 個以太后合約中有7+1=8 個以太,無法通過require(balance <= targetAmount, 'Game is over') 的檢查並回滾。到這裡我們已經成功的使EtherGame 合約癱瘓了,這個遊戲將永遠不會產生winner,Alice 的winner 夢也就此破滅了,6 個以太被永遠的鎖在了EtherGame 合約中。哎,可憐的Alice 。

下面是攻擊流程圖:

修復建議

看到這裡我相信大家對自毀函數的功能及其危害都有一定的了解了。下面我們還是用開發者和審計者這兩個角色來分析如何發現和預防通過自毀函數的攻擊:

(1)作為開發者

這裡我們就拿上面的漏洞合約EtherGame 來說,這個合約可以被攻擊者攻擊是因為依賴了address(this).balance 來獲取合約中的餘額且這個值可以影響業務邏輯,所以我們這裡可以設置一個變量balance,只有玩家通過EtherGame.deposit 成功向合約打入以太后balance 才會增加。這樣只要不是通過正常途徑進來的以太都不會影響我們的balance 了,避免強制轉賬導致的記賬錯誤。下面是修復代碼:

// SPDX-License-Identifier: MITpragma solidity ^0.8.10;contract EtherGame { uint public targetAmount = 3 ether; uint public balance; address public winner; function deposit() public payable { require(msg.value == 1 ether, 'You can only send 1 Ether'); balance += msg.value; require(balance <= targetAmount, 'Game is over'); if (balance == targetAmount) { winner = msg.sender; } } function claimReward( ) public { require(msg.sender == winner, 'Not winner'); (bool sent, ) = msg.sender.call{value: balance}(''); require(sent, 'Failed to send Ether') ; }}

(2)作為審計者

作為審計者我們需要結合真實的業務邏輯來查看address(this).balance 的使用是否會影響合約的正常邏輯,如果會影響那我們就可以初步認為這個合約存在被攻擊者強制打入非預期的資金從而影響正常業務邏輯的可能(比如被selfdestruct 攻擊)。在審計過程中還需要結合實際的代碼邏輯來進行分析。