CTF Fallback

這道 Blockchain CTF 題目涉及一個以太坊智能合約的fallback漏洞利用,目標是通過特定的操作序列獲得這個合約的所有權並把合約的餘額歸零。

題目位置:https://ethernaut.openzeppelin.com/level/1

這類漏洞在早期的以太坊智能合約中較為常見,尤其是在回退函數(如 receivefallback)的設計上。這2函數在處理未指定函數的調用或純以太幣轉賬時非常敏感,應謹慎設計其邏輯。


題目說明

Look carefully at the contract’s code below. You will beat this level if

  1. you claim ownership of the contract
  2. you reduce its balance to 0
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Fallback {
    mapping(address => uint256) public contributions;
    address public owner;

    constructor() {
        owner = msg.sender;
        contributions[msg.sender] = 1000 * (1 ether);
    }

    modifier onlyOwner() {
        require(msg.sender == owner, "caller is not the owner");
        _;
    }

    function contribute() public payable {
        require(msg.value < 0.001 ether);
        contributions[msg.sender] += msg.value;
        if (contributions[msg.sender] > contributions[owner]) {
            owner = msg.sender;
        }
    }

    function getContribution() public view returns (uint256) {
        return contributions[msg.sender];
    }

    function withdraw() public onlyOwner {
        payable(owner).transfer(address(this).balance);
    }

    receive() external payable {
        require(msg.value > 0 && contributions[msg.sender] > 0);
        owner = msg.sender;
    }
}

合約分析

這是一個名為 Fallback 的 Solidity 合約,包含以下關鍵功能:

  • 狀態變量
    • mapping(address => uint256) public contributions:記錄每個地址的貢獻量(以 wei 為單位)。
    • address public owner:記錄合約的當前擁有者。
