| 时间 | 2016 年 6 月 17 日 |
| 涉案 | 约 360 万 ETH(当时约 $6000 万) |
| 结局 | 通过硬分叉追回,代价是 ETH / ETC 永久分裂 |
| 类别 | C1 控制流假设失效 |
一、背景:The DAO 在做什么
2016 年 4 月,The DAO 在以太坊上众筹了约 1150 万 ETH——当时约占 ETH 流通总量的 14%。它的定位是一只去中心化的风险投资基金:持币人投票决定投哪些项目,收益按份额分配。
关键在于它的退出机制。设计者考虑到"多数派通过了我不同意的提案"这种情况,给了少数派一条出路:
splitDAO —— 分裂
① 你发起一个 split 提案,指定一个新的 curator
② 等待期结束后,你可以带着【你那一份 ETH】
分裂出去,形成一个独立的 child DAO
③ child DAO 有 27 天锁定期才能动用资金
⭐ 注意第 ③ 步。 这个锁定期本来是为了防止 curator 卷款跑路——它后来成了社区能够组织硬分叉的唯一原因。
二、漏洞在哪
问题出在 splitDAO 的语句顺序上。简化后的逻辑是这样:
function splitDAO(uint _proposalID, address _newCurator) returns (bool) {
// ... 一堆提案有效性检查 ...
// ① 按份额算出该给这个人多少 ETH
uint fundsToBeMoved =
(balances[msg.sender] * p.splitData[0].splitBalance) /
p.splitData[0].totalSupply;
// ② ⚠️ 把 ETH 转到 child DAO —— 这一步会【调用外部合约】
if (p.splitData[0].newDAO.createTokenProxy.value(fundsToBeMoved)(msg.sender)
== false) throw;
// ③ 发放累计奖励 —— 内部使用 .call.value()()
withdrawRewardFor(msg.sender);
// ④ ⚠️ 直到这里才把余额清零
totalSupply -= balances[msg.sender];
paidOut[msg.sender] = 0;
balances[msg.sender] = 0; // ← 太晚了
return true;
}
第 ② 步和第 ④ 步之间,隔着一次向攻击者转账。
⚠️ 而在 EVM 里,“向一个地址转账"和"调用那个地址的代码"是同一件事:
向【外部账户】转账 ⟹ 只是余额变动,没有代码执行
向【合约地址】转账 ⟹ ⭐ 触发它的 fallback 函数
⟹ 控制权【完全交给了对方】
⟹ 对方想干什么就干什么,
包括【再调用你一次】
⭐ 这就是整个事故的全部内容:函数在自己的状态还没更新完的中途,把控制权交给了不受信任的一方。攻击者接过控制权时,看到的是一个半更新的状态——balances[msg.sender] 还是原值。
三、攻击复盘
攻击者部署了一个合约,它的 fallback 函数会回头再调用 splitDAO:
攻击者合约 A,在 The DAO 里有 100 个代币的份额
第 1 次调用 A.splitDAO()
│
├─ ① 读 balances[A] = 100,算出该转 100 份 ETH
├─ ② 转账给 child DAO ─────────┐
│ │ 控制权到了 A 的 fallback
│ ▼
│ A.fallback()
│ └─ 第 2 次调用 splitDAO()
│ ├─ ① 读 balances[A]
│ │ ⚠️ 【仍然是 100】——第一次还没清零
│ ├─ ② 再转 100 份 ⟶ 又触发 fallback
│ │ └─ 第 3 次…… 第 4 次……
│ └─ ④ balances[A] = 0
│ ┌────────────────────
├─ ③ withdrawRewardFor(A) ◀──┘ 递归收敛后回到这里
└─ ④ balances[A] = 0 (对一个【已经是 0】的值重复赋零)
递归深度由攻击者控制,唯一的限制是单笔交易的 Gas 上限。一份份额被兑现了几十次。
时间线:
2016-06-17 攻击开始,资金持续流入攻击者的 child DAO
⭐ 因为 child DAO 有 27 天锁定期,
钱在链上【看得见但取不走】
2016-06-18 社区确认攻击,白帽团队用【同样的漏洞】
把剩余资金抢救到另一个 child DAO
2016-07-20 区块 1,920,000 执行硬分叉,把资金退回
⟹ 不接受分叉的一批人继续原链 = Ethereum Classic
⭐ 那个 27 天锁定期是唯一的运气。 如果 The DAO 允许即时提款,社区连组织讨论的时间都没有——这不是安全设计的成功,是无关的一个参数恰好起了作用。
四、为什么没被发现
这一点最值得看,因为 The DAO 不是没审计。
① 代码公开、经过多方评审,社区里有大量眼睛在看
② ⚠️ 更讽刺的是:递归调用的风险在攻击【发生之前】
就已经被公开讨论过了。
有人写文章提醒过 "recursive call" 这类问题,
开发团队也在准备修复。
③ 但合约已经部署,钱已经众筹进来。
⭐ 以太坊合约默认不可变 ——
"我们知道了、正在修" 和 "已经修好了" 之间,
隔着一个攻击者。
更根本的原因是审计视角:
⚠️ 当时的评审在问 “这个函数会不会把钱算错?”
而应该问的是 “这个函数执行到一半时,如果被再次进入,会怎样?”
⭐ 前者是函数式的思维,后者是并发的思维。 重入不是并发问题(EVM 是单线程),但它需要同一套心智模型。
五、防御
① 检查 → 生效 → 交互(CEI)
function splitDAO(...) {
// Checks —— 所有校验
require(...);
// Effects —— ⭐ 先把自己的状态改完
uint amount = balances[msg.sender];
balances[msg.sender] = 0;
totalSupply -= amount;
// Interactions —— 最后才碰外部
(bool ok, ) = target.call{value: amount}("");
require(ok);
}
⭐ CEI 的本质是:在交出控制权之前,先把账本改成"这笔钱已经付过了"的样子。这样即使被重入,攻击者看到的也是已经归零的余额。
② 重入锁
uint256 private _status = 1;
modifier nonReentrant() {
require(_status == 1, "reentrant");
_status = 2;
_;
_status = 1;
}
⚠️ 重入锁是第二道防线,不是第一道。 只上锁不改顺序,会在跨函数重入面前失效(见下一节)。
③ 不要用 transfer / send 来"防重入”
早年的建议是用 .transfer(),因为它只转发 2300 Gas,不够对方做事。这条建议现在是错的:
⚠️ EIP-1884(Istanbul)上调了 SLOAD 等操作码的价格,EIP-2929(Berlin)又引入冷热访问差价。
固定 2300 的假设不再可靠:
· 接收方是多签钱包 ⟹ 2300 不够,转账直接失败
· 未来再改 Gas 定价 ⟹ 你的合约无声地坏掉
⭐ 现在的正确做法:用 call 转全部 Gas + CEI + 重入锁。
——用【逻辑】防重入,不要用【Gas 额度】防重入。
六、⭐ 举一反三
这一节是这篇文章存在的理由。 如果读完只记住"提款要先清零",那么下面六种情形你还是会中招。
变体 ①:不只是 ETH 转账
任何把控制权交给外部地址的操作都是重入点:
· ETH 转账(call / send / transfer)
· ⚠️ ERC-721 的 safeTransferFrom ⟹ 触发 onERC721Received
· ⚠️ ERC-1155 的 safeTransferFrom ⟹ 触发 onERC1155Received
· ⚠️ ERC-777 的 transfer ⟹ 触发 tokensReceived(见第 2 篇)
· 任何对【用户能指定地址】的合约的调用
· 任何 ERC-20 —— 如果代币本身是攻击者部署的
⭐ 判据只有一条:这一行之后,控制权会不会离开我? 会,它就是交互步骤,必须排在所有状态更新之后。
变体 ②:跨函数重入
withdraw() 有 nonReentrant 锁 ✅
transfer() 没有锁 ⚠️
两者共享 balances
攻击:在 withdraw 的转账回调里,不再调 withdraw(会被锁挡住),
改调 transfer,把【还没被清零的余额】转给同伙。
⟹ ⭐ 锁保护的是【函数】,攻击者攻击的是【状态】。
规则:锁必须覆盖所有读写同一份状态的入口,而不是只加在"看起来危险"的那个函数上。
变体 ③:跨合约重入
两个合约共享一份状态(比如都读同一个 Vault 的余额),各自加了锁,但锁是各自的。攻击者在 A 的回调里去调 B。
规则:锁要加在状态的所有者上,不是在调用方上。
变体 ④:只读重入
不写任何状态也能被打穿。 见 第 3 篇——这是本单元里最反直觉的一种。
变体 ⑤:先转账后记账的一切等价形式
重入的本质是**“状态与外部世界短暂地不一致”**,它可以长得完全不像转账:
// 表面上没有重入
function claim() external {
uint reward = computeReward(msg.sender);
rewardToken.transfer(msg.sender, reward); // 外部调用
lastClaimed[msg.sender] = block.timestamp; // ⚠️ 记账在后
}
⚠️ 如果 rewardToken 是可以被攻击者控制的(比如一个用户可以自己注册的奖励代币),同一个洞就在这里。
变体 ⑥:升级和初始化也是"交互"
⭐ delegatecall 到一个用户可以影响的地址,
等于把【自己的存储】交给了对方。
这比重入更严重 —— 见 [第 4 篇](https://blog.ifcalm.org/posts/security/blockchain/04-parity-multisig-1/)。
一条可以随身带的检查
写完任何一个函数,从上往下扫一遍,找出所有【外部调用】那几行。
对每一行问:
⭐ ① 这行之后,控制权会离开我吗?
② 如果对方在这里回头调我,我的状态是不是【自洽的】?
③ 如果对方在这里调的是【别的】函数呢?
三个问题都答得上来,才算写完。
七、本案小结
- 漏洞只是一个语句顺序:ETH 转账写在了
balances[msg.sender] = 0之前。 - ⭐⭐ 在 EVM 里,向合约地址转账等于调用它的代码。“转账"这个动作天然是一次控制权移交——这是重入的全部来源。
- 递归深度由攻击者控制,唯一限制是单笔交易的 Gas 上限。一份份额被兑现了几十次。
- 那个 27 天锁定期是唯一的运气,它给了社区组织硬分叉的时间。它不是安全设计的成功,是一个无关参数恰好起了作用。
- ⚠️ The DAO 是审计过的,而且风险在攻击前已被公开讨论。问题在于合约默认不可变——“知道了"和"修好了"之间隔着一个攻击者。
- 审计视角的教训:当时问的是"这个函数会不会算错钱”,该问的是”执行到一半被再次进入会怎样"。
- CEI 的本质:在交出控制权之前,先把账本改成"这笔钱已经付过了"的样子。
- ⚠️ 不要用
.transfer()的 2300 Gas 限制来防重入——Gas 定价改过,这个假设已经不可靠,而且会让多签钱包收不到钱。用逻辑防,不要用 Gas 额度防。 -⭐ 重入有六个变体,其中跨函数、跨合约、只读三种,光加nonReentrant挡不住:锁保护的是函数,攻击者攻击的是状态。
思考题
- 为什么说"在 EVM 里转账等于调用代码"?向外部账户(EOA)转账为什么没有这个问题?
- 把
splitDAO按 CEI 重写。写完后论证:即使攻击者重入,为什么第二次调用一定失败? - 如果 The DAO 用了
nonReentrant修饰符但没有调整语句顺序,攻击还能成功吗?请分情况讨论。 - 构造一个跨函数重入的最小例子:两个函数、一个共享状态、一个锁加在错误的位置。
- 为什么"用
.transfer()防重入"这条建议现在是有害的?它会在什么场景下让合约无声地坏掉? - 一个 NFT 市场在
safeTransferFrom之后才把挂单标记为"已成交"。请写出攻击路径。 - 变体 ⑤ 的例子里,攻击者需要什么前提条件才能利用?如果
rewardToken是一个写死的、可信的地址,这个洞还存在吗? - The DAO 的 27 天锁定期是"运气"。请设计一个有意为之的机制,让大额异常提款天然带有延迟,并说明它自己的代价。
- 硬分叉追回了资金,但代价是链的分裂。请从第 35 讲“信任假设"的角度,说明这次分叉推翻了什么假设。
- 用本篇最后那三个问题,检查你自己写过的任意一个含外部调用的函数。把检查过程写下来。