The vault contract holds ETH deposited by many honest users (the drifting cyan spheres) plus the attacker's own 1 ETH deposit. Its vulnerable withdraw() sends ETH out via an external call before it decrements the caller's internal ledger. The attacker's contract has a fallback() function that runs the instant it receives ETH — instead of returning quietly, it calls withdraw() again, and because the ledger hasn't been updated yet, the check still shows a full balance. Each re-entry stacks one level higher (the rising platforms), draining another chunk of the vault, until the vault runs dry or the attacker's transaction runs out of gas (the depth limit).
// vulnerable order — interaction before effect
function withdraw(uint amt) external {
require(balances[msg.sender] >= amt);
(bool ok,) = msg.sender.call{value: amt}(""); // ← reentry happens HERE
require(ok);
balances[msg.sender] -= amt; // ← too late, already re-entered
}
- Reentrancy guard — a mutex flag set before the external call and checked at the top of
withdraw(); a re-entrant call sees the flag and reverts immediately, so only the first, legitimate withdrawal succeeds.
- Checks-Effects-Interactions — reorders the function so the ledger is decremented before the external call is made; a re-entrant call then sees a balance that's already gone to zero and has nothing left to withdraw.
- Withdraw per call — how much ETH leaves the vault on every recursive hop.
- Attacker gas depth limit — real transactions revert once they run out of gas; this caps how many stack frames the recursion can reach before that happens.
Real-world relevance: this exact pattern drained roughly 3.6M ETH from The DAO in 2016 and remains one of the most common root causes of Web3 exploits — either defense alone is normally enough to stop it, which is why modern audits check for both.