Reentrancy
A reentrancy bug lets an attacker call back into your contract before the first call has finished updating state. Because the contract's bookkeeping is stale at the moment of re-entry, the attacker can withdraw the same balance repeatedly in a single transaction.
Vulnerable code
mapping(address => uint256) public balance;
function withdraw(uint256 amt) external {
require(balance[msg.sender] >= amt, "insufficient");
(bool ok, ) = msg.sender.call{value: amt}("");
require(ok, "transfer failed");
balance[msg.sender] -= amt; // state written AFTER the call
}
Why it breaks
The external call on the third line hands control to msg.sender. If that address is a contract, its receive() function runs immediately, and balance[msg.sender] has not been decremented yet. The attacker's receive() simply calls withdraw() again. The require check passes because the balance is unchanged, so another transfer goes out. This repeats until the contract is empty or gas runs out.
Watch the attack run
The same bug as a stepped sequence. Play it, or walk through with the arrow keys.
The fix
mapping(address => uint256) public balance;
function withdraw(uint256 amt) external {
require(balance[msg.sender] >= amt, "insufficient");
balance[msg.sender] -= amt; // effects BEFORE interaction
(bool ok, ) = msg.sender.call{value: amt}("");
require(ok, "transfer failed");
}
The invariant
The fix above is what an audit gives you. This is what monitoring gives you: the thing that should always be true once the contract is live, stated precisely enough to check every block.
Internal accounting is settled before control leaves the contract, and no balance is read twice inside one transaction.
invariant reentrancy_depth(tx) == 1 for every state-changing entry point invariant balance_written_before_external_call == true
How to test for it
A finding you cannot reproduce is an opinion. Write the test before you write the fix, watch it fail, then make it pass.
contract Attacker {
Vault vault;
constructor(Vault v) { vault = v; }
receive() external payable {
if (address(vault).balance >= 1 ether) {
vault.withdraw(1 ether);
}
}
}
// In your Foundry test:
function testReentrancy() public {
assertEq(address(vault).balance, 10 ether);
attacker.attack{value: 1 ether}();
assertEq(address(vault).balance, 10 ether); // fails if vulnerable
}
Where it has caused real losses
The DAO hack in 2016 drained roughly 3.6 million ETH through a reentrancy bug and led directly to the Ethereum/Ethereum Classic chain split. Reentrancy remains one of the most frequently exploited classes a decade later, because the pattern reappears every time a developer sends value before updating state.
What we check during review
Checks-Effects-Interactions is the rule: validate first, write state second, call out third. A nonReentrant modifier from OpenZeppelin's ReentrancyGuard is a good belt-and-braces addition, but ordering your code correctly is the actual fix. Guards can be bypassed through cross-function and cross-contract reentrancy if the underlying ordering is wrong.
Watch particularly for read-only reentrancy, where an attacker re-enters a view function during a callback and reads an inconsistent intermediate state. That variant bypasses most guards entirely and has caused several eight-figure losses in lending protocols.
Want us to check your contract for this?
A free monitoring assessment covers one contract up to 200 lines, manually reviewed, findings back within 72 hours. This class is on the checklist for every review we run.
Related vulnerabilities
Found this useful?
We publish these because the alternative, asking you to trust us, is worth less. Send a contract and we’ll apply the same thinking to your code.
- Reply written by the auditor who read your code
- No sales sequence, no drip campaign, no retargeting
- We’ll tell you if you don’t need a paid audit yet
- Report published only with your written permission