W3
严重预言机与价格静态扫描查不出

闪电贷操纵价格

Flash Loan Price Manipulation

攻击者借来巨额资金,在一个交易内把某交易对的现货价格推歪,让依赖这个价格的协议做出错误判断(多放贷、错误清算、错误铸币),然后归还贷款。

根因

协议把可以被单笔交易改变的值当成了可信价格。AMM 的现货价就等于「储备量之比」,而储备量是可以被一笔大额 swap 直接推着走的。这个值在攻击结束后会回到原处,所以攻击者甚至不需要承担价格风险。

攻击是怎么一步步发生的

  1. 1
    闪电贷协议

    借入巨额 TOKEN_A(无抵押,只要在同一交易内归还)

  2. 2
    AMM 池

    把 TOKEN_A 全部砸进池子 → TOKEN_A 价格暴跌、TOKEN_B 相对暴涨

  3. 3
    被攻击协议

    用被推歪的现货价计算:抵押品「价值」被高估(或债务被低估)

  4. 4
    被攻击协议

    借出远超真实抵押能力的资金

  5. 5
    AMM 池

    反向 swap 把价格还原,拿回绝大部分 TOKEN_A

  6. 6
    闪电贷协议

    归还贷款 + 利息,净赚差额

有漏洞的版本

不要照抄solidity
Solidity17
contract SpotPriceLending {
    IUniswapV2Pair public pair;
    mapping(address => uint256) public collateralOf;

    // 直接用资金池的当前储备量当价格
    function collateralValue(address user) public view returns (uint256) {
        (uint112 r0, uint112 r1, ) = pair.getReserves();
        // 现货比:攻击者只要在同一个交易里把 r0 推大,这个价格立刻变小
        uint256 priceToken1PerToken0 = (uint256(r1) * 1e18) / uint256(r0);
        return (collateralOf[user] * priceToken1PerToken0) / 1e18;
    }

    function borrow(uint256 amount) external {
        require(amount <= collateralValue(msg.sender) / 2, "insufficient collateral");
        // ...放款
    }
}
  • getReserves() 返回的是此刻的储备量,没有时间维度,无法抵抗单笔交易的影响
  • 整个操纵在同一笔交易内完成,攻击者用完就还,资金成本接近于零(只有 gas 和手续费)
  • 静态扫描完全看不出来:这段代码语法、逻辑、权限都没毛病

修复版本

推荐写法solidity
Solidity20
contract SafePriceLending {
    AggregatorV3Interface public immutable feed;   // Chainlink 喂价
    IUniswapV3Pool public immutable pool;          // 兜底:TWAP

    function price() public view returns (uint256) {
        (, int256 answer, , uint256 updatedAt, ) = feed.latestRoundData();
        require(answer > 0, "invalid price");
        require(block.timestamp - updatedAt <= 1 hours, "stale price"); // 喂价不能太旧
        return uint256(answer);
    }

    // 关键补丁:给「单笔能借走多少」设上限,
    // 让操纵价格的成本高于能借走的收益
    uint256 public constant MAX_BORROW_PER_TX = 100_000e18;

    function borrow(uint256 amount) external {
        require(amount <= MAX_BORROW_PER_TX, "exceeds per-tx cap");
        // ...后续按 price() 计算抵押率
    }
}
  • 换用外部喂价(Chainlink 等),它的值不由本协议所在的池子决定,单笔 swap 推不动
  • 检查 updatedAt 时效,避免用了几天前的旧价格
  • 设单笔上限是最容易被忽略但最有效的一招:即便预言机被绕过,损失也被封顶
  • 还可以叠加 TWAP(时间加权均价)+ 与现货价的偏离度校验,偏离过大就暂停

怎么防

  • 不要用 AMM 现货价当预言机,改用 Chainlink / Pyth 等外部喂价
  • 确实要用链上价格就用 TWAP(≥30 分钟窗口),拉长操纵成本
  • 多源交叉验证:两个独立来源偏离超过阈值就暂停
  • 给单笔操作设上限,把最坏损失封顶
  • 区分「价格」与「可清算性」:清算阈值留足缓冲,不要卡在边界

真实案例

2020bZx约 95 万美元(两起)

闪电贷操纵的早期经典案例,开创了这一攻击范式

2020Harvest Finance约 3380 万美元

用闪电贷推歪 Curve 池价格,套利协议自身的策略

2021PancakeBunny约 4500 万美元

BNB/BUNNY 池被操纵,BUNNY 被超量铸出后砸盘

2022Mango Markets约 1.16 亿美元

自买自卖推高 MNGO 价格 → 抵押品虚高 → 借空金库

审计这个合约时要问的问题

  • 1.合约里的每一个「价格」从哪来?它能不能被同一笔交易改变?
  • 2.这个价格有多个来源吗?来源之间会互相依赖吗?
  • 3.单笔交易的收益上限是多少?攻击成本是否高于收益?
  • 4.清算逻辑有没有依赖「现货余额 / 储备量」这类瞬时值?

这些问题也是审计检查清单里对应的条目——那里可以把每一条的检查结果记下来,最后导出成报告。

静态扫描能发现它吗

不能。源码扫描引擎里没有能覆盖这一条的规则。 原因不是规则写得不够多,而是这类漏洞的代码语法完全正常: 权限检查写了、外部调用顺序对了、整数运算也没问题, 危险来自运行时的状态组合。

这正是「扫一遍没发现问题」不能当作安全结论的原因。 本站的合约体检会把这一类明确标成「工具完全查不出来」的检查项,而不是给你一个绿灯。

延伸阅读

下一步:这一条没有任何自动规则覆盖,扫描结果里永远看不到它——这正是Rug Pull 扫描器页面里那句「没命中不等于安全」的具体含义。 审计检查清单可以把它落成一条可勾选、可写备注的记录,最后导出 Markdown 报告;想先弄懂这一类漏洞背后的通用思路,去参考手册看对应的语言与标准条目。