DamnVulnerableDeFi - 01_Unstoppable

Challenge Link to heading
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.
You are provided with the code for the flashloan providing vault:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "solmate/src/utils/FixedPointMathLib.sol";
import "solmate/src/utils/ReentrancyGuard.sol";
import { SafeTransferLib, ERC4626, ERC20 } from "solmate/src/mixins/ERC4626.sol";
import "solmate/src/auth/Owned.sol";
import { IERC3156FlashBorrower, IERC3156FlashLender } from "@openzeppelin/contracts/interfaces/IERC3156.sol";
/**
* @title UnstoppableVault
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
*/
contract UnstoppableVault is IERC3156FlashLender, ReentrancyGuard, Owned, ERC4626 {
using SafeTransferLib for ERC20;
using FixedPointMathLib for uint256;
uint256 public constant FEE_FACTOR = 0.05 ether;
uint64 public constant GRACE_PERIOD = 30 days;
uint64 public immutable end = uint64(block.timestamp) + GRACE_PERIOD;
address public feeRecipient;
error InvalidAmount(uint256 amount);
error InvalidBalance();
error CallbackFailed();
error UnsupportedCurrency();
event FeeRecipientUpdated(address indexed newFeeRecipient);
constructor(ERC20 _token, address _owner, address _feeRecipient)
ERC4626(_token, "Oh Damn Valuable Token", "oDVT")
Owned(_owner)
{
feeRecipient = _feeRecipient;
emit FeeRecipientUpdated(_feeRecipient);
}
/**
* @inheritdoc IERC3156FlashLender
*/
function maxFlashLoan(address _token) public view returns (uint256) {
if (address(asset) != _token)
return 0;
return totalAssets();
}
/**
* @inheritdoc IERC3156FlashLender
*/
function flashFee(address _token, uint256 _amount) public view returns (uint256 fee) {
if (address(asset) != _token)
revert UnsupportedCurrency();
if (block.timestamp < end && _amount < maxFlashLoan(_token)) {
return 0;
} else {
return _amount.mulWadUp(FEE_FACTOR);
}
}
function setFeeRecipient(address _feeRecipient) external onlyOwner {
if (_feeRecipient != address(this)) {
feeRecipient = _feeRecipient;
emit FeeRecipientUpdated(_feeRecipient);
}
}
/**
* @inheritdoc ERC4626
*/
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));
}
/**
* @inheritdoc IERC3156FlashLender
*/
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;
}
/**
* @inheritdoc ERC4626
*/
function beforeWithdraw(uint256 assets, uint256 shares) internal override nonReentrant {}
/**
* @inheritdoc ERC4626
*/
function afterDeposit(uint256 assets, uint256 shares) internal override nonReentrant {}
}
Analysis Link to heading
The goal is to get the flashLoan() function to revert when another user calls it. In total there are ways how we could get this to work. I analyzed each of them for possible attack paths:
amount == 0 Link to heading
The call reverts if the given amount is 0. This can in our testcase not happen as the user after us passes a non zero amount.
asset != _token Link to heading
The call would also revert if the passed token is not the asset that the vault handels, but in the testcase the right one is passed, so no exploit there :(
convertToShares(totalSupply) != totalAssets(); Link to heading
This checks for the amount of totaly issued shares in the vault being the same as its balance of the token it holds. This is the part that we’ll exploit in the next step.
safeTransfer(borrower) reverts && receiver.onFlashLoan() != keccak256(“IERC3156FlashBorrower.onFlashLoan”) && safeTransferFrom() reverts Link to heading
This should not happen as the safeTransfer & onFlashLoan & safeTransferFrom() is correctly implemented in the receiver that the other user will use.
safeTransfer(fee) reverts. Link to heading
THis could happen if we are able to change the fee receiver (not possible in this case)
Solution Link to heading
We can achieve this by triggering the revert in this code snippet:
uint256 balanceBefore = totalAssets();
if (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance(); // enforce ERC4626 requirement