Side Entrance - Damn Vulnerable DeFi #04

Side Entrance - Damn Vulnerable DeFi #04

Analysis and PoC of Damn Vulnerable DeFi Level 04 - Side Entrance

Objectives

A lending pool allows users to deposit and withdraw ETH. It also offers flash loans for free. The pool has 1000 ETH in balance and we start with 1 ETH. Our objective is to drain the pool.

Smart Contract Analysis

SideEntranceLenderPool.sol

  • function deposit() allows users to deposit ETH into the pool which is added to their balances mapping.

  • function withdraw() allows users to withdraw their own balance that they have deposited. The balances mapping is checked for the amount to withdraw.

  • function flashLoan() is used to request a free flashloan.

It's all pretty straightforward, isn't it? So how do we drain the 1000 ETH from the pool?

The Exploit

This contract is vulnerable to the classic Re-entrancy bug. The issue is also on Line 35 require() validation. It is only making sure that the balance of the pool after calling the execute() is greater than the balanceBefore stored on Line 30.

On Line 33, the flow of the contract is in the attacker's control. What this means is that when we receive control inside our own execute() function, we can call the deposit() function to deposit the loaned amount back to the pool.
The pool will update its balance and the validation

address(this).balance >= balanceBefore

will return true because the pool only checks if its total balance is updated. It does not account that the ETH was deposited by the attacker using a different function, and therefore, the attacker will be able to call the withdraw() function to take out all the ETH from their balance.

Let's make a contract to exploit the whole thing.

  • The function exploit() calls the pool.flashLoan() to start the attack.

  • The flashLoan() function calls IFlashLoanEtherReceiver(msg.sender).execute{value: amount}(); which sends the control flow back to our contract's receive() function since our contract is msg.sender for this transaction, along with the loaned amount.

  • The function execute() calls pool.deposit() and deposits the loaned amount back into the pool, but also updates the balances mapping so that we can withdraw.

  • flashLoan()'s require() validation returns true, the flow is again returned to our contract's exploit() function and it calls pool.withdraw() to withdraw all the deposited ETH by our AttackerContract which is received by the receive() function that we have implemented.

  • It again calls attacker.transfer() to transfer all the ETH back into our deployer account, finishing the exploit.

Let's write the test case for this:

It just deploys our AttackerContract, connects to it via the attacker account, and calls exploit() function with the total ETH owned by the pool as the argument.

Run the script using yarn run side-entrance and the test case will pass.

Key Takeaways

  • This could have been easily prevented by using OpenZeppelin's Re-entrancy Guard, or the nonReentrant modifier on the deposit() function.

  • When you're handling balances inside multiple functions, make sure that all of them account for the ETH/tokens received via other functions.