Caloogy 文章
English

重入攻击:The DAO 为什么让以太坊分叉

日期
2025 年 3 月 6 日
系列
链上
阅读
3 分钟
主题
OnchainSmart contractReentrancyThe dao

一句话版本

智能合约给别人转 ETH 时,对方如果也是一个合约,就会自动执行一段对方的代码。如果你的合约是"先转钱、后记账",对方就可以在收到钱的那一刻再调用一次取款,而此时你的账本还没扣,于是可以反复取钱。 2016 年的 The DAO 被这样掏走约 360 万 ETH,以太坊社区为此硬分叉回滚,留下了今天的以太坊(ETH)和以太坊经典(ETC)。防御口诀只有一句:先检查,再改账,最后才和外部交互。

全文分三层:

  1. 通俗理解:取款机先吐钱、后扣余额
  2. 实际用处:The DAO、各种变体、2023 年的 Curve 编译器事件
  3. 原理与工程要点:漏洞代码、修复模式、重入锁、只读重入与跨合约重入

第一层:通俗理解

1.1 先吐钱、后扣余额的取款机

一台有 bug 的取款机,流程是:

  1. 检查余额够不够;
  2. 吐出现金;
  3. 从余额里扣掉。

一个手快的人在第 2 步现金吐出的瞬间,又按了一次"取款"。机器回到第 1 步检查余额:还没扣,够!于是再吐一次……只要手够快,可以一直取到机器空了为止。

智能合约里,"手快"不需要人去按:对方合约收到 ETH 时会自动运行一个函数(receive / fallback),在那里面再调用一次取款就行了。

1.2 为什么会这样写

人类写代码的直觉是"先把钱给出去,成功了再记账",这在普通程序里没问题。但在智能合约里,"给出去"这个动作会把控制权交给对方,而对方是不可信的。


第二层:实际用处

2016The DAO 被盗约 360万 ETH以太坊硬分叉,ETH 与ETC 分家2020Lendf.Me 等借贷协议因ERC-777代币回调被重入2021-2022多个协议因跨合约、跨函数重入被盗2023Curve 多个池子因Vyper 编译器的重入锁bug 损失约 7000万美元重入攻击大事记

第三层:原理与工程要点

3.1 漏洞代码

// 有漏洞
function withdraw() external {
    uint256 amount = balances[msg.sender];
    require(amount > 0);
    (bool ok, ) = msg.sender.call{value: amount}("");   // ① 先转钱:控制权交给对方
    require(ok);
    balances[msg.sender] = 0;                            // ② 后改账:太晚了
}

攻击合约:

receive() external payable {
    if (address(victim).balance >= 1 ether) {
        victim.withdraw();    // 在 ① 和 ② 之间再取一次
    }
}

一次攻击的账

攻击者存入 1 ETH,合约里共有 10 ETH。攻击者调用 withdraw:

  • 第 1 层:余额记录 1,转出 1 ETH → 触发 receive → 再调 withdraw
  • 第 2 层:余额记录仍是 1(还没清零),再转出 1 ETH → 再触发……
  • ……共 10 层,合约被掏空,最后各层依次执行"余额清零"。

攻击者存 1 取 10。

3.2 修复:检查—生效—交互(Checks-Effects-Interactions)

function withdraw() external {
    uint256 amount = balances[msg.sender];   // 检查
    require(amount > 0);
    balances[msg.sender] = 0;                // 生效:先改账
    (bool ok, ) = msg.sender.call{value: amount}("");   // 交互:最后才和外部打交道
    require(ok);
}

对方再重入时,余额已经是 0,require 直接失败。

3.3 重入锁

modifier nonReentrant() {
    require(!locked);
    locked = true;
    _;
    locked = false;
}

OpenZeppelin 的 ReentrancyGuard 就是这个思路。它是第二道保险,不能代替正确的顺序。

3.4 更隐蔽的变体

变体 说明
跨函数重入 在 withdraw 的回调里调用同一合约的 transfer,而两个函数共享未更新的状态
跨合约重入 多个合约共享状态,A 合约在回调中调用 B 合约,B 读到 A 尚未更新的数据
只读重入 回调中调用的是一个 view 函数(比如"当前池子价格"),它没有锁,但返回了中间状态的错误值;其他协议据此定价就会被骗
代币回调 ERC-777、ERC-721 / 1155 的 onReceived 回调,让"转代币"也能触发对方代码

3.5 测试方法


总结

层次 要点
直觉 先吐钱后扣账,被人在中间反复取款
历史 The DAO 导致以太坊分叉;Curve 事件说明编译器也会出错
修复 检查 → 改账 → 外部调用;再加重入锁
变体 跨函数、跨合约、只读重入、代币回调