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