时间 2020 年 2 月 15 日与 18 日
涉案 两起合计约 $100 万
意义 ⭐ 此后所有"闪电贷 + 预言机操纵"攻击的模板
类别 C4 外部数据假设失效

一、背景:闪电贷不是漏洞

先把这件事说清楚,因为它经常被误解。

闪电贷:在【同一笔交易】内借出资金,
        并在交易结束前还清(含手续费)。
        ⭐ 如果没还上,整笔交易回滚,就像什么都没发生。

⟹ 出借方【零风险】:要么还了,要么这笔借款根本不存在。
⟹ 因此不需要抵押品,也不需要信用。

⭐⭐ 闪电贷本身完全安全,它是原子性的一个漂亮应用。 它改变的不是"能不能借到钱",而是攻击者的资金门槛

闪电贷之前:
   要操纵一个池子的价格,你得【真的拥有】几百万美元。
   ⟹ 攻击者集合 = 有几百万闲钱且愿意冒险的人
   ⟹ 这个集合很小

⭐ 闪电贷之后:
   任何人都可以在一个区块内临时拥有几千万美元。
   ⟹ 攻击者集合 = 【所有会写 Solidity 的人】
   ⟹ 攻击成本 = Gas 费

闪电贷没有创造任何新漏洞。 它把"理论上存在但没人有钱去利用"的漏洞,变成了"任何人都能利用"的漏洞。

所有那些默认了"攻击者不会有那么多钱"的设计,在 2020 年 2 月同时到期了。

二、漏洞在哪

bZx 是一个保证金交易协议。它允许用户借入资金开杠杆头寸,并需要判断头寸是否健康——而"健康"取决于价格

bZx 的价格来自 Kyber / Uniswap 的链上现货价

⚠️ 而链上现货价有一个致命性质:

   在恒定乘积池 x·y = k 里,价格 p = y/x。
   ⭐ 任何一笔足够大的交易,都会【立刻】改变 p。

   把价格推到 k 倍需要投入:ΔY = y · (√k − 1)
      推到 2 倍  ⟹ 0.414 × 池中 Y 储备
      推到 4 倍  ⟹ 1.000 × 池中 Y 储备

⟹ 池子越浅,操纵越便宜。

bZx 的假设是"现货价 ≈ 市场价"。这个假设只在"没人愿意花钱推它"时成立。

三、攻击复盘

第一起(2 月 15 日):单笔交易内的组合攻击

一笔交易,五个步骤,全部原子完成:

① 从 dYdX 闪电贷借入 10,000 ETH
       │
② 用其中一部分在 Compound 抵押,借出 WBTC
       │
③ ⭐ 在 bZx 上开一个【超大杠杆】的 WBTC 空头
       └─ bZx 为了建仓,去 Uniswap 上【卖出大量 ETH 换 WBTC】
       └─ ⚠️ 这笔交易本身把 Uniswap 的 WBTC 价格推得极高
       │
④ 把第 ② 步借来的 WBTC 在【被推高的】Uniswap 上卖掉
       └─ ⭐ 以远高于市价的价格出货
       │
⑤ 还掉闪电贷,剩下的是利润

注意第 ③ 步的精妙之处:攻击者没有自己去操纵价格——他让 bZx 替他操纵。bZx 为了执行用户的开仓请求,主动在一个浅池里做了一笔巨额交易。

⚠️ 而 bZx 本应有一个"滑点保护"来拒绝这种交易 ——
   ⭐ 那个检查因为一个逻辑错误被绕过了。

第二起(2 月 18 日):更直接的预言机操纵

① 闪电贷借入大量 ETH

② ⭐ 在 Uniswap 的 sUSD/ETH 池里大量买入 sUSD
      ⟹ 池子很浅,sUSD 的价格被推到市价的数倍

③ 把 sUSD 存入 bZx 作为抵押品
      ⟹ bZx 读 Uniswap 的现货价
      ⟹ ⚠️ 认为这些 sUSD 值远超真实价值

④ 按虚高的抵押品价值,借出 bZx 里的 ETH

⑤ 还掉闪电贷,带走差额

第二起是"教科书式"的版本,此后被复制了上百次

借钱 ⟹ 推高某个资产在浅池里的价格
     ⟹ 让协议按这个价格给你计价
     ⟹ 借出/换出真实资产
     ⟹ 还钱,走人

四、为什么没被发现

① “没人会花几百万来操纵一个池子”

