Episode 1: Unstoppable (Damn DeFi)


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:
Blockchain Basics
Basic knowledge of Solidity and smart contract interactions
Foundry installed
A local clone of the Damn Vulnerable DeFi repo
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:
ThetotalAssets()
function just checks how many DVT tokens the contract holds.
ThetotalSupply()
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 🧩:
token.transfer(address(vault), INITIAL_PLAYER_TOKEN_BALANCE);
→ We send tokens directly to the vault without using thedeposit()
function.
→ This breaks the synchronization betweentotalAssets()
andtotalSupply()
.vm.expectRevert();
→ We expect the next call to fail (revert). It’s a feature of Foundry tests to check if an error is thrown.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 trueInstead, 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!
Subscribe to my newsletter
Read articles from Pavel Egin directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
