這道 Blockchain CTF 題目涉及一個以太坊智能合約的fallback漏洞利用,目標是通過特定的操作序列獲得這個合約的所有權並把合約的餘額歸零。
題目位置:https://ethernaut.openzeppelin.com/level/1
這類漏洞在早期的以太坊智能合約中較為常見,尤其是在回退函數(如 receive
或 fallback
)的設計上。這2函數在處理未指定函數的調用或純以太幣轉賬時非常敏感,應謹慎設計其邏輯。
題目說明
Look carefully at the contract’s code below. You will beat this level if
- you claim ownership of the contract
- 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
。
- 允許用戶通過發送以太幣(ETH)進行貢獻,但要求
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 wei,data 字段為空。
- EVM 檢測到這是一筆純以太幣轉帳(msg.value > 0,data 為空)。
- EVM 找到合約中的 receive 函數並執行它。
- receive 函數檢查:
- msg.value > 0:1 wei 滿足條件。
- contributions[msg.sender] > 0:由於第一步 contribute({value: 1}) 已使 contributions[msg.sender] = 1,條件滿足。
- 執行 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
函數的漏洞:
- 調用
contribute
發送 1 wei,使得contributions[msg.sender] > 0
。 - 通過
sendTransaction
發送 1 wei 觸發receive
函數,成為owner
。 - 調用
withdraw
提取合約餘額。