The DAO 事件里,ETH 是怎样被反复取走的

目录

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 时,接收方的 receivefallback 会在当前交易里立刻运行。它只要再调一次 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 而不是立刻自由提走,锁定窗口给了社区处置时间。后来的硬分叉也不只是一次代码修复,而是一次关于治理和资金救济的选择。

把状态更新放在前面

早期教程常建议用 transfersend,因为它们只给接收方 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");
    }
}

审计里看到 calldelegatecall 或外部接口时,要沿着资产状态看。外部调用前,余额、份额、债务或奖励是否已经更新。回调进入同一函数或另一个共享状态的入口后,不变量还能不能成立。失败时会不会完整回滚。用户可控地址、token 合约、预言机和 adapter 都不该被当成不会执行代码的普通地址。

参考资料

发布于 2023 年 3 月 28 日
评论