一句话版本
智能合约给别人转 ETH 时,对方如果也是一个合约,就会自动执行一段对方的代码。如果你的合约是"先转钱、后记账",对方就可以在收到钱的那一刻再调用一次取款,而此时你的账本还没扣,于是可以反复取钱。 2016 年的 The DAO 被这样掏走约 360 万 ETH,以太坊社区为此硬分叉回滚,留下了今天的以太坊(ETH)和以太坊经典(ETC)。防御口诀只有一句:先检查,再改账,最后才和外部交互。
全文分三层:
- 通俗理解:取款机先吐钱、后扣余额
- 实际用处:The DAO、各种变体、2023 年的 Curve 编译器事件
- 原理与工程要点:漏洞代码、修复模式、重入锁、只读重入与跨合约重入
第一层:通俗理解
1.1 先吐钱、后扣余额的取款机
一台有 bug 的取款机,流程是:
- 检查余额够不够;
- 吐出现金;
- 从余额里扣掉。
一个手快的人在第 2 步现金吐出的瞬间,又按了一次"取款"。机器回到第 1 步检查余额:还没扣,够!于是再吐一次……只要手够快,可以一直取到机器空了为止。
智能合约里,"手快"不需要人去按:对方合约收到 ETH 时会自动运行一个函数(receive / fallback),在那里面再调用一次取款就行了。
1.2 为什么会这样写
人类写代码的直觉是"先把钱给出去,成功了再记账",这在普通程序里没问题。但在智能合约里,"给出去"这个动作会把控制权交给对方,而对方是不可信的。
第二层:实际用处
- The DAO(2016):一个去中心化投资基金,募集了当时约 14% 的流通 ETH。攻击者利用拆分(split)功能的重入漏洞反复取款。以太坊社区经过激烈争论后选择硬分叉,把被盗资金退回;反对者继续维护原链,即以太坊经典。这次事件也引发了"代码即法律"的长久争论。
- Curve / Vyper(2023):这次合约代码本身写了重入锁,但编译器的某几个版本在实现重入锁时有 bug,锁实际上没生效。这说明安全要一路检查到工具链。
第三层:原理与工程要点
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 测试方法
- 单元测试里专门写一个"恶意接收方"合约,在回调中尝试重入每一个外部函数;
- 静态分析工具(如 Slither)能自动标出"外部调用之后还修改状态"的代码;
- 不变量测试:"合约的 ETH 余额 ≥ 所有用户余额之和"在任何调用序列下都成立(见《合约审计与形式化验证》)。
总结
| 层次 | 要点 |
|---|---|
| 直觉 | 先吐钱后扣账,被人在中间反复取款 |
| 历史 | The DAO 导致以太坊分叉;Curve 事件说明编译器也会出错 |
| 修复 | 检查 → 改账 → 外部调用;再加重入锁 |
| 变体 | 跨函数、跨合约、只读重入、代币回调 |