Damn Vulnerable DeFi - Challenge #2 - Naive receiver
1) Challenge
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 receiveing flash loans of ETH.
Drain all ETH funds from the user’s contract. Doing it in a single transaction is a big plus ;) (link)
Challenge created by @tinchoabbate.
2) Code Review
Like in the previous challenge, let’s start by reviewing how flash loans are provided.
The function responsible for offering flash loans is flashLoan
, defined in the NaiveReceiverLenderPool
contract (source code):
|
|
The flashLoan
function:
- prevents reentrancy by using the
nonReentrant
modifier from ReentrancyGuard - checks if the ETH balance of the contract is greater or equal to the amount we want to borrow (line
24
) - checks if the borrower is a contract (line
27
) - calls the
receiveFlashLoan
function of the borrower contract (line29
) - checks if we paid back the loan (line
38
)
The pool starts with 1000 ETH
(challenge setup), while the FlashLoanReceiver
contract (borrower) starts with 10 ETH
balance (challenge setup).
Important things to notice:
- anyone can call this function
- there are no checks on the borrow amount (so it can also be
0
)
To transfer ETH, the pool contract uses functionCallWithValue
(line 29
) from Address.sol
library (doc here). It sends the borrowAmount
to the borrower and calls the payable receiveEther(uint256)
function, exposed by the borrower, with 1 ETH
amount value (the fixed expensive fee).
The receivedEther
function from FlashLoanReceiver
(source code):
- checks if the sender is the pool (line
22
), so it means the pool contract can only call it - computes the amount to be paid back that is the amount borrowed plus the (expensive) fee, that in this case is
1 ETH
- checks if the balance of the contract is greater or equal to the amount to be paid back
- calls
_executeActionDuringFlashLoan
(that does nothing) - finally, it returns the amount borrowed plus the fees to the pool (line
31
)
|
|
After calling receiveEther
, the borrower
has to pay back the flash loan plus an expensive fee of 1 ETH
.
flashLoan
function ?As we have seen before, flashLoan
can be called by anyone by providing a borrower contract address that implements the receiveEther
function.
receiveEther
function of a target contract?Only the pool can call receiveEther
since there is a check at line 22
.
However, by providing a borrower address, anyone can call the flashLoan
function. It means that we (as an attacker) can indirectly call receiveEther
of a borrower contract because receiveEther
only checks the msg.sender
but does not check who called flashLoan
.
flashLoan
function is called ?Every time the flashLoan
function is called, the borrower has to pay 1 ETH
fee.
Let’s see an example with the following values (I’m using the challenge code provided):
borrower
is set to thereceiver.address
borrowAmount
is set to0
(it can be any value<= 1000 ETH
)
|
|
When calling functionCallWithValue
, the borrowAmount
, that in our example is 0
, is sent to the borrower. Since receiveEther
is a payable function, the borrower’s balance is updated with the amount received (that in our example is 0
- so it does not change).
Inside receivedEther
, the value of amountToBeRepaid
(line 24
) will be equal to 0 ETH + 1 ETH = 1 ETH
and thus the condition require(address(this).balance >= amountToBeRepaid)
will be true
since the receiver balance is 10 ETH
and the amount to be repaid is 1 ETH
.
Finally, 1 ETH
is sent back to the pool, decreasing the borrower balance by 1 ETH
.
So, after calling receiveEther
, the receiver’s balance is decreased by 1 ETH
(the fee applied by the pool).
If we call flashLoan
another time with the same input, the borrower’s balance will be 9 ETH
, so the condition require(address(this).balance >= amountToBeRepaid)
will still be true
(9 >= 1
) and it will be decreased by 1 ETH
.
So, executing the function 10
times will drain the borrower’s balance.
3) Solution
There are multiple ways to solve this challenge.
3.1) Solution 1: multiple transactions
We can call flashLoan
10
times in a loop and drain all the borrower’s balance.:
|
|
Output:
|
|
However, with the code above, every time we call flashLoan
, this will be executed in a single transaction, so we need to perform 10
transactions to solve the challenge.
3.2) Solution 2: single transaction
To optimize the number of transactions, we can implement the same logic in a contract and then call the function only once.
AttackNaiveReceiver.sol
:
|
|
naive-receiver.challenge.js
:
|
|
You can find the complete code here and here.