Vulnerability class · VC-01
Reentrancy attacks explained: every variant, and why guards aren't enough
Single-function, cross-function, cross-contract and read-only reentrancy — with the code that breaks, the code that fixes it, and the cases where a nonReentrant modifier gives you false confidence.
What reentrancy actually is
Reentrancy is not a bug in a function. It is a bug in an ordering: your contract hands control to code it does not own while its own state still describes a world that is about to change.
Every external call is a yield. token.transfer(), msg.sender.call{value: x}(""), an ERC-721 safeTransferFrom, a callback to a strategy — each of these hands the EVM to another contract, which may do anything, including calling straight back into you. If your storage has not yet been updated to reflect the operation in progress, the re-entering call sees stale state and is granted the same permission again.
The mental model that catches this reliably: at every external call, ask what an attacker-controlled implementation on the other end would do if it called back into every public function you have. Not just the one you are in.
Single-function reentrancy
The classic. State updated after the transfer, so the balance check passes on every re-entry.
// VULNERABLE
mapping(address => uint256) public balanceOf;
function withdraw(uint256 amount) external {
require(balanceOf[msg.sender] >= amount, "insufficient");
(bool ok, ) = msg.sender.call{value: amount}(""); // yields control
require(ok, "transfer failed");
balanceOf[msg.sender] -= amount; // too late
}
An attacker contract with a receive() that calls withdraw() again drains the contract in a single transaction, bounded only by gas. The fix is checks-effects-interactions — validate, mutate, then call:
// FIXED
function withdraw(uint256 amount) external nonReentrant {
require(balanceOf[msg.sender] >= amount, "insufficient");
balanceOf[msg.sender] -= amount; // effects
(bool ok, ) = msg.sender.call{value: amount}(""); // interaction
require(ok, "transfer failed");
}
The nonReentrant modifier is defence in depth, not the fix. Order the operations correctly and the guard becomes a safety net rather than the only thing standing between you and insolvency.
Cross-function reentrancy
Here the re-entered function is a different one that reads the same state. A nonReentrant on withdraw() alone does not help, because the attacker never calls withdraw() twice.
// VULNERABLE: transfer() reads balanceOf, which withdraw() has not yet updated
function withdraw() external nonReentrant {
uint256 amount = balanceOf[msg.sender];
(bool ok, ) = msg.sender.call{value: amount}(""); // attacker re-enters transfer()
require(ok);
balanceOf[msg.sender] = 0;
}
function transfer(address to, uint256 amount) external { // no guard
require(balanceOf[msg.sender] >= amount);
balanceOf[msg.sender] -= amount;
balanceOf[to] += amount; // shares still credited
}
During the callback the attacker transfers their still-intact balance to a second address they control, then lets withdraw() finish and zero the original. They now hold both the ETH and the balance.
Cross-contract and read-only reentrancy
Cross-contract reentrancy is the same shape spread across two deployments: contract A calls out, the attacker re-enters contract B, and B reads a value from A that is mid-update. Guards on A do nothing for B.
Read-only reentrancy is the variant that surprises even experienced teams, because nothing is written at all. A view function — a share price, a pool balance, an exchange rate — is queried during a callback, while the contract is in an intermediate state. The function reverts nothing, modifies nothing, and returns a value that is simply wrong.
// Pool.sol — mid-removal, reserves are updated but LP supply is not
function removeLiquidity(uint256 lp) external nonReentrant {
uint256 amount0 = ...; uint256 amount1 = ...;
_updateReserves(); // reserves down
_safeTransfer(token0, msg.sender, amount0); // callback fires HERE
_safeTransfer(token1, msg.sender, amount1);
_burn(msg.sender, lp); // supply not yet reduced
}
// Consumer.sol — a completely different protocol
function collateralValue(address user) public view returns (uint256) {
return pool.getVirtualPrice() * lpBalance[user]; // reads the wrong price
}
The consumer has no reentrancy guard because it never writes anything. It does not need one to be exploited — it needs to not read a price from a contract that is mid-operation.
Mitigations: expose a re-entrancy status flag that integrators can check (Curve's remove_liquidity guard pattern), or make the read function revert while the lock is held. If you integrate with someone else's pool, check whether their view functions are safe during a callback — and assume they are not until you have read the code.
Token hooks: reentrancy you did not write
ERC-777 and ERC-1363 tokens call a hook on the recipient during transfer. ERC-721's safeTransferFrom calls onERC721Received. ERC-1155 does the same. Every one of these is an external call that your code did not visibly make.
This is how contracts that contain no .call{value:} anywhere still get drained. A deposit function that does token.transferFrom(msg.sender, address(this), amount) before updating state is reentrant if the token has a sender hook — and if the token is user-supplied, the attacker chooses the token.
_safeMint before incrementing the minted counter lets a contract minter re-enter and exceed the per-wallet cap. This exact bug has shipped in production collections repeatedly.How to actually test for it
Reentrancy is one of the few classes where a property test is straightforward to write, because the invariant is simple: the contract's balance must always cover what it owes.
// Foundry invariant — catches every reentrancy variant that steals value
function invariant_solvent() public view {
assertGe(address(vault).balance, vault.totalOwed());
}
// Plus an explicit adversary in the handler set:
contract ReentrantActor {
receive() external payable {
if (depth++ < 2) try vault.withdraw(shares) {} catch {}
}
}
Add a malicious receiver to your invariant handlers and let the fuzzer sequence calls around it. The value of this over a hand-written exploit test is that it finds the cross-function paths you did not think to write a test for.
The review checklist we use
- List every external call, including token transfers, hooks and callbacks. Assume each one yields control to an attacker.
- For each call, ask: what state is stale at this moment, and which public functions read it?
- Apply checks-effects-interactions first. Treat
nonReentrantas a second layer, never the primary control. - Guard the whole set of functions sharing state, not just the one making the call.
- Check
viewfunctions used by integrators for read-only reentrancy exposure. - If tokens are user-supplied or non-standard, assume transfer hooks exist.
- Add a reentrant actor to the invariant handler set and assert solvency.
Reentrancy has been public knowledge since 2016 and it still appears in audits every month — because the modern variants do not look like the 2016 one. The pattern to internalise is not “update state before transferring ETH”. It is “every external call is a yield, and control comes back into a contract whose state you have not finished updating.”
All code in this article is illustrative — written to demonstrate a pattern, not taken from any client engagement. If you want this class of issue hunted in your own codebase, that is what a Smart Contract Security Audit is for.