时间 2022 年 4 月 17 日
协议损失 约 $1.82 亿 TVL
攻击者净得 约 $7600 万(其余是被摧毁的价值)
类别 C5 经济与博弈假设失效

一、背景:一个"完全去中心化"的治理系统

Beanstalk 是一个算法稳定币协议。它的治理设计在当时被认为很先进:

① 任何人都可以提交提案(BIP)
② 投票权 = 你在协议里质押的份额(Stalk)
③ 提案需要经过一段等待期
④ ⭐ 如果一个提案获得【超过 2/3】的绝对多数,
      可以通过 emergencyCommit 立即执行

第 ④ 条是为"紧急情况"设计的——比如发现漏洞需要快速修复。它的存在完全合理,而它就是这次事故的入口。

二、漏洞在哪:等待期加在了错误的地方

⚠️ 24 小时的等待期,加在【提案被创建】之后。

⭐ 它没有加在【投票权被获得】之后,
   也没有加在【提案通过】和【提案执行】之间。

于是攻击的时间线可以被拆成两段:

第 1 天:提交提案 —— ⭐ 这一步不需要任何投票权,
                     任何人都能做,成本只有 Gas。

(等 24 小时,等待期悄悄走完)

第 2 天:⭐ 在【一笔交易】内完成
         获得投票权 ⟹ 投票 ⟹ 通过 ⟹ 执行 ⟹ 归还投票权

⭐⭐ 等待期防的是"提案内容没人看见就通过", 它完全没有防"投票权在投票的那一瞬间才被借来"。

这是两个不同的威胁,而只有前一个被防住了。

三、攻击复盘

第一天:埋好提案

攻击者提交两个提案:
   · BIP-18:⚠️ 恶意提案 —— 把协议的全部资产转给某个地址
   · BIP-19:一个看起来正当的捐赠提案(用来分散注意力)

⭐ 提交提案不需要任何投票权。
   两个提案静静地躺了 24 小时,等待期走完。

第二天:一笔交易,六个步骤

一笔原子交易:

① ⭐ 从 Aave 闪电贷借入约 10 亿美元等值的稳定币

② 把这些钱换成 Beanstalk 的 LP 代币

③ 把 LP 代币存进协议的 Silo
     ⟹ ⭐ 立刻获得对应的 Stalk(投票权)
     ⟹ 攻击者此刻持有【远超 2/3】的投票权

④ 用这些投票权为 BIP-18 投赞成票
     ⟹ 达到绝对多数

⑤ 调用 emergencyCommit(BIP-18)
     ⟹ ⭐ 提案立即执行
     ⟹ 协议的全部资产被转走

⑥ 用转走的资产还掉闪电贷
     ⟹ 剩下约 7600 万美元,走人

整个第二天的过程发生在一个区块内。 没有任何人有机会看到、反应、或阻止。

⚠️ 注意攻击者的实际资金投入是零——闪电贷的本金在同一笔交易内就还掉了。他真正花的只有闪电贷手续费和 Gas。

四、为什么没被发现

① “去中心化治理"被当成了安全属性

⭐ 心智模型:
   "投票权分散在很多持有人手里,
    所以没有人能单方面通过提案。"

⚠️ 这个推理默认了:
   【获得大量投票权需要时间和成本】。

⟹ 而 Beanstalk 的投票权可以用现金【即时购买】,
   闪电贷又让现金变得无限且免费。
   ⭐ 那条默认前提在 2020 年就已经失效了(见[第 12 篇](https://blog.ifcalm.org/posts/security/blockchain/12-bzx/))。

② 应急通道被单独设计,但没被单独审视

emergencyCommit 的设计初衷完全正当:
   "发现严重漏洞时,我们需要能快速修复。"

⚠️ 但它绕过的恰恰是【所有为慢速通道设计的保护】。

⭐ 一般规律:
   任何"应急通道"都是一条绕过防线的合法路径。
   它必须被【比主通道更严格】地审查,
   而实践中它往往被【更宽松】地对待 ——
   因为"它只在紧急情况下用"。

③ 提案的内容与提案的执行被分开看

审计会看:
   □ 投票计票逻辑对不对?      ✅ 对
   □ 门槛判断对不对?          ✅ 对
   □ 提案能不能被重复执行?    ✅ 不能

⚠️ 没有人问:
   ⭐【一个提案可以执行【任意】代码。
      那么"谁能通过一个提案"这个问题,
      就等价于"谁能控制整个协议"。】

五、防御

① 投票权快照,且快照时点在提案之前

// ⭐ 用提案创建【之前】某个区块的余额作为投票权
uint256 votes = token.getPastVotes(voter, proposal.snapshotBlock);

关键在于 snapshotBlock 必须早于提案的创建(OpenZeppelin Governor 的默认设计)。这样:

⟹ 攻击者必须在【提交提案之前】就持有代币
⟹ 而他不知道自己以后想提什么提案
⟹ ⭐ 闪电贷完全失效 —— 借来的钱在快照那一刻还不在他手上

② 执行时间锁:通过 ≠ 执行

⭐⭐ 这是最重要的一条:提案通过之后,必须再等一段时间才能执行。

   提案创建 ──24h──▶ 投票期 ──▶ 通过 ──⭐ 时间锁 ──▶ 执行
                                        (48h / 7 天)

⟹ 在时间锁期间:
   ✅ 所有人都能看到"一个提案通过了,它会做这些事"
   ✅ 用户可以退出
   ✅ 守护者可以否决

⭐ 时间锁的价值不在于"阻止",在于【给人反应的时间】。

⚠️ Beanstalk 的 emergencyCommit 恰恰跳过了这一段。

③ 应急通道要用不同的授权模型

⭐ 不要让"应急"仅仅意味着"更快",
   要让它意味着"不同的授权方式":

   常规提案:代币投票 + 时间锁
   ⭐ 应急提案:由一个【预先指定的、独立的】守护者委员会执行,
              并且【只能做减法】——
              · 可以暂停
              · 可以撤销权限
              · ⚠️ 【不能转移资产、不能升级实现】

⟹ 这样即使守护者被攻破,最坏结果是 DoS,而不是资金损失。

④ 治理提案的能力边界

⚠️ "提案可以执行任意代码"是绝大多数治理系统的默认设计,
   而它意味着治理攻击 = 协议归零。

⭐ 更安全的做法是给治理【划定能力范围】:

   ✅ 可以调整参数(在硬编码的上下界内)
   ✅ 可以增删抵押品(走额外的时间锁)
   ⚠️ 不能直接转移金库资产
   ⚠️ 不能任意 delegatecall

⟹ 代价是灵活性下降 —— 这是一个应该被显式讨论的取舍。

⑤ 参与门槛与法定人数

□ 最低质押时长才能投票(防止即时买入)
□ ⭐ 投票权随质押时间【线性增长】(老用户权重更高)
□ 法定人数按【总供应量】计,而不是按参与量
   ⚠️ 否则低参与率时,一小撮人就是"绝对多数"

六、⭐ 举一反三

核心命题

⭐⭐ 任何"能在一个区块内获得"的权力,都不是权力,是一个可以被租用的道具。

把这个问题问遍你的协议:

   "如果调用者在这一个区块内拥有无限资金,
    ⭐ 他能获得哪些【本该需要长期投入才能获得】的地位?"

   · 投票权              ⟹ 本篇
   · 价格影响力          ⟹ [第 12 篇](https://blog.ifcalm.org/posts/security/blockchain/12-bzx/)
   · 份额占比            ⟹ [第 15 篇](https://blog.ifcalm.org/posts/security/blockchain/15-cream-finance/)、[第 19 篇](https://blog.ifcalm.org/posts/security/blockchain/19-erc4626-inflation/)
   · 奖励快照里的持仓    ⟹ 空投/挖矿刷量
   · 白名单资格(持有 X 才能做 Y)
   · ⭐ 费率档位、VIP 等级、优先权

时间锁的一般价值

⭐ 时间锁不是"更安全的代码",它是【让人有机会介入】的机制。

⟹ 它在这些地方都应该出现:
   · 治理提案的执行
   · 合约升级
   · ⭐ 预言机地址变更([第 6 篇](https://blog.ifcalm.org/posts/security/blockchain/06-poly-network/)的教训)
   · 权限/角色变更
   · 关键参数的大幅调整
   · 提取协议金库

⚠️ 而它必须配合【监控】才有意义 ——
   一个没人看的时间锁,只是延迟了损失。

“应急通道"这个通用反模式

⭐ 任何系统里,"应急/快速/绕过"通道都有同一个问题:

   它被设计出来的理由是【正常流程太慢】,
   ⚠️ 而正常流程慢,恰恰是因为那些保护措施。

⟹ 于是应急通道 = 一条【合法的、绕过全部保护的路径】。

⭐ 检查方法:把系统里所有"绕过常规流程"的入口列出来,
   对每一个问:
      ① 它绕过了哪些保护?
      ② 谁能用它?
      ③ ⭐ 用它能造成的最坏后果是什么?
      ④ 这个后果能不能被限制成"只能做减法"?

关于"这在规则内是合法的”

⚠️ 攻击者常常主张:"我只是使用了协议允许的功能。"

⭐ 从写代码的角度看,这个主张的对错并不重要 ——
   重要的是它揭示的事实:

   【你的代码定义的规则,就是你实际同意的全部规则。】
   你脑子里的"意图"不在链上,
   ⟹ 而攻击者只和链上的那份规则打交道。

七、本案小结

  • 攻击者的实际资金投入是零:闪电贷本金在同一笔交易内还清,真正花的只有手续费和 Gas。
  • ⭐⭐ 等待期加在了错误的地方:24 小时加在"提案被创建"之后,而提交提案不需要任何投票权。攻击者第一天埋好提案,等待期悄悄走完;第二天在一个区块内完成"获得投票权 → 投票 → 通过 → 执行 → 还款”。
  • 等待期防的是"提案没人看见就通过",它完全没防"投票权在投票那一瞬间才被借来"——两个不同的威胁,只有前一个被防住。
  • ⚠️ “投票权分散所以没人能单方面通过"默认了"获得大量投票权需要时间和成本”——而 Beanstalk 的投票权可以用现金即时购买,闪电贷让现金无限且免费。这条前提在 2020 年就失效了。 -⭐ 应急通道是一条合法的、绕过全部保护的路径。 它被设计出来的理由是"正常流程太慢",而正常流程慢恰恰是因为那些保护。它必须被比主通道更严格地审查,实践中却往往被更宽松地对待。
  • 防御核心两条:① 投票权快照的时点必须早于提案创建——攻击者必须在提案之前就持币,而他那时还不知道要提什么提案,闪电贷因此完全失效。② 通过 ≠ 执行,中间必须有时间锁
  • 时间锁的价值不在于"阻止",在于给人反应的时间——而它必须配合监控才有意义,没人看的时间锁只是延迟了损失。
  • 应急通道应当只能"做减法"(暂停、撤权),不能转移资产、不能升级实现——这样最坏结果是 DoS 而非资金损失。 -⭐ 一般化:任何"能在一个区块内获得"的权力,都不是权力,是一个可以被租用的道具。
  • ⚠️ 你的代码定义的规则,就是你实际同意的全部规则——你脑子里的"意图"不在链上。

思考题

  1. 为什么"提交提案不需要投票权"这个设计本身是合理的?它和等待期组合起来为什么就出问题了?
  2. 精确说明:投票权快照的时点如果改成"提案创建的那个区块",攻击还能成功吗?如果改成"提案创建前 1000 个区块"呢?
  3. 时间锁"给人反应的时间"——请设计一套在时间锁期间发挥作用的监控与响应流程。
  4. 设计一个"只能做减法"的应急通道,列出它能调用的函数白名单。
  5. “法定人数按总供应量计而不是按参与量计”——请说明后者在低参与率时会怎样被利用。
  6. 把"如果调用者在这个区块内拥有无限资金"这个问题应用到你的协议上,找出至少三处可被临时获得的地位。
  7. 列出你系统里所有"绕过常规流程"的入口,逐个回答本篇给的四个问题。
  8. 治理提案"可以执行任意代码"意味着治理攻击 = 协议归零。请为一个具体协议设计治理的能力边界。
  9. 攻击者主张"我只使用了协议允许的功能"。请从写代码的人的角度,说明这个主张揭示了什么。
  10. 对比本篇和第 16 篇:两者都涉及治理被攻击者控制,但一个在攻击之前、一个在攻击之后。这个差别说明了什么?