Track 7 · The Breach Lab · lesson 1

Reentrancy

20 min


Everything until now, you built. This track, you break.

Below is a vault holding about 100 ETH — 99 that other people deposited, plus the 1 ETH you are about to seed it with. You have one ETH to your name and one instruction.

Empty it.

The victim

Read it before you touch anything. The bug is in withdraw, and it is a single line in the wrong place.

function withdraw() public {
    uint256 bal = balances[msg.sender];
    require(bal > 0, "nothing to withdraw");

    (bool ok, ) = msg.sender.call{value: bal}("");   // sends ETH...
    require(ok, "send failed");

    balances[msg.sender] = 0;                          // ...then updates
}

The vault sends your ETH, and only afterwards sets your balance to zero.

Between those two lines, control is yours. When call sends ETH to your contract, your receive() function runs — and at that instant, the vault still thinks you have your full balance, because it has not reached the zeroing line.

So you call withdraw again. The check passes. It sends again.

Drain it

Write the attacker. attack() seeds the vault and pulls the first thread; receive() keeps pulling until the vault is empty.

Attacker.solAttacker
Loading editor…
Empty the vault.

VulnerableVault

? ETH

Nudge
Look at the order of operations in the vault's withdraw(). What runs between the ETH arriving and your balance being zeroed?
Show me the approach
Your receive() runs while balances[you] is still 1 ETH. If you call withdraw() again from there, the check still passes.
Show me the code
In attack(): `vault.deposit{value: 1 ether}(); vault.withdraw();`. In receive(): call `vault.withdraw()` again while `address(vault).balance >= 1 ether`.
Explain the exploit
The guard `address(vault).balance >= 1 ether` stops the recursion cleanly when the vault runs dry, rather than reverting on the final short withdrawal. This exact bug took $60M from The DAO in 2016 and forced the fork that created Ethereum Classic.

What you just did

You did not break the cryptography. You did not forge a signature or guess a key. You used a public function exactly as written, in an order the author did not expect.

That is what almost every real exploit is. Not broken math — broken assumptions. The author assumed withdraw would run start to finish before anything else happened. External calls violate that assumption, and you met exactly this warning in track 4: control genuinely leaves your contract on an external call.

In 2016 this precise bug drained $60 million from The DAO. The response split Ethereum into two chains — the one you use, and Ethereum Classic, whose holders refused to reverse it.

Check

What single change to the vault would have prevented this?

Choose one answer

Worth remembering

  • Reentrancy: a victim calls out to you before updating its own state, and you call back in.
  • The window is between the external call and the state update.
  • The exploit uses public functions exactly as written — the assumption is what breaks, not the code.
  • The fix is checks-effects-interactions: update state before calling out.
  • This bug drained $60M from The DAO and split Ethereum from Ethereum Classic.