2016 年 6 月,一个名为 The DAO 的去中心化投资组织被攻击者利用重入漏洞盗走了约 360 万 ETH。当时价值约 6000 万美元。这件事直接导致了以太坊硬分叉,分裂成了今天的以太坊(ETH)和以太坊经典(ETC)。

重入攻击至今仍然是智能合约最常见的漏洞之一。据 SlowMist 等安全公司的统计,每年因重入攻击造成的损失都在数百万到数千万美元不等。

核心要点: 重入攻击利用合约在转账过程中状态未更新的窗口期。攻击者通过 fallback 函数递归调用提款函数,反复取款直到合约余额清零。防范方法是”检查-更新-交互”(Checks-Effects-Interactions)模式和使用防重入锁。

一个生活类比

想象一个 ATM 机。正常的取款流程是:你输入取款金额,ATM 先检查你的余额是否足够,然后从你的账户余额里扣除对应金额,最后吐出现金。

但假设这个 ATM 有一个 bug:它先吐现金,然后才扣余额。你取了 100 元,ATM 吐了钱但还没来得及扣余额。在吐钱和扣余额之间的那一瞬间,你立刻又发起一次取款请求。ATM 检查余额,发现还是原来的金额(因为还没扣),又吐了 100 元。然后又发起请求,又吐了 100 元…

这就是重入攻击的基本逻辑。利用”扣款发生在给钱之后”这个时间差,在扣款完成之前反复取钱。

技术原理

Solidity 智能合约中,当你向一个合约地址发送 ETH 时(使用 .transfer().send().call()),接收方的合约会被触发 fallback()receive() 函数。攻击者的恶意合约在 fallback 函数里递归调回提款函数。

一个有漏洞的合约代码大致长这样:

function withdraw() public {
    uint balance = balances[msg.sender];
    (bool success,) = msg.sender.call{value: balance}("");
    require(success);
    balances[msg.sender] = 0;  // 余额清零发生在转账之后
}

注意第三行和第五行的顺序:合约先把钱发给用户,然后才把用户余额更新为 0。攻击者的恶意合约收到 ETH 后,fallback 函数被触发:

receive() external payable {
    if (address(vulnerableContract).balance > 0) {
        vulnerableContract.withdraw();  // 递归调用!
    }
}

这个递归调用会不断重复:提款 → 触发 fallback → 再提款 → 再触发 fallback → …直到合约余额耗尽。

每次递归调用都发生在余额清零之前,所以合约每次检查 balances[msg.sender] 时都会看到原始的余额值。

为什么 .call() 比其他转账方式危险

Solidity 提供三种向地址发送 ETH 的方式:

.transfer() 向地址发送 ETH,如果接收方的 fallback 函数消耗超过 2300 Gas,交易会回滚。这个 Gas 限制使得递归调用几乎不可能,因为复杂操作会超限。

.send().transfer() 类似,返回 bool 而非回滚。

.call() 是最灵活的方式,它不限制接收方 fallback 函数的 Gas 消耗。这意味着接收方可以在 fallback 里执行任意复杂的逻辑,包括递归调用。

.call() 引入的 Gas 灵活性既是优势也是风险。它让合约可以与更复杂的接收方交互,但也打开了重入攻击的大门。自从 Solidity 0.8 开始,.call() 成了推荐用法(因为 .transfer() 的 2300 Gas 限制在 EIP-1884 之后可能导致正常操作也失败),但使用 .call() 时必须搭配防重入保护。

防范方法

检查-更新-交互模式(Checks-Effects-Interactions)。 这是最根本的防御。在任何外部调用之前,先完成所有状态更新:

function withdraw() public {
    uint balance = balances[msg.sender];
    balances[msg.sender] = 0;  // 先更新状态(Effects)
    (bool success,) = msg.sender.call{value: balance}("");  // 然后交互(Interactions)
    require(success);
}

即使攻击者的 fallback 函数递归调回 withdraw(),此时 balances[msg.sender] 已经是 0 了,不会再给钱。

重入锁。 使用一个修饰器(modifier)来防止函数被递归调用:

bool private locked;
modifier noReentrancy() {
    require(!locked, "Reentrant call");
    locked = true;
    _;
    locked = false;
}

当函数第一次被调用时,locked 设为 true。如果攻击者试图递归调用,require(!locked) 会失败。

使用 OpenZeppelin 的 ReentrancyGuard。 不要自己写重入锁,使用经过审计的标准库。OpenZeppelin 的 ReentrancyGuard 提供了 nonReentrant 修饰器,是行业标配。

跨合约重入

除了传统的单合约重入,还有一种更隐蔽的变体:跨合约重入。攻击者不是在一个合约的 fallback 里递归调用,而是在另一个关联合约里利用状态未更新的窗口。

这种变体更难检测,因为重入不是发生在同一个合约内部,而是跨越多个合约。防御方法不变:遵循 Checks-Effects-Interactions 模式,在所有外部调用前完成状态更新。

重入攻击之所以至今仍然危险,是因为它太基础了。每个智能合约开发者都知道它的存在,但在复杂的业务逻辑中,外部调用和状态更新的顺序很容易被搞错。一个看似无关的代码修改可能引入重入漏洞。这就是为什么安全审计和重入检测工具(如 Slither)必不可少。