Side-Entrance
Statement
A surprisingly simple lending pool allows anyone to deposit ETH, and withdraw it at any point in time. This very simple lending pool has 1000 ETH in balance already, and is offering free flash loans using the deposited ETH to promote their system. You must take all ETH from the lending pool.
Analysis
We need to steal all the ETH from the pool and send them to the attacker's address.
Understanding contracts
There is only one contract to analyze:
- SideEntranceLenderPool.sol: Contract which implements the logic of lending flash loans, depositing and withdrawing from the pool.
It has three interesting functions:
- deposit: Which allows you to deposit some ETH in the pool.
- withdraw: Which withdraws all ETH deposited.
- flashLoan: Function that implements the lending logic.
Analyzing deposit()
1. function deposit() external payable {
2. balances[msg.sender] += msg.value;
3. }
Super short function. It just keeps a record of the money deposited by the one who sent the message. Due to being working with ETH the amount of deposited money comes in the msg.value.
Analyzing withdraw()
1. function withdraw() external {
2. uint256 amountToWithdraw = balances[msg.sender];
3. balances[msg.sender] = 0;
4. payable(msg.sender).sendValue(amountToWithdraw);
5. }
Another short function. It first gets the deposited balance of the msg.sender by querying the state of balances. Once that number is known, it updates the balance of the msg.sender with 0 (because it extracts all the money) and finally it sends that amount of ETH to the caller.
Analyzing flashLoan()
1. function flashLoan(uint256 amount) external {
2. uint256 balanceBefore = address(this).balance;
3. require(balanceBefore >= amount, "Not enough ETH in balance");
4.
5. IFlashLoanEtherReceiver(msg.sender).execute{value: amount}();
6.
7. require(address(this).balance >= balanceBefore, "Flash loan hasn't been paid back");
8. }
As usual, in the first two lines it ensures that it can actually borrow the asked amount (it has enough money). Line 7 checks that the flashloan has been repaid by checking the current balance and comparing it to one got before. The only interesting line here is line 5.
Line 5 wraps the msg.sender as a contract who implements the IFlashLoanEtherReceiver interface. This interface is depicted a bit earlier in the contract definition:
interface IFlashLoanEtherReceiver {
function execute() external payable;
}
As can be seen, it is super simple. It just "asks" that the Smart Contract implements a payable function called execute(). Spoiler: Our attacker contract will have to implement it.
Going back to the original contract, the 5th line makes a call to execute() function implemented in the Smart Contract which originally called the flashLoan() function. As part of this call, it sends the asked loan amount.
So, some conditions that we need to accomplish:
- We will need to craft a Smart Contract (as the flashLoan() function is calling a function from the msg.sender()).
- This SC will have to implement the function execute(). Furthermore, within this function we will need to do the nasty things.
- This SC will have to be the one that actually calls the flashLoan() function. This is because of condition 1.
Ok. Now.. what nasty thing should we do inside execute() in order to steal all the funds? In order to check if the loan was repaid, the Lender Pools compares the balances. Additionally, if you take a deeper look to deposit() you will realize that all the money sent here will end up in the Lender Pool's balance, BUT it will be stored under the "domain" of the msg.sender. So, what an attacker could do is to deposit the acquired loan to the pool again. This will cause the whole balance to remain the same (thus, accomplishing the condition of flashloan repay) but also will leave the msg.sender (in this case the attacker's SC) with the power to claim (withdraw) the funds.
Therefore, as a summary, this is the call flow:
Attacker Contract --flashLoan()--> Lender Pool --execute()--> Attacker Contract --deposit()--> Lender Pool
Attacker Contract --withdraw()--> Lender Pool --transfer()--> Attacker contract --transfer()--> Attacker's address
Final solution
1. // SPDX-License-Identifier: MIT
2.
3. pragma solidity ^0.8.0;
4. import "@openzeppelin/contracts/utils/Address.sol";
5. import "hardhat/console.sol";
6.
7. interface ISideEntranceLenderPool {
8. function deposit() external payable ;
9. function withdraw() external ;
10. function flashLoan(uint256 amount) external ;
11. }
12.
13. contract SideEntranceAttacker {
14.
15. function initAttack(address lender_pool_address, address attacker_address) external {
16. ISideEntranceLenderPool lender_pool = ISideEntranceLenderPool(lender_pool_address);
17. lender_pool.flashLoan(address(lender_pool).balance);
18. lender_pool.withdraw();
19. payable(attacker_address).transfer(address(this).balance);
20. }
21.
22. function execute() external payable {
23. ISideEntranceLenderPool(msg.sender).deposit{value:msg.value}();
24. }
25.
26. receive() external payable {}
27. }
The initAttack() is where everything starts. This time we can't use the constructor due to at the moment of the construction, the Smart Contract "doesn't exist" yet (is being constructed) and therefore the Lender Pool cannot perform a function call to it.
First (line 15) it wraps the Lender Pool object according to the ISideEntranceLenderPool interface just to make it easier to call the functions of this Smart Contract. Then, it call the flashLoan() function exactly with the amount of ether that the Lender Pool has (to get all its money). During the execution of this function, the execute() will be called due to the 5th line of the Lender Pool's code. When execute() is called, all the money received from the loan will be deposited again in the Lenders Pool. Once it finishes, it will go back to the Lender Pool's control and will check if the loan was repaid. Due to it compares balances, and the funds were deposited there, it will pass that check. Now, line 17th finished executing and it's time for line 18th. The Attacker Contract will withdraw() all the deposited Ether from the loan. Recall that in order to be able to receive money from transfers, our Smart Contract should implemented the payable receive(). If this function doesn't exist, we should implement a payable fallback(). If none of them exist, we may not be able to receive payments. Once line 18th finishes, we would have already stolen all the ethers from the Lender Pool. Our last step, just to pass the level, is to send all this money to the attacker address.
In order to solve it, I stored the previous Smart Contract inside an "attackers-contracts" directory within the "contracts" directory and completed the following line inside side-entrance.challenge.js:
it('Exploit', async function () {
const SideEntranceAttacker = await ethers.getContractFactory('SideEntranceAttacker', attacker);
this.attacker_contract = await SideEntranceAttacker.deploy();
await this.attacker_contract.initAttack(this.pool.address, attacker.address);
});