Reentrancy attacks, explained by actually draining a contract
A PicoCTF challenge, ethers.js, and the one function-ordering mistake that breaks everything.
I'd read about reentrancy attacks before I ever tried one. I could recite the definition: a contract calls out to an external address before it finishes updating its own state, and that external address calls back in and does it again, and again, before the first call ever gets to finish. I understood the sentence. I did not understand the attack, not really, until I sat down with a PicoCTF challenge, a vulnerable contract, and ethers.js, and actually drained it myself.
That gap between reading a definition and watching a balance go to zero turned out to be the entire lesson.
The contract that looked fine
The vulnerable contract was small, maybe forty lines of Solidity, and on a first read it looked like every other simple bank contract you'd find in a tutorial. Deposit function, withdraw function, a mapping tracking who owned what. The withdraw function was the one that mattered:
function withdraw(uint256 amount) public {
require(balances[msg.sender] >= amount, "Insufficient balance");
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
balances[msg.sender] -= amount;
}
Read it top to bottom, quickly, and it seems reasonable. Check the balance. Send the money. Update the balance. That's the order a person would naturally write it in, because that's the order it happens in when you picture a human teller handing over cash and then updating a ledger.
The problem is that msg.sender.call{value: amount}("") is not a person. It's a handoff to another contract, and that other contract gets to run its own code the instant it receives the money, before this function ever reaches the line that subtracts the balance. The check happened. The money moved. The ledger has not been updated yet. There is a window, and in that window, the caller still has a full balance according to the mapping, even though it already received a payout.
That window is the entire attack.
Building the attacker
The exploit is a second contract, not a script. It needs a receive function, the piece of Solidity that runs automatically when it gets sent plain ETH, and that function is where the reentrancy actually happens:
contract Attacker {
Vulnerable public target;
uint256 public amount = 1 ether;
constructor(address _target) {
target = Vulnerable(_target);
}
function attack() external payable {
target.deposit{value: amount}();
target.withdraw(amount);
}
receive() external payable {
if (address(target).balance >= amount) {
target.withdraw(amount);
}
}
}
Here's the sequence that actually plays out once attack() fires. The attacker deposits one ether, a completely normal, legitimate deposit. Then it calls withdraw. The vulnerable contract checks the balance, sees enough, and sends the ether back to the attacker contract using call.
That call triggers the attacker's receive function immediately, mid-transaction, before the original withdraw call has reached its final line. And receive does not politely wait its turn. It checks whether the target still has funds, and if it does, it calls withdraw again, right then, from inside the payment it's currently receiving. That second withdraw runs the exact same check, sees the exact same unchanged balance (because the first call never got to the line that decrements it), and sends money again.
This keeps happening, call folding inside call inside call, until the vulnerable contract's balance actually runs out. Only then does the stack start unwinding, and only at the very end do all those queued-up balances[msg.sender] -= amount lines finally execute, against a balance number that by then has nothing to do with reality.
One deposit. Every last drop of the contract's ether.
Checks, effects, interactions: the fix is a reordering, not a rewrite
The fix does not add any real logic. It reorders three lines:
function withdraw(uint256 amount) public {
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
That's the whole checks-effects-interactions pattern. Check your conditions first. Update your own state second. Only then talk to the outside world. By the time the external call happens and control potentially gets handed to some other contract's code, the balance mapping already reflects the withdrawal. If the attacker's receive function calls back into withdraw again, the require at the top now fails immediately, because the balance was already reduced before any external call ever went out.
The pattern has a name and a clean explanation, but I don't think I actually believed it until I'd watched the broken version drain a contract and the reordered version refuse to. Reading "update state before external calls" as advice is one thing. Watching a require statement stop the exact same attack cold, using the exact same attacker contract, is what made it a rule I'd never forget instead of a line in a checklist.
Where ethers v5 quietly ate an afternoon
The Solidity side clicked fast. The part that actually cost me time was the exploit script, and specifically the fact that half the ethers.js documentation and examples floating around are written for v6, while the challenge environment was pinned to v5. The two APIs look similar enough to feel interchangeable and are different enough to break your script in ways that don't explain themselves.
The one that got me first was numeric types. In v5, anything involving ETH amounts comes back as a BigNumber object, not a native JavaScript number or bigint:
// ethers v5
const balance = await provider.getBalance(contractAddress);
console.log(balance.toString()); // BigNumber, not a plain number
const value = ethers.utils.parseEther("1.0");
In v6, BigNumber is gone entirely, replaced by JavaScript's native bigint, and the helper functions move too:
// ethers v6
const balance = await provider.getBalance(contractAddress);
console.log(balance.toString()); // native bigint now
const value = ethers.parseEther("1.0"); // utils namespace is gone
I'd copied a snippet using ethers.parseEther straight from a v6 example, dropped it into a v5 project, and gotten a plain TypeError: ethers.parseEther is not a function, with nothing in the message pointing at a version mismatch. It just looks like a typo until you go check which major version is actually installed.
Contract deployment had the same trap, one method that quietly disappeared between versions:
// ethers v5
const contract = await ContractFactory.deploy();
await contract.deployed();
// ethers v6
const contract = await ContractFactory.deploy();
await contract.waitForDeployment();
contract.deployed() simply does not exist in v6. Again, no helpful redirect, just a method-not-found error that reads exactly like every other typo you've ever made, except this one only shows up because two versions of a library disagree about what a deployed contract object is supposed to expose.
None of this is hard once you know to check package.json for the actual installed version before trusting any code sample you find. But that's exactly the point: it's not conceptually hard, it's just invisible until it isn't, and the error messages give you no hint that a version number is the actual root cause.
What I actually learned
The Solidity fix here is three lines moved above one call. The lesson underneath it is bigger than that: any function that both reads its own state and hands control to an address it doesn't own is making an assumption that the outside world will wait patiently for it to finish. Nothing forces that assumption to hold. An external call is a pause button that anything on the other end can use to run its own code, and if your state update is sitting on the wrong side of that pause, an attacker doesn't need to be clever. They just need to notice the gap and call back in.
I'd read the checks-effects-interactions pattern described as "best practice" a dozen times before this and treated it as one more line item on a security checklist. It stopped being a checklist item the moment I watched the exact same attacker contract fail against the exact same target, with nothing changed except the order of three lines. That's the difference experience makes over reading: the definition tells you the rule exists, but actually draining a contract, then watching one reorder shut the door, is what makes the rule feel obvious in hindsight instead of arbitrary.