时间 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、桥、借贷、质押——每一个"转换"都有一条"往返必须亏"的不变量,每条都不到十行。

思考题

  1. 精确写出攻击的一轮循环,标出份额价格在每一步的变化方向。
  2. 为什么攻击者要在第 ④ 步把池子推回原位?不推回去会怎样?
  3. bZx 攻击"估值”,Harvest 攻击"两次估值之间的差"。请再举一个属于后者的例子。
  4. 写出"往返不赚钱"不变量的 Foundry 测试代码。它还能抓住哪些别的漏洞?
  5. 为什么稳定币池的低波动性反而是一个风险因素?
  6. get_virtual_price() 对池子失衡不敏感,所以适合做估值——但它有什么别的问题?(提示:第 3 篇
  7. “让存入和取出不能在同一区块完成"会破坏哪些正常的用例?请评估这个代价。
  8. 设计一个存取费方案,使得本案的攻击无利可图。攻击者需要多大的价差才能突破它?
  9. 攻击持续了七分钟。请设计一套监控 + 响应机制,说明从"告警触发"到"资金停止流出"需要经过哪几步、各需多久。
  10. 把本档案前 13 篇按"攻击持续时间"分成四档。你自己的协议对哪几档做了准备?