| 时间 | 2020 年 10 月 26 日 |
| 涉案 | 约 $3400 万 |
| 结局 | 攻击者退回约 $250 万 |
| 类别 | C4 外部数据假设失效 |
| 新形态 | ⭐ 攻击"份额价格"而不是"抵押品估值" |
一、背景:金库的份额价格
Harvest 是一个收益聚合器(机枪池)。用户存入稳定币,金库拿去做收益策略,用户拿到份额代币(fToken)代表自己的占比。
存入: 份额 = 存入金额 / 份额价格
取出: 金额 = 份额 × 份额价格
其中: ⭐ 份额价格 = 金库总资产 / 总份额
关键在于"金库总资产"怎么算。 Harvest 的资产是放在 Curve 的流动性池里的,所以:
金库总资产 = 金库持有的 Curve LP 数量 × ⚠️ LP 的当前价值
└─ 从 Curve 池【即时读取】
⭐ 于是份额价格间接地依赖于 Curve 池的即时状态——而池子的状态可以被一笔交易改变。
二、漏洞在哪:存和取用了同一个可被操纵的价格
⚠️ 存入和取出【读的是同一个数】,
而这个数在一笔交易内可以被推到任意位置。
⟹ 攻击者只要能让
存入时的份额价格 < 取出时的份额价格
就能在一个来回里凭空获利。
⭐⭐ 这个漏洞的形状和 bZx 不同,值得区分:
bZx:操纵价格 ⟹ 让协议高估我的抵押品 ⟹ 借出更多 Harvest:操纵价格 ⟹ 让协议低估我存入时的单价、高估我取出时的单价 ⟹ 份额套利
前者攻击的是"估值",后者攻击的是"两次估值之间的差"。
三、攻击复盘
在一个区块内,重复以下循环(实际执行了三十多次):
① 闪电贷借入大量 USDT
② ⭐ 在 Curve 的 USDT/USDC 池里,用 USDT 大量换 USDC
⟹ 池子失衡,USDT 变"便宜"、USDC 变"贵"
⟹ ⚠️ Harvest 金库的份额价格被【压低】
③ 以【被压低的】份额价格,向金库存入 USDC
⟹ 拿到比正常情况【更多】的份额
④ ⭐ 反向操作:把 USDC 换回 USDT,池子回到原位
⟹ 份额价格【恢复正常】
⑤ 以【恢复后的】份额价格赎回刚才拿到的份额
⟹ 取出的金额 > 存入的金额
⑥ 还掉闪电贷,差额是利润。回到 ①。
⭐ 注意第 ④ 步:攻击者把池子推回原位,所以每一轮结束时市场看起来毫无异常。他不需要"维持"一个错误价格,只需要在存入和取出这两个瞬间之间制造差异。
一个重要的细节:这不是一笔交易
⚠️ 攻击持续了约七分钟、三十多笔交易,
而不是一笔原子交易。
⭐ 这意味着:
① 中途有充分的时间窗口可以被发现并阻断
② ⚠️ 但没有任何自动机制在盯着 ——
没有异常告警,没有单位时间限额,没有暂停按钮被按下
这一点比漏洞本身更值得记住:不是每次攻击都是"一个区块内神不知鬼不觉"。很多攻击留出了几分钟的反应时间,而绝大多数协议没有任何东西在利用这几分钟。
四、为什么没被发现
① 存取用同一个价格函数,看起来是"一致"的
⭐ 心智模型:
"存入和取出用同一套估值逻辑,
这样才公平、才不会有偏差。"
⚠️ 这个推理漏掉了一件事:
公平的前提是【两次估值之间价格没被人为改变】。
⟹ 用同一个函数保证了"公式一致",
⭐ 但保证不了"输入一致"。
② 攻击的是稳定币池,波动看起来很小
USDT/USDC 池 —— 两种稳定币,价格几乎恒等于 1。
⟹ ⚠️ "这个池子的价格根本不会动" 是一个非常自然的直觉。
⭐ 但攻击者不需要让价格【偏离很多】,
他只需要让它【偏离一点点】,然后重复三十次。
⟹ 稳定币池的低波动性,反而让人放松了对操纵的警惕。
③ 没有针对"往返"的测试
测试用例通常是:
□ 存入 ⟹ 份额正确
□ 取出 ⟹ 金额正确
□ 收益增长 ⟹ 份额价格上升
⚠️ 没有人测:
⭐【存入之后【立刻】取出,用户是赚了还是亏了?】
⟹ 而这一条恰恰是整个金库最核心的不变量。
五、防御
① 那条应该刻在每个金库上的不变量
⭐⭐ 存入之后立刻取出,用户拿回的金额必须【严格小于】他存入的金额。
差额来自手续费和舍入。如果这个往返能赚钱,无论多少,都是漏洞。
function invariant_roundTripNeverProfits(uint256 amount) public {
uint256 balBefore = token.balanceOf(user);
uint256 shares = vault.deposit(amount, user);
vault.redeem(shares, user, user);
uint256 balAfter = token.balanceOf(user);
assertLe(balAfter, balBefore); // ⭐ 必须不赚钱
}
这条不变量能自动抓住本案、第 19 篇的第一存款人攻击,以及大部分份额计算错误。写它只需要五行。
② 存取不能用可被即时操纵的估值
⭐ 金库的"总资产"必须来自不可被单笔交易改变的来源:
✅ 底层资产的 TWAP
✅ 外部预言机
✅ ⭐ Curve 的 get_virtual_price()(它对池子失衡不敏感)
⚠️ 但要注意[第 3 篇](https://blog.ifcalm.org/posts/security/blockchain/03-readonly-reentrancy/)的只读重入问题!
❌ 池子的即时储备比例
⚠️ 注意这里的两难:抗操纵的价格源往往有滞后,而滞后本身也是攻击面(见第 14 篇)。没有免费的答案。
③ 存取之间加时间差
⭐ 最彻底的结构性解法:让"存入"和"取出"不能在同一个区块完成。
· 提款需要先 requestWithdraw,下一个区块才能执行
· 或者存入后有最短锁定期(哪怕只有一个区块)
⟹ 这直接消灭了整类"单笔交易内套利"的攻击,
⚠️ 代价是用户体验和组合性变差。
④ 存取费
⚠️ 一个足够大的存取费(比如 0.1%)会让往返套利无利可图,
⭐ 但它只是提高门槛,不是消除漏洞 ——
攻击者只要能制造大于费率的价差,仍然有利可图。
⟹ 把它当成【纵深防御的一层】,不要当成解法。
⑤ 监控要能利用那七分钟
⭐ Harvest 的攻击持续了约七分钟、三十多笔交易。
一个最基础的监控就能发现:
□ 单位时间内同一地址的存取次数异常
□ 份额价格在短时间内的波动超过阈值
□ ⭐ 金库总资产的净流出速度超过阈值
□ 与前一个区块相比,任何关键指标的跳变
⚠️ 有了告警还不够,还要有【能在几分钟内执行的动作】:
一个不需要多签、由 guardian 单方面就能按的【暂停键】。
六、⭐ 举一反三
核心命题:攻击"两次读数之间的差"
⭐⭐ 不要只问"这个价格能不能被操纵",还要问"同一个价格被读两次之间,能不能被改变"。
这个模式的其它形态:
· 存入价 vs 取出价 ⟹ 本篇
· 抵押时的估值 vs 清算时的估值 ⟹ 可以先高估抵押、再低估触发清算
· 下单时的价格 vs 成交时的价格 ⟹ 三明治攻击([第 34 讲](https://blog.ifcalm.org/posts/blockchain/34-mev/))
· 快照时的持仓 vs 分配时的持仓 ⟹ 空投/奖励刷量
· 计算费用时的汇率 vs 支付时的汇率
· ⭐ 任何"先读一次、做点事、再读一次"的流程
一条设计规则
⭐ 在一个函数里,如果同一个外部量被读了两次,
⚠️ 且两次之间有任何外部调用,
⟹ 必须假设这两次读到的值【不同,且差值由攻击者选择】。
⟹ 解法:读一次,缓存,全程使用缓存值。
如果业务上必须读两次,就必须显式处理它们不一致的情况。
关于"往返不变量"的一般形式
⭐ 几乎所有涉及"转换"的协议,都有一条同形的不变量:
金库: 存入 ⟹ 取出 必须亏
AMM: A→B ⟹ B→A 必须亏
桥: 链1→链2 ⟹ 链2→链1 必须亏(或至少不赚)
借贷: 存 ⟹ 借 ⟹ 还 ⟹ 取 必须亏
质押: 质押 ⟹ 立即解押 必须亏
⚠️ 每一条都能写成不到十行的 fuzz 测试,
⭐ 而它们能覆盖的漏洞类型远超其代码量。
关于响应时间
⭐ 把历史事故按"攻击持续时间"分类,会看到一个被忽略的事实:
一笔原子交易 ⟹ 无法人工干预([第 12 篇](https://blog.ifcalm.org/posts/security/blockchain/12-bzx/)第一起)
几分钟、几十笔 ⟹ ⭐ 有窗口,但需要自动监控 + 快速暂停(本篇)
几小时 ⟹ 有充分窗口([第 7 篇](https://blog.ifcalm.org/posts/security/blockchain/07-nomad-bridge/) Nomad)
⚠️ 几天才被发现 ⟹ 连"有没有在看"都成问题([第 22 篇](https://blog.ifcalm.org/posts/security/blockchain/22-ronin-bridge/) Ronin 六天)
⟹ 大部分协议只为第一档做了防御(代码层面),
⭐ 而对后三档几乎没有任何准备。
七、本案小结
- 攻击的不是抵押品估值,是"份额价格":在同一个区块里以被压低的价格存入、以恢复后的价格取出,重复三十多次。
- ⭐⭐ 和 bZx 的关键区别:bZx 攻击的是"估值"(高估我的抵押品),Harvest 攻击的是"两次估值之间的差"。
- 攻击者每轮都把池子推回原位,所以每一轮结束时市场看起来毫无异常——他不需要维持错误价格,只需要在两个瞬间之间制造差异。
- ⚠️ “存取用同一个函数所以公平"的推理漏掉了一件事:公式一致保证不了输入一致。
- 稳定币池的低波动性反而降低了警惕——攻击者不需要偏离很多,只需要偏离一点点然后重复三十次。 -⭐ 那条应该刻在每个金库上的不变量:存入之后立刻取出,拿回的金额必须严格小于存入的金额。 差额只能来自手续费和舍入。能赚钱就是漏洞,无论多少。写它只要五行,能抓住本案和第一存款人攻击。
- 最彻底的结构性解法是让存入和取出不能在同一区块完成(提款请求 + 下一区块执行,或最短锁定期),代价是体验和组合性。
- ⚠️ 存取费只是提高门槛,不是解法——攻击者只要制造大于费率的价差仍然有利可图。当作纵深防御的一层。 -⭐ 攻击持续了约七分钟、三十多笔交易,而没有任何自动机制在盯着。 不是每次攻击都神不知鬼不觉——很多攻击留出了几分钟,而绝大多数协议没有任何东西在利用这几分钟。
- 一般化的往返不变量:金库、AMM、桥、借贷、质押——每一个"转换"都有一条"往返必须亏"的不变量,每条都不到十行。
思考题
- 精确写出攻击的一轮循环,标出份额价格在每一步的变化方向。
- 为什么攻击者要在第 ④ 步把池子推回原位?不推回去会怎样?
- bZx 攻击"估值”,Harvest 攻击"两次估值之间的差"。请再举一个属于后者的例子。
- 写出"往返不赚钱"不变量的 Foundry 测试代码。它还能抓住哪些别的漏洞?
- 为什么稳定币池的低波动性反而是一个风险因素?
get_virtual_price()对池子失衡不敏感,所以适合做估值——但它有什么别的问题?(提示:第 3 篇)- “让存入和取出不能在同一区块完成"会破坏哪些正常的用例?请评估这个代价。
- 设计一个存取费方案,使得本案的攻击无利可图。攻击者需要多大的价差才能突破它?
- 攻击持续了七分钟。请设计一套监控 + 响应机制,说明从"告警触发"到"资金停止流出"需要经过哪几步、各需多久。
- 把本档案前 13 篇按"攻击持续时间"分成四档。你自己的协议对哪几档做了准备?