Damn Vulnerable DeFi: Unstoppable — Halting a Vault with One Rogue Transfer
How I break a lending vault by sending a single rogue token transfer, throwing off its internal accounting and halting all future loans.
Introduction
🔗 Challenge: Unstoppable @ DamnVulnerableDeFi
📂 Source code: v3he/damn-vulnerable-defi/test/unstoppable
Understanding the Setup
Ok, so let’s look at what’s actually going on.
We’re given a tokenized vault that holds 1,000,000 DVT
tokens and offers free flash loans for a limited time during a “grace period.” Flash loans are short-term loans that must be borrowed and repaid within the same transaction — if you don’t pay it back, the transaction reverts entirely.
The core components involved in this challenge are:
🏦 UnstoppableVault.sol
The vault is an ERC4626-compliant contract that:
- Accepts deposits and tracks internal balances via shares (
totalSupply
) - Offers flash loans by calling
flashLoan()
with some accounting checks - Requires internal accounting (
convertToShares(totalSupply)
) to match the actual token balance (totalAssets()
) - Pays fees after the grace period ends
🔍 UnstoppableMonitor.sol
The monitor is a separate contract that:
- Calls
checkFlashLoan()
to test if a flash loan ofn DVT
tokens is still possible - If the flash loan fails, it:
- Emits an event (
FlashLoanStatus(false)
) - Pauses the vault
- Transfers vault ownership back to the deployer
- Emits an event (
This acts like a safety net — a way for developers to detect if the flash loan feature is misbehaving before the vault goes fully permissionless.
Solving Conditions
Let’s look at what the test uses to determine if the challenge is solved:
1
2
3
4
5
6
7
8
9
10
11
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");
}
To pass the challenge, we need two things to happen:
- The monitor’s call to
checkFlashLoan()
must fail, meaning flash loans are no longer possible. - The monitor must pause the vault and transfer ownership back to the deployer.
So, the entire exploit comes down to causing the vault’s flashLoan()
function to revert by corrupting its assumptions.
Finding the Break
Let’s take a closer look at how this whole thing is monitored.
The monitor calls this function to check whether the vault can still issue a flash loan:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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);
}
}
As we can see, the way to trigger the success conditions is by making flashLoan()
revert. That’s our only objective.
So now let’s look at the actual flashLoan()
function inside the vault:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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;
}
As we can see, the loan can fail for multiple reasons — the amount might be zero, the token might be unsupported, or the callback might return the wrong value.
But we don’t control the vault contract, and we can’t modify the monitor.
We’re just the player with 10 tokens — so our only power is how we interact with the vault.
The line that stands out is this one:
1
if (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance();
This is a sanity check: the vault expects its own internal accounting (the result of convertToShares(totalSupply)
) to match the actual token balance (totalAssets()
).
So the question becomes: Can we somehow desync those values from the outside?
Spoiler: Yes, We Can
But first, we need to understand how the vault is doing the counting — and where the mismatch comes from.
The vault relies on two separate things to track tokens:
- 🔸
totalAssets()
— this reflects the actual balance of tokens held by the vault (viatoken.balanceOf(address(this))
) - 🔸
totalSupply
— this is the internal accounting: how many shares the vault thinks are issued (only updated viadeposit()
ormint()
)
Normally, these stay in sync because deposits go through the vault’s functions, which update both the actual balance and the internal totalSupply
.
But here’s the catch:
Nothing stops us from calling token.transfer(vault, amount)
directly. If we do that, the actual balance increases — but the vault has no idea it happened. totalSupply
doesn’t change.
So if the vault later runs this check:
1
if (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance();
…it compares an outdated convertToShares()
value against the new balance — and the whole flash loan reverts.
Executing the Exploit
At this point, the only thing left to do is trigger the imbalance ourselves — and we can do it with a single line of code.
Just a direct transfer()
is enough to break the vault’s assumptions.
Here’s the entire code for the test case:
1
2
3
function test_unstoppable() public checkSolvedByPlayer {
token.transfer(address(vault), 1 ether); // desyncing in balance vs shares
}
That’s it.
We’re directly transferring 1 DVT
token to the vault from the player address, bypassing its deposit()
function.
This increases the vault’s actual balance (totalAssets()
), but its totalSupply
remains unchanged — because transfer()
doesn’t trigger any vault logic.
The next time the monitor calls checkFlashLoan()
, the vault fails its own internal check:
1
2
3
// convertToShares(totalSupply) == 1_000_000 ether
// balanceBefore == 1_000_001 ether
if (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance();
And just like that, the loan reverts → the monitor sees the failure → pauses the vault → transfers ownership → ✅ challenge solved.
Conclusion
This was definitely one of the “baby” challenges — the exploit path is short, the interaction is minimal, and there are no complex contract deployments or clever tricks.
But despite that, it still took me time.
Not because the solution is hard — but because I needed to understand what a flash loan actually is, how ERC4626 vaults manage internal accounting, and why totalSupply
doesn’t always match the actual token balance.
Hopefully in the next few challenges, I’ll spend less time getting unblocked by fundamentals and more time breaking things.
1
total_hours_wasted_here = 1.5 // +1h reading docs, exploring the vault, and realizing "oh... that's it?"
As promised and water clear: I literally spent an hour to write a one-line exploit 😅