Race-to-Empty 说的是合约准备把一笔余额取光,却在余额归零前先把 ETH 或控制权交给了外部地址。接收方如果是合约,就能立刻回调提款函数。原合约再次读取余额时,读到的仍是旧值,于是同一份余额被支付多次。
它不是 mempool 里的抢跑,也不是谁调用得更快。这是一种重入攻击,发生在一笔交易和同一个调用栈里。The DAO 在 2016 年暴露的就是这类顺序问题。攻击者把 ETH 转入 child DAO,问题出在应用合约,并非以太坊协议本身。之后围绕资金恢复的争议,也成为 ETH 与 ETC 分叉历史的一部分。
控制权交出去得太早
下面的合约刻意写得很短,只保留漏洞所在的顺序。
// 反例:不要在生产环境使用
contract VulnerableVault {
mapping(address => uint256) public balances;
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "nothing to withdraw");
// 外部地址此时可以执行自己的代码
(bool ok,) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
// 已经太晚
balances[msg.sender] = 0;
}
}
EVM 调用是同步的。执行到 call 时,接收方的 receive 或 fallback 会在当前交易里立刻运行。它只要再调一次 withdraw(),第一次调用还没走到余额归零,第二次就会拿到原来的 amount。
Vault.withdraw()
└─ 向攻击合约 call{value: amount}("")
└─ 攻击合约 receive 或 fallback
└─ 再次调用 Vault.withdraw()
└─ 再次读到 balances[attacker]
递归不会真的没有尽头。合约余额、可用 gas、调用深度和攻击合约的限制都会让它停下。问题在于停下之前,一笔本来只能支付一次的余额已经可能被支付了很多次。
receive() external payable 处理空 calldata 的 ETH 转账。fallback() external payable 在没有匹配函数选择器时执行,没有定义 receive 时也可能接到空 calldata 的转账。审计时要看实际合约定义,不能把收到 ETH 简化成一定触发 fallback。
攻击合约不需要猜 Vault 的内部状态,收到 ETH 后回调即可。
interface IVault {
function withdraw() external;
}
contract ReenteringReceiver {
IVault immutable vault;
uint256 private rounds;
uint256 private constant MAX_ROUNDS = 3;
constructor(address vault_) {
vault = IVault(vault_);
}
receive() external payable {
if (rounds < MAX_ROUNDS) {
rounds += 1;
vault.withdraw();
}
}
}
call{value: amount}("") 本身不是漏洞。它的问题是一次外部交互,接收方可以执行代码。默认情况下它给出的 gas 足以运行复杂逻辑,因此每一次外部调用都该按控制权可能马上回来的情况设计。
The DAO 的攻击路径
把 The DAO 简化为一个余额提款合约,能说明重入的骨架,但细节并不完全相同。攻击路径与 splitDAO 有关。持有者退出父 DAO 并进入 child DAO 时,合约按 token 份额计算应转移的 ETH。外部支付发生在内部记账完成前,攻击者就能利用这个窗口继续进入路径。
合约不会因为一笔付款看起来像重复付款而停下来。状态更新被写在外部调用之后,链就会照这个顺序执行。攻击资金当时进入 child DAO 而不是立刻自由提走,锁定窗口给了社区处置时间。后来的硬分叉也不只是一次代码修复,而是一次关于治理和资金救济的选择。
把状态更新放在前面
早期教程常建议用 transfer 或 send,因为它们只给接收方 2300 gas。把这个额度当成安全边界已经不合适。gas 成本会变,正常接收逻辑也可能因此失败。更何况重入不只发生在 ETH 转账,token hook、回调接口和跨合约调用都会交出控制权。
常用的处理方式是 Checks-Effects-Interactions。先检查条件,再改内部状态,最后做外部调用。
contract SaferVault {
mapping(address => uint256) public balances;
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "nothing to withdraw");
balances[msg.sender] = 0;
(bool ok,) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
}
}
图里用的是旧版 Solidity 语法,表达的顺序没有变化。上面的代码采用现在常见的 call{value: amount}("") 写法。若调用失败并触发 require,整笔交易会 revert,前面的余额更新也会回滚,不会留下余额清掉但付款没成功的状态。
复杂合约还应加重入锁。它不能替代前面的状态更新,但能防止另一个入口或更深的内部调用重新进入同一份会计状态。
contract GuardedVault {
mapping(address => uint256) public balances;
bool private entered;
modifier nonReentrant() {
require(!entered, "reentrant call");
entered = true;
_;
entered = false;
}
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
require(amount > 0, "nothing to withdraw");
balances[msg.sender] = 0;
(bool ok,) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
}
}
审计里看到 call、delegatecall 或外部接口时,要沿着资产状态看。外部调用前,余额、份额、债务或奖励是否已经更新。回调进入同一函数或另一个共享状态的入口后,不变量还能不能成立。失败时会不会完整回滚。用户可控地址、token 合约、预言机和 adapter 都不该被当成不会执行代码的普通地址。