⚠️ 这是一个【经济学假设】,被当成了【安全假设】。

   它成立的条件:攻击成本 > 攻击收益。
   ⭐ 而闪电贷把攻击成本从"几百万本金"降到了"Gas 费"。

⟹ 所有基于"攻击者资金有限"的推理,在那一刻全部失效。

② 链上价格被当成了"客观价格"

心智模型:
   "Uniswap 的价格是市场决定的,它是客观的。"

⭐ 现实:
   Uniswap 的价格是【最后一笔交易】决定的。
   而"最后一笔交易"可以是攻击者刚刚做的那一笔。

⟹ 它不是"市场价",它是"一个可以被出价改变的数"。

③ 单个协议审计看不到组合

⚠️ 第一起攻击串联了 dYdX、Compound、bZx、Uniswap 四个协议。

   每一个协议单独看都在正常工作:
      dYdX 的闪电贷按设计还清了 ✅
      Compound 的抵押借贷正常 ✅
      Uniswap 的定价公式正确 ✅
      bZx 的开仓逻辑执行了用户的指令 ✅

⭐ 攻击路径不存在于任何一个协议里,
   它存在于【它们的组合】里。
   ⟹ 而没有任何一份审计的范围包含"组合"。

五、防御

① 永远不要读现货价

// ⚠️⚠️ 绝对不要这样
uint price = pool.getReserves().token1 / pool.getReserves().token0;

// ⚠️ 也不要这样(一次 swap 的即时报价同样可被操纵)
uint price = router.getAmountsOut(1e18, path)[1];

判据:任何"读一次就能得到"的链上价格,都可以在读之前的那一行被推到任意位置。

② TWAP 提高操纵成本(但不是免费的)

时间加权均价把成本从"一个区块"抬到"持续若干区块":

   30 分钟窗口、只能维持一个区块、想把 TWAP 推到 2 倍
      ⟹ ⭐ 现货价要推到 151 倍,需要 11.3 倍池子储备
      ⟹ 比现货操纵贵约 27 倍
      ⟹ ⭐ 而且【用不了闪电贷】—— 闪电贷必须在一个区块内还清

