Episode 1: Unstoppable (Damn DeFi)

Pavel EginPavel Egin
6 min read

Welcome to the first post in the Damn DeFi series — a hands-on journey through DeFi security challenges inspired by Damn Vulnerable DeFi. If you're passionate about Web3 security, Solidity, and breaking things for good reasons — you're in the right place.

👋 Who am I?

I'm kode-n-rolla, an offensive security researcher diving deep into the world of smart contract security. Through this series, I’ll be sharing practical walkthroughs of vulnerable contracts, exploits, and techniques for testing DeFi systems.

Whether you’re an aspiring auditor or a curious hacker, I’ll guide you through each challenge step-by-step — from understanding the code to developing working exploits and verifying success criteria.

🔨 What you'll need

To follow along, make sure you have:

Challenge: Unstoppable

This is the first challenge in the Damn DeFi set — and it’s a great warm-up to get into the mindset of a smart contract auditor.

We’ll explore a seemingly solid ERC4626 vault contract that offers flash loans, as well as a monitoring contract that reacts if something goes wrong. Our goal is to break the flash loan functionality by introducing a state inconsistency that will prevent further usage of the feature — triggering a pause in the vault and returning control to the deployer.

In the next section, we’ll break down the logic, identify the vulnerability, and develop a working solution using Foundry.


Stay tuned — the vault may be called Unstoppable, but we’re about to prove otherwise.

Challenge Overview: What’s Unstoppable?

The Unstoppable Vault is a tokenized vault (ERC4626) holding 1,000,000 DVT tokens. It provides free flash loans during an initial 30-day grace period (GRACE_PERIOD), aiming to test functionality before going fully permissionless.

To monitor this behavior, a dedicated UnstoppableMonitor contract can programmatically test the flash loan system. If something breaks (e.g., flash loan reverts), it will:

  • 🚨 Pause the vault

  • 🔐 Transfer ownership to the original deployer

The vault is considered "unstoppable" because, in theory, it should always allow flash loans as long as the system is balanced.

But... there’s a catch.


🎯 Objective: How Do You Break It?

You start as the player with 10 DVT tokens and no ownership or control. Your goal is to:

Make the vault fail its flash loan check.

If you succeed, the monitor contract will trigger emergency pause, halting the vault and returning ownership to the deployer.

✅ To mark the challenge as solved:

  • Flash loan must revert.

  • Vault must be paused.

  • Vault must transfer ownership back.

Why the Vault Can Be Broken

Let’s understand why the vault’s logic is vulnerable.

The core of the problem lies in this check inside the flashLoan() function:

uint256 balanceBefore = totalAssets();
if (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance();

This line assumes that the vault’s balance (totalAssets()) is always in sync with the total supply of tokens (totalSupply()). But that’s a dangerous assumption.

🕳️ Here’s the trap:
The totalAssets() function just checks how many DVT tokens the contract holds.
The totalSupply() tracks how many shares were minted through proper deposits.

Now imagine this:

  • Someone sends DVT tokens directly to the vault without calling deposit().

  • The vault’s totalAssets() increases.

  • But totalSupply() stays the same.

Boom — desynchronization 💥


🧠 Visualization: How the Vault Breaks

[MONITOR]
   │
   ▼
checkFlashLoan()
   │
   ▼
vault.flashLoan(...)  ⟶ checks:
                        - is msg.sender == monitor? ✅
                        - totalAssets == totalSupply? ❌ ← We break this
                             |
                             └── revert InvalidBalance
                                      │
                                      ▼
                              monitor.pauseVault()
                              - vault.paused = true
                              - vault.owner = deployer

💥 BOOM — The vault is no longer unstoppable.

💡 The key idea is to desynchronize totalAssets() and totalSupply() by directly transferring DVT tokens into the vault (not via deposit()), which increases totalAssets but not totalSupply.

Test That Breaks the Vault

To confirm the vulnerability, here’s the test written in Foundry:

function test_unstoppable() public checkSolvedByPlayer {
    token.transfer(address(vault), INITIAL_PLAYER_TOKEN_BALANCE);

    vm.expectRevert();
    vault.flashLoan(monitorContract, address(token), 1e18, "");
}

Let’s break it down step by step 🧩:

  1. token.transfer(address(vault), INITIAL_PLAYER_TOKEN_BALANCE);
    → We send tokens directly to the vault without using the deposit() function.
    → This breaks the synchronization between totalAssets() and totalSupply().

  2. vm.expectRevert();
    → We expect the next call to fail (revert). It’s a feature of Foundry tests to check if an error is thrown.

  3. vault.flashLoan(...)
    → This triggers the flash loan. But remember:
    totalAssets() != totalSupply() → 💥 FLASHLOAN_DISABLED is reverted!

🧠 What Went Wrong?

This is a great example of how unexpected token flows can break carefully crafted invariants in smart contracts.

In our case, the UnstoppableVault assumes that all token deposits happen only through its own deposit() function — where totalSupply() and totalAssets() remain in sync.

But in reality, ERC20 tokens don’t prevent direct transfers. That means any user can bypass the contract's logic by sending tokens directly to the vault’s address.

This subtle assumption leads to a broken invariant:

if (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance();

Once this condition fails, the flash loan feature becomes unusable — and the monitoring contract pauses the vault. Game over 🕹️


🚨 Real-World Impact

In a real DeFi protocol, this kind of bug could:

  • Permanently disable an essential feature, like flash loans or interest accrual

  • Lead to locked funds or loss of protocol utility

  • Trigger panic, loss of user trust, or even exploit if other invariants rely on this sync

It's not just a toy bug — it's a critical design flaw rooted in false assumptions about ERC20 behavior.


🔧 How to Fix It?

To prevent this issue, developers should:

  • Never rely on assumptions like totalAssets() == totalSupply() staying true

  • Instead, calculate totalAssets() using actual token balance:

      return token.balanceOf(address(this));
    
  • Or redesign the logic to not rely on that invariant at all

In short: always expect users to interact with your contract in unpredictable ways 🤯

🔍 Why This Matters for Auditors and Researchers

This type of issue perfectly illustrates the importance of smart contract audits — not just for spotting obvious bugs, but for challenging dangerous assumptions baked into the logic.

Even seemingly simple lines like:

if (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance();

...can lead to critical vulnerabilities if the protocol design doesn’t account for all possible token interactions.

As security researchers, our job is to think like attackers while staying on the defender’s side. Break things to make them stronger 🛡️


🙏 Thanks for Reading!

Thanks for following me through this challenge!
If you're learning smart contract security or just love breaking things for the greater good — stick around 💥

You can find me sharing more write-ups, tips, and experiments on:

Happy hacking — and see you in the next episode of the Damn DeFi series 🚀
Until then, keep questioning assumptions and pushing boundaries!

1
Subscribe to my newsletter

Read articles from Pavel Egin directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Pavel Egin
Pavel Egin