Damn Vulnerable DeFi Solution 1: Unstoppable

Understand what are Flash loans and Lending pools

In this series of articles we are going to make a walkthrough of the famous CTF Damn Vulnerable Defi, created by one of the top auditors of OpenZeppelin,

My goal with this series of articles is to show you, not only the solution but also what was my process to reach that solution, also explain basic and advanced topics of blockchain development, Defi, EVM…

Our approach will be the same as we were doing in the ethernaut series, we are going to describe the contracts and some concepts that you may not be familiar with, explain how we can hack the contract and hack the contract.

Ready?

To set up the development environment you can go to the Damn Vulnerable Defi web and follow the instructions to download the code and start hacking.

Let's start with our first Challenge: Unstoppable

The challenge

There's a lending pool with a million DVT tokens in balance, offering flash loans for free. If only there was a way to attack and stop the pool from offering flash loans … You start with 100 DVT tokens in balance.

Let's first understand a couple of concepts, Lending Pool and Flash loans

Explaining some concepts

Lending pools

In the world of decentralized finance, Lending pools act as accounts in where any user who wants to lend their money, can deposit it in that account for any other user who wants to borrow some money. That's the reason why they are called pools, because many users can lend money (lenders) in a single account to make a pool of money for any other user who wants to borrow from (borrowers). These accounts are smart contracts. Of course, the idea is much more complex than that, but let's keep it simple.

To be able to borrow money from a lending pool we have to provide some kind of collateral, this is to maintain the liquidity in the pool The most “basic” way is by providing over-collateralization to borrow some money. Example

Let's say we have a pool in which users can lend DAI, for any other user who wants to borrow DAI from the pool they have to provide 110% in any other currency as collateral.

E.g: If I want to borrow 100 DAI I have to provide 110 ETH as collateral.

Of course, protocols use different types of mechanisms to maintain liquidity, this is just the most simple example.

Flash loans

Well, flash loans allow us to borrow some money but without providing any kind of collateral, WHAT? Free money??

Yeah, kinda, but why?

This is thanks to smart contract technology, to be able to do a flash loan we have to return the same amount that we borrow in the same block transaction.

Remember, when smart contracts call a function of other smart contracts they do not execute another transaction, they only pass a message within them in the same transaction. You can read more about that here

But, why do I want to borrow money to return it immediately? Well, the most common example is to do ”arbitrage”

Let's say Token A is in DEX A at 10 ETH but that same Token A is in DEX B at 11 ETH, you can buy Token A in DEX A at 10 ETH and then sell it in DEX B for 11ETH, and bum!!! PROFIT.

stonks-2286073.jpg

Of course, you have to take into account the fees, gas and interest of both dexes to be able to successfully execute the flash loan.

Now,

Let's take a look at the contracts.

ReceiverUnstoppable.sol

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import "../unstoppable/UnstoppableLender.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

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

    UnstoppableLender private immutable pool;
    address private immutable owner;

    constructor(address poolAddress) {
        pool = UnstoppableLender(poolAddress);
        owner = msg.sender;
    }

    // Pool will call this function during the flash loan
    function receiveTokens(address tokenAddress, uint256 amount) external {
        require(msg.sender == address(pool), "Sender must be pool");
        // Return all tokens to the pool
        require(IERC20(tokenAddress).transfer(msg.sender, amount), "Transfer of tokens failed");
    }

    function executeFlashLoan(uint256 amount) external {
        require(msg.sender == owner, "Only owner can execute flash loan");
        pool.flashLoan(amount);
    }
}

We have a function receiveTokens that receives the address of a Token (an ERC20) and an amount.

It evaluates whether the caller of the function is the smart contract referenced in the pool variable,. If not, it’ll revert the transaction

Then, it transfers the amount that we introduce as a parameter to the lending pool contract, if the transaction fails, it will revert.

The other function is executeFlashLoan that receives an amount as a parameter and requires that only the owner of the contract, who is set during the deployment of the contract, can call it.

Then it will active the flashLoan function of the pool contract

UnstoppableLender.sol

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

interface IReceiver {
    function receiveTokens(address tokenAddress, uint256 amount) external;
}

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

    IERC20 public immutable damnValuableToken;
    uint256 public poolBalance;

    constructor(address tokenAddress) {
        require(tokenAddress != address(0), "Token address cannot be zero");
        damnValuableToken = IERC20(tokenAddress);
    }

    function depositTokens(uint256 amount) external nonReentrant {
        require(amount > 0, "Must deposit at least one token");
        // Transfer token from sender. Sender must have first approved them.
        damnValuableToken.transferFrom(msg.sender, address(this), amount);
        poolBalance = poolBalance + amount;
    }

    function flashLoan(uint256 borrowAmount) external nonReentrant {
        require(borrowAmount > 0, "Must borrow at least one token");

        uint256 balanceBefore = damnValuableToken.balanceOf(address(this));
        require(balanceBefore >= borrowAmount, "Not enough tokens in pool");

        // Ensured by the protocol via the `depositTokens` function
        assert(poolBalance == balanceBefore);

        damnValuableToken.transfer(msg.sender, borrowAmount);

        IReceiver(msg.sender).receiveTokens(address(damnValuableToken), borrowAmount);

        uint256 balanceAfter = damnValuableToken.balanceOf(address(this));
        require(balanceAfter >= balanceBefore, "Flash loan hasn't been paid back");
    }
}

We have a depositTokens function that allows us to provide liquidity to the pool, to lend some Damn Vulnerable Token, it uses the transferFrom function of the ERC20 interface to transfer tokens from our balance to the balance of the contract and updates the poolBalance state variable adding the amount of tokens we transferred.

Then, we have a flashLoan function, that receives a uint256 as a parameter, this is the amount we want to borrow.

It checks what is the current balance of the tokens that this contract has in the DVT tokens, stores it in a local variable balanceBefore and It requires that the pool have enough tokens for us to borrow, if not it reverts.

Then it asserts that the poolBalace storage variable is equal to the balanceBefore

Iit transfer to us the amount we want to borrow, to do whatever operations we want

Calls the receiveTokens function of the UnstoppableReceiver contract

It checks again the current balance of tokens of the contract in the DVT smart contract and stores it in a variable balanceAfter

And finally checks whether balanceAfter >= balanceBefore, to assert that all the liquidity borrowed was returned.

Let's think

Ok, since we have to stop the flash loan function, there has to be some storage variable we can modify to revert all the calls made to the function, or at least some change we can make in the contract that allows us to modify its behavior forever. And what are those errors that can revert and stop smart contracts during execution? Of course, we can run out of gas, but that problem can be solved by putting more gas into the transaction. I’m referring to those specific lines of code that can revert transactions based on some parameters… exactly, the require , asserts, and if throw statements. So, let's check those…

In the depositTokens function there isn't an error or bug we can exploit to stop the flash loan, since only increments the balance of the contract

In the flashLoan function there are 3 points to which the contract can revert.

The first one, checks if the amount passed as a parameter is greater than zero, this is the most basic prevention you have to add when you are receiving tokens; so we can’t exploit it

require(amount > 0, "Must deposit at least one token");

The last require statement checks if all the money borrowed was returned successfully, the variables it checks are available only during the execution of the function since those are memory variables. So, at least we can modify those variables during the execution of the function to cause an error, every time someone calls it, there is nothing to do there.

With the assert statement, something is interesting;

assert(poolBalance == balanceBefore);

It checks if poolBalance and balanceBefore are equal.

If we see, poolBalance always updates when someone execute the depositToken function, it tracks internally how many DVTs the contract has.

But, balanceBefore is a memory variable that is set during the execution of flashLoan and it checks, not the internal amount of tokens that this contract has, but the number of tokens that the address of this contract has in the ERC20 DVT contract.

Is there a condition - escenario in where those variables can differ

uint256 balanceBefore = damnValuableToken.balanceOf(address(this));

Remember that the specification of every ERC20 contract must have a mapping that tracks how many tokens a specific account has.

You got it?, exactly,** we have to find a way to increment our balance directly in the DVT ERC20 smart contract** Since we are not calling the depositToken function, poolBalance wouldn't be updated, but since we are updating our balance in the DVT contract our balance in that contract will update and the assertion will no longer be true.

Lets hack the contract

HEY, try to hack the contract on your own, it's better to have hands-on experience by yourself.

Remember we have to code our solutions in the provided .challenge.js files (inside each challenge's folder in the test folder)

First, we have to execute a function that allows us to increment our DVT token balance in the DVT token contract. The transfer function will allow us to do that, every ERC20 smart contract must have a transfer function.

Since we were given 100 DVT (in the attacker address) tokens at the beginning, we can just transfer those to the UnstoppableLender smart contract.

await this.token.connect(attacker)
await this.token.transfer(this.pool.address, INITIAL_ATTACKER_TOKEN_BALANCE);

In the first line of code, we are just telling hardhat, that we want to interact with the DVT smart contract using the attacker address

In the second line of code, we are effectively transferring all our tokens to the UnstoppableLender contract. Using the transfer function which receives the address we want to send the tokens and the number of tokens we want to send

That's it.

Now just run

$ yarn hardhat test test/unstoppable/unstoppable.challenge.js

Done


That's all folks…

You can see the complete solution in this GitHub repo

If you have any comments or suggestions please leave it in the comments section, also if you see any problem with the code feel free to make a PR.

You can follow me on my Twitter @kevbto and DM me, I’m always happy to talk and get to know more people in this amazing community.

Stay tuned for the next Damn Vulnerable Token solution: Naive receiver