⚠️ 代价是滞后 —— 见[第 14 篇](https://blog.ifcalm.org/posts/security/blockchain/14-compound-dai/)。

完整推导见第 31 讲,那里有可运行的计算代码。

③ 多源 + 偏离检测

uint spot   = getSpotPrice();
uint twap   = getTWAP(30 minutes);
uint oracle = getExternalOracle();

// ⭐ 任意两个偏离超过阈值 ⟹ 判定市场异常,暂停敏感操作
require(_within(spot, twap, 5e16), "price deviation");
require(_within(twap, oracle, 5e16), "price deviation");

⚠️ 注意这引入了新的攻击面:如果制造"偏离"很便宜,攻击者就能按需冻结你的协议(DoS)。必须一起设计恢复机制。

④ 池子深度门槛

⭐ ΔY = y(√k − 1) 里的 y 是池子储备。
   ⟹ 小池子的价格,无论用什么方法平滑,都是纸糊的。

⟹ 上架一个资产之前,先算:
   "把它的价格推到 2 倍要多少钱?"
   ⚠️ 如果这个数小于你协议里该资产的敞口,
      就不要上架它。

⑤ 检查自己的操作会不会移动价格

⭐ bZx 的第一起攻击里,是【bZx 自己】把价格推歪的。

⟹ 任何时候你的合约要在 DEX 上执行一笔交易:
   □ 设置滑点上限,并确认这个检查【真的会执行】
   □ 检查这笔交易占池子深度的比例
   □ ⚠️ 大额交易拆分或走聚合器

六、⭐ 举一反三

核心命题:闪电贷让"资金量"不再是门槛

⭐⭐ 对你合约里每一个"按余额/持仓/份额计权"的地方,都要问: “如果调用者在这一个区块内拥有无限资金,会怎样?”

这条问题会命中的地方远比想象中多:

   · 价格 = f(池子储备)              ⟹ 本篇
   · 投票权 = f(代币余额)            ⟹ [第 17 篇](https://blog.ifcalm.org/posts/security/blockchain/17-beanstalk/) Beanstalk
   · 份额 = f(存款/总资产)           ⟹ [第 19 篇](https://blog.ifcalm.org/posts/security/blockchain/19-erc4626-inflation/) 第一存款人
   · 奖励 = f(某时刻的持仓快照)      ⟹ 快照可被临时拉满
   · 清算折扣 = f(抵押率)            ⟹ 抵押率可被临时操纵
   · 费率档位 = f(交易量/持仓量)     ⟹ 临时冲量拿低费率
   · ⚠️ 任何 "持有 X 才能做 Y" 的门槛 ⟹ 临时持有 X

一条设计原则

⭐ 把"瞬时状态"和"持续状态"分开:

   ⚠️ 瞬时可得的:当前余额、当前储备、当前价格
      ⟹ 不能用来做任何重要决策

   ✅ 需要时间累积的:TWAP、时间加权余额、
      快照 + 延迟、上一个区块的状态
      ⟹ 攻击者必须【跨区块】维持,成本高得多

⟹ 一般化:
   ⭐【任何在单个区块内可以被改变的量,
      都不能单独决定资金的去向。】

组合性风险

⭐ 你的协议一旦上线,就会被别人组合进他们的系统,
   而你无法控制这件事。

⟹ 两个方向的责任:
   ① 作为【读取方】:假设你读的一切都可能被操纵
   ② ⭐ 作为【被读方】:你暴露出去的每一个 view 函数,
        都可能被别人当成价格源
        ⟹ 如果它可被操纵,就要在文档里【明确警告】
        ⟹ 或者干脆不要暴露它

⚠️ [第 3 篇](https://blog.ifcalm.org/posts/security/blockchain/03-readonly-reentrancy/)讲的是同一件事的另一面。

七、本案小结

  • 金额只有约 $100 万,但它是此后所有价格操纵攻击的模板。
  • ⭐⭐ 闪电贷本身完全安全,它是原子性的漂亮应用。它没有创造任何新漏洞——它把"理论上存在但没人有钱利用"的漏洞变成了"任何人都能利用"的漏洞。 攻击者集合从"有几百万闲钱的人"变成"所有会写 Solidity 的人"。
  • 第一起的精妙之处:攻击者没有自己操纵价格,他让 bZx 替他操纵——bZx 为执行开仓请求,主动在浅池里做了一笔巨额交易,而滑点保护因逻辑错误被绕过。
  • 第二起是教科书版本,此后被复制上百次:借钱 → 推高浅池价格 → 让协议按这个价格计价 → 借出真实资产 → 还钱走人。
  • ⚠️ “没人会花几百万操纵一个池子"是经济学假设,被当成了安全假设——闪电贷把攻击成本从几百万本金降到 Gas 费,所有基于"攻击者资金有限"的推理在那一刻同时失效
  • 链上现货价不是"市场价”,是"最后一笔交易决定的数"——而最后一笔交易可以是攻击者刚做的那笔。
  • 攻击路径不存在于任何单个协议里:dYdX、Compound、Uniswap、bZx 各自都在正常工作。它存在于组合里,而没有任何审计的范围包含"组合"。 -⭐ 核心检查:对每一个"按余额/持仓/份额计权"的地方问——“如果调用者在这一个区块内拥有无限资金,会怎样?” 这条会命中价格、投票权、份额、奖励快照、费率档位、任何"持有 X 才能做 Y"的门槛。
  • 设计原则:任何在单个区块内可以被改变的量,都不能单独决定资金的去向。

思考题

  1. 为什么说"闪电贷不是漏洞"?请从出借方的风险角度论证它的安全性。
  2. 在恒定乘积池里把价格推到 3 倍需要多少储备?推到 9 倍呢?这个函数的增长有什么特点?
  3. 第一起攻击中,是谁把 Uniswap 的价格推歪的?这个细节为什么重要?
  4. 为什么 TWAP 操纵"用不了闪电贷"?请精确说明闪电贷的原子性约束在这里如何起作用。
  5. 多源 + 偏离检测引入了什么新的攻击面?设计一个兼顾"能检测异常"和"不能被廉价触发"的方案。
  6. 上架一个新资产前要算"把价格推到 2 倍要多少钱"。如果这个数是 50 万美元,而你协议里该资产的敞口是 2000 万,应该怎么办?
  7. 把"如果调用者在这个区块内拥有无限资金"这个问题,应用到你熟悉的一个协议上。找出至少两处会出问题的地方。
  8. 区分"瞬时可得的量"和"需要时间累积的量"。请在你的代码里各找出三个例子。
  9. 作为"被读方"的责任是什么?如果你的协议暴露了一个可被操纵的 view 函数,你应该做什么?
  10. bZx 两起攻击相隔三天。第二起为什么比第一起更"标准"、更容易被复制?这对漏洞披露后的响应窗口意味着什么?