contract Fallback {
    mapping(address => uint256) public contributions;
    address public owner;
  • 構造函數 (constructor)
    • 在部署合約時,將部署者的地址設置為 owner
    • 為部署者初始化 contributions[msg.sender] = 1000 ether
    constructor() {
        owner = msg.sender;
        contributions[msg.sender] = 1000 * (1 ether);
    }
  • contribute 函數
    • 允許用戶通過發送以太幣(ETH)進行貢獻,但要求 msg.value < 0.001 ether(即小於 0.001 ETH)。
    • 將發送的 msg.value 累加到 contributions[msg.sender]
    • 如果某地址的貢獻量超過當前 owner 的貢獻量(contributions[owner]),則該地址成為新的 owner
    function contribute() public payable {
        require(msg.value < 0.001 ether);
        contributions[msg.sender] += msg.value;
        if (contributions[msg.sender] > contributions[owner]) {
            owner = msg.sender;
        }
    }
  • getContribution 函數
    • 返回調用者的貢獻量 contributions[msg.sender]
    function getContribution() public view returns (uint256) {
        return contributions[msg.sender];
    }
  • withdraw 函數
    • 僅限 owner 調用(通過 onlyOwner 修飾符檢查)。
    • 將合約的全部餘額轉移給 owner
    function withdraw() public onlyOwner {
        payable(owner).transfer(address(this).balance);
    }
  • receive 函數
    • 這是一個回退函數,當合約收到以太幣但未指定具體函數時觸發。
    • 要求 msg.value > 0(發送的以太幣大於 0)且 contributions[msg.sender] > 0(調用者必須有過貢獻)。
    • owner 設置為 msg.sender
    receive() external payable {
        require(msg.value > 0 && contributions[msg.sender] > 0);
        owner = msg.sender;
    }

漏洞分析

有2種方式可以成為owner

方法優點缺點
利用 contribute() (合法路徑)符合合約的設計邏輯,不需要利用 receive 函數。成本非常高昂。為了超過初始的 1000 ether,攻擊者需要投入大量的資金,手續費成本也相當高。
利用 receive() (漏洞路徑)成本極低。只需要先花一點點錢成為 contributor (例如 0.0001 ether),再發送一筆純 ETH 交易就可以直接奪權。利用了合約的漏洞,攻擊行為不符合設計者的預期。

具體來說,receive 函數允許任何滿足以下條件的地址直接成為 owner

  • 發送的以太幣大於 0(msg.value > 0)。
  • 該地址的貢獻量大於 0(contributions[msg.sender] > 0)。

這意味著,只要一個地址先通過 contribute 函數貢獻了一定量的以太幣(使得 contributions[msg.sender] > 0),然後直接向合約發送任意金額的以太幣(例如 1 wei),就能觸發 receive 函數並成為 owner。這是一個顯著的設計缺陷,因為 receive 函數的檢查條件過於寬鬆,沒有考慮貢獻量的多少或與當前 owner 的比較。

相比之下,contribute 函數要求貢獻量超過 owner 的貢獻量(contributions[owner],初始為 1000 ETH)才能改變所有權,這對於攻擊者來說難度較高(需要貢獻超過 1000 ETH)。而 receive 函數繞過了這個限制,只需滿足兩個簡單條件即可奪取所有權。


解答步驟詳解

以下逐行分析這些操作如何利用漏洞並達成目標:

1. 調用 contribute 函數,向合約發送 1 wei(以太坊的最小單位)

  • 操作await contract.contribute({value: 1})
  • 效果
    • contribute 函數檢查 msg.value < 0.001 ether(即 10^15 wei)。由於 1 wei 遠小於 0.001 ETH,條件滿足。
    • 合約將 msg.value(1 wei)累加到 contributions[msg.sender],因此 contributions[msg.sender] = 1
    • 檢查 contributions[msg.sender] > contributions[owner]。初始 owner 的貢獻量為 1000 ether(10^21 wei),而 contributions[msg.sender] = 1 wei,因此不滿足條件,owner 不變。
  • 目的:這一步的目的是滿足 receive 函數的條件之一:contributions[msg.sender] > 0。通過貢獻 1 wei,攻擊者的地址在 contributions 映射中有了非零記錄。

可以用以下指令發現,在智能合約的地址上,金額己大於0

contract.getContribution() 

2. 直接向合約地址發送 1 wei 的以太幣,觸發合約的 receive 函數

  • 操作await contract.sendTransaction({value: 1})
  • 效果
    • receive 函數檢查兩個條件:
      • msg.value > 0:發送了 1 wei,滿足條件。
      • contributions[msg.sender] > 0:由於上一步的 contribute 操作,contributions[msg.sender] = 1,滿足條件。
    • 條件滿足後,receive 函數執行 owner = msg.sender,將攻擊者的地址設置為新的 owner
  • 目的:這一步利用了 receive 函數的漏洞,直接將攻擊者設為 owner,無需滿足 contribute 函數中苛刻的貢獻量比較條件。

執行完 await contract.sendTransaction({value: 1})後,可以執行以下指令查看owner狀態

await contract.owner()

可以發現owner變成你的eth address


使用sendTransaction的原因

在智能合約的程式碼中,確實沒有定義一個名為 sendTransaction 的函數。這是因為 sendTransaction 不是合約內的函數,而是以太坊客戶端(如 Web3.js 或 Ethers.js)提供的一個方法,用來向智能合約發送一筆交易。具體來說,sendTransaction 用於直接向合約地址發送以太幣(ETH),而不調用合約中的任何特定函數。這種行為會觸發合約的回退函數(在這道題中是 receive 函數)。

回退函數

    在 Solidity 中,回退函數可以通過兩種方式定義:

    • fallback 函數:顯式定義為 fallback() external [payable],用於處理無效函數調用或純以太幣轉帳(如果有 payable 修飾符)。
    • receive 函數:顯式定義為 receive() external payable,專門用於處理純以太幣轉帳(data 字段為空)。

    這兩個函數統稱為「回退函數」,因為它們是當交易無法匹配合約中的其他函數時的「後備」處理機制。區別在於:

    • receive 專用於純以太幣轉帳(data 為空且 msg.value > 0)。
    • fallback 更通用,可以處理無效函數調用或純以太幣轉帳(如果標記為 payable)。

    在你的合約程式碼中,只有 receive 函數:

    receive() external payable {
        require(msg.value > 0 && contributions[msg.sender] > 0);
        owner = msg.sender;
    }

    這意味著,當合約收到一筆純以太幣轉帳(data 為空,msg.value > 0)時,receive 函數會被觸發。雖然程式碼中沒有 fallback 這個詞,但 receive 函數本身就是一種特殊的回退函數,專門處理純以太幣轉帳的情況。

    sendTransaction 觸發 receive 函數過程

      當你使用 await contract.sendTransaction({value: 1}) 時,實際上是在通過客戶端庫(如 Ethers.js)向合約地址發送一筆交易。這筆交易的特點是:

      • 目標地址:合約地址。
      • 金額:1 wei(由 value: 1 指定)。
      • 數據字段 (data):為空(因為 sendTransaction 沒有指定調用任何具體函數)。

      在以太坊虛擬機(EVM)的執行邏輯中,當一筆交易的 data 字段為空(即不調用任何特定函數)且 msg.value > 0(發送了以太幣)時,EVM 會檢查合約是否定義了 receive 函數:

      • 如果合約有 receive() external payable 函數,則執行該函數。
      • 如果沒有 receive 函數,但有 fallback() external payable 函數,則執行 fallback 函數。
      • 如果兩者都沒有,且交易發送了以太幣,交易會失敗(回拋)。

      在這道題的合約中,因為定義了 receive 函數:

      當你執行 await contract.sendTransaction({value: 1}) 時:

      1. 客戶端庫發送一筆交易到合約地址,附帶 1 wei,data 字段為空。
      2. EVM 檢測到這是一筆純以太幣轉帳(msg.value > 0,data 為空)。
      3. EVM 找到合約中的 receive 函數並執行它。
      4. receive 函數檢查:
        • msg.value > 0:1 wei 滿足條件。
        • contributions[msg.sender] > 0:由於第一步 contribute({value: 1}) 已使 contributions[msg.sender] = 1,條件滿足。
      5. 執行 owner = msg.sender,將攻擊者的地址設為 owner。

      因此,sendTransaction 觸發了 receive 函數,因為它發送了一筆純以太幣轉帳,而 receive 函數正是為這種情況設計的。


      3. 調用 withdraw 函數

      • 操作await contract.withdraw()
      • 效果
        • withdraw 函數有 onlyOwner 修飾符,檢查 msg.sender == owner。由於上一步已將 owner 設為攻擊者的地址,條件滿足。
        • 合約執行 payable(owner).transfer(address(this).balance),將合約的全部餘額轉移給攻擊者。
      • 目的:這一步提取合約中的所有以太幣,完成攻擊目標。


      解答步驟整理如下

      await contract.contribute({value: 1})
      await contract.sendTransaction({value: 1})
      await contract.withdraw()

      通過以下步驟,攻擊者利用了 receive 函數的漏洞:

      1. 調用 contribute 發送 1 wei,使得 contributions[msg.sender] > 0
      2. 通過 sendTransaction 發送 1 wei 觸發 receive 函數,成為 owner
      3. 調用 withdraw 提取合約餘額。