A withdraw function must (1) send funds and (2) record the balance update. Order is everything:
// vulnerable β interactions before effects
function withdraw(amt) {
require(bal[msg.sender] >= amt);
msg.sender.call{value: amt}(""); // β© triggers attacker fallback
bal[msg.sender] -= amt; // updated too late
}
// safe β checks-effects-interactions
function withdraw(amt) {
require(bal[msg.sender] >= amt);
bal[msg.sender] -= amt; // β© updated first
msg.sender.call{value: amt}(""); // reentry now fails the check
}
Sending ETH to a contract address runs that contract's fallback function before the original call returns. A malicious fallback calls withdraw() again immediately. In the vulnerable version the balance record hasn't been decremented yet, so the check still passes β the call stack grows one level deeper on every reentry until the pool runs dry or the simulated gas limit is hit, draining far more than was ever deposited. In the safe version the balance is zeroed before any funds are sent, so the reentrant call's check fails instantly and the stack never grows past one legitimate withdrawal.