时间 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 挡不住:锁保护的是函数,攻击者攻击的是状态。

思考题

  1. 为什么说"在 EVM 里转账等于调用代码"?向外部账户(EOA)转账为什么没有这个问题?
  2. splitDAO 按 CEI 重写。写完后论证:即使攻击者重入,为什么第二次调用一定失败?
  3. 如果 The DAO 用了 nonReentrant 修饰符但没有调整语句顺序,攻击还能成功吗?请分情况讨论。
  4. 构造一个跨函数重入的最小例子:两个函数、一个共享状态、一个锁加在错误的位置。
  5. 为什么"用 .transfer() 防重入"这条建议现在是有害的?它会在什么场景下让合约无声地坏掉?
  6. 一个 NFT 市场在 safeTransferFrom 之后才把挂单标记为"已成交"。请写出攻击路径。
  7. 变体 ⑤ 的例子里,攻击者需要什么前提条件才能利用?如果 rewardToken 是一个写死的、可信的地址,这个洞还存在吗?
  8. The DAO 的 27 天锁定期是"运气"。请设计一个有意为之的机制,让大额异常提款天然带有延迟,并说明它自己的代价。
  9. 硬分叉追回了资金,但代价是链的分裂。请从第 35 讲“信任假设"的角度,说明这次分叉推翻了什么假设。
  10. 用本篇最后那三个问题,检查你自己写过的任意一个含外部调用的函数。把检查过程写下来。