Damn Vulnerable DeFi Unstoppable Solution and Explain
Welcome to the first challenge of Damn Vulnerable DeFi, called Unstoppable.
This post is for those who have given up or want to check their solution.
Understanding the Challenge
First, let's review the challenge. You can find the details here.
The challenge involves a tokenized vault with a million DVT tokens deposited. It offers free flash loans until a grace period ends. To pass the challenge, you need to make the vault stop offering flash loans. You start with 10 DVT tokens in your balance.
Diving into the Code
// 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 {}
}
ReceiverUnstoppable.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol";
import "solmate/src/auth/Owned.sol";
import { UnstoppableVault, ERC20 } from "../unstoppable/UnstoppableVault.sol";
/**
* @title ReceiverUnstoppable
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
*/
contract ReceiverUnstoppable is Owned, IERC3156FlashBorrower {
UnstoppableVault private immutable pool;
error UnexpectedFlashLoan();
constructor(address poolAddress) Owned(msg.sender) {
pool = UnstoppableVault(poolAddress);
}
function onFlashLoan(
address initiator,
address token,
uint256 amount,
uint256 fee,
bytes calldata
) external returns (bytes32) {
if (initiator != address(this) || msg.sender != address(pool) || token != address(pool.asset()) || fee != 0)
revert UnexpectedFlashLoan();
ERC20(token).approve(address(pool), amount);
return keccak256("IERC3156FlashBorrower.onFlashLoan");
}
function executeFlashLoan(uint256 amount) external onlyOwner {
address asset = address(pool.asset());
pool.flashLoan(
this,
asset,
amount,
bytes("")
);
}
}
The UnstoppableVault contract and ReceiverUnstoppable contract are at the heart of this challenge. The test involves calling executeFlashLoan
from the ReceiverUnstoppable contract to initiate a flash loan. Our goal is to figure out how to halt this contract.
Attack Strategy
Let's start with the function that gets called first:
function executeFlashLoan(uint256 amount) external onlyOwner {
address asset = address(pool.asset());
pool.flashLoan(
this,
asset,
amount,
bytes("")
);
}
This function doesn't seem vulnerable as it's just calling flashLoan
with the pool's asset and is protected by the onlyOwner
modifier.
Next, let's look at the flashLoan
function in 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;
}
There are several points where a revert could happen. Let's focus on the following condition
if (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance();
The balanceBefore
variable represents the total assets in the vault, and totalSupply
is the total supply of tokens. The convertToShares
function calculates how many shares the total supply can be exchanged for in the vault. So, the condition for revert is:
if (shares that can be exchanged for the token amount != vault's total asset amount) revert
As a hacker, you already have 10 DVT tokens, and the logic of the UnstoppableVault contract is functioning correctly. Therefore, simply increasing the contract's holdings will break this logic.
This is because you're supposed to use the deposit
function to deposit into the vault. By directly transferring tokens to the UnstoppableVault contract, you skip the calculation inside the contract after the deposit. This results in a mismatch:
Metric | Before Transfer | After Transfer |
---|---|---|
Shares that can be exchanged | 1000000000000000000000000 | 1000000000000000000000000 |
Vault's total asset amount | 999990000099999000009999 | 1000010000000000000000000 |
By directly sending tokens to the vault without using the deposit
function, you can ensure a revert will occur.
Solution
it('Execution', async function () {
/** CODE YOUR SOLUTION HERE */
await token.connect(player).transfer(vault.address, INITIAL_PLAYER_TOKEN_BALANCE)
});
And there you have it! Surprisingly, it only takes one line of code to stop the contract, making this a very straightforward challenge.
If you couldn't figure it out, it's a good idea to study in detail why this happens! The concepts of ERC20 and ERC4626 were introduced in this challenge, so a bit of knowledge about these two is necessary.
Now that you've come closer to being a hacker, let's move on to the next challenge!