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