Damn Vulnerable DeFi Challenge #1 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 Feb 08, 2023
@tinchoabbate recently released a new version of Damn Vulnerable DeFi, which introduces ERC implementations, gas optimizations, and assembly. It also welcomes more libraries. Solmate and Solady join OpenZeppelin Contracts in the wargame. I am very experienced with OpenZeppelin Contracts, but I am very new to Solmate and Solady.
Challenge #1 - Unstoppable
There’s a tokenized vault with a million DVT tokens deposited. It’s offering flash loans for free, until the grace period ends.
To pass the challenge, make the vault stop offering flash loans.
You start with 10 DVT tokens in balance.
Let’s take a look at the UnstoppableVault.sol
contract. More specifically, let’s focus on its flashLoan
function. It calls onFlashLoan
on any IERC3156FlashBorrower
that is passed as an argument. We can use this as an entry point and execute our own arbitrary code, and all we have to do is return keccak256("IERC3156FlashBorrower.onFlashLoan")
!
// contracts/unstoppable/UnstoppableVault.sol
function flashLoan(
IERC3156FlashBorrower receiver,
address _token,
uint256 amount,
bytes calldata data
) external returns (bool) {
if (amount == 0) revert InvalidAmount(0); // fail early
if (address(asset) != _token) revert UnsupportedCurrency(); // enforce ERC3156 requirement
uint256 balanceBefore = totalAssets();
if (convertToShares(totalSupply) != balanceBefore)
revert InvalidBalance(); // enforce ERC4626 requirement
uint256 fee = flashFee(_token, amount);
// transfer tokens out + execute callback on receiver
ERC20(_token).safeTransfer(address(receiver), amount);
// callback must return magic value, otherwise assume it failed
if (
receiver.onFlashLoan(
msg.sender,
address(asset),
amount,
fee,
data
) != keccak256("IERC3156FlashBorrower.onFlashLoan")
) revert CallbackFailed();
// pull amount + fee from receiver, then pay the fee to the recipient
ERC20(_token).safeTransferFrom(
address(receiver),
address(this),
amount + fee
);
ERC20(_token).safeTransfer(feeRecipient, fee);
return true;
}
If we can make the vault revert on every call to flashLoan
, it will be unable to offer any more flash loans. The InvalidAmount
error can always be avoided since amount
is specified by the caller. The UnsupportedCurrency
error is also avoidable for the same reason (asset
is an immutable variable set as DVT
in the constructor). The InvalidBalance
error, however, stands out. Its guard clause uses the return value of the totalAssets
function:
// contracts/unstoppable/UnstoppableVault.sol
function totalAssets() public view override returns (uint256) {
assembly {
// better safe than sorry
if eq(sload(0), 2) {
mstore(0x00, 0xed3ba6a6)
revert(0x1c, 0x04)
}
}
return asset.balanceOf(address(this));
}
The function starts with what looks like a reentrancy guard. If there is no reentrancy, it returns the vault’s DVT
balance. flashLoan
then compares that to the total supply of ERC4626
shares with the help of the convertToShares
function from the ERC4626
contract:
// node_modules/solmate/src/mixins/ERC4626.sol
function convertToShares(
uint256 assets
) public view virtual returns (uint256) {
uint256 supply = totalSupply; // Saves an extra SLOAD if totalSupply is non-zero.
return
supply == 0
? assets
: assets.mulDivDown(supply, totalAssets());
}
This function uses totalAssets
to calculate the amount of shares for a given amount of assets
. This is totally fine, since the vault’s DVT
balance and the total supply of shares are perfectly balanced, as all things should be. It remains in balance because shares are minted every time the deposit
function (from the ERC4626
contract) is called. However, a regular ERC20
token, much like ETH
, can be transferred to any address, whether they want to accept it or not.
We can destroy the vault’s delicate balance by simply transferring the DVT
tokens we have to the vault! In fact, all we need is a single unit of DVT
, and we can keep the rest for ourselves!
// test/unstoppable/unstoppable.challenge.js
it('Execution', async function () {
await token.connect(player).transfer(vault.address, 1)
})
Now the InvalidBalance
error’s condition is met, causing flashLoan
to revert when it is called. In other words, the vault is now unable to offer flash loans.