Walk me through a re-entrancy attack, and how checks-effects-interactions prevents it
A reentrancy attack happens when a smart contract calls an untrusted address before updating its own state, letting the callee enter again and reuse stale balances. The checks-effects-interactions pattern closes that window by validating first, mutating state next and calling out last.
What the interviewer is scoring
- Whether you can name the exact line at which the contract becomes vulnerable, rather than describing the attack in general terms
- Does the candidate understand that an external call transfers control, so any address with code can run arbitrary logic mid-function
- That they order state updates before the call instead of reaching straight for a guard modifier
- Whether the answer extends past same-function re-entrancy to cross-function and view-function variants
- Whether gas forwarding is discussed as a fragile mitigation rather than a solution
Answer
Short answer
A reentrancy attack exploits an external call made before state is corrected. Fix the function by doing checks first, applying effects such as balance updates second, and performing interactions last; add a reentrancy guard for cross-function cases that ordering alone cannot cover.
Where the window opens
An external call in the EVM hands control to the callee. If the target address holds code, its own function body runs to completion before your next statement executes, and nothing stops it calling back into you while you are still mid-function. That is the whole mechanism: re-entrancy is not a bug in the EVM, it is the consequence of synchronous calls between mutually distrusting programs that share no scheduler.
The vulnerability appears when the state that authorises an action is still uncorrected at the moment control leaves. A withdrawal function that sends ether and only then zeroes the caller's balance is claiming, for the duration of that call, that the caller is still owed the money. A contract on the other end reads that claim and acts on it.
mapping(address => uint256) private balances;
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "nothing to withdraw");
// Control leaves here. balances[msg.sender] is still the full amount.
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "send failed");
balances[msg.sender] = 0; // reached only after the attacker is finished
}
The attacking contract implements receive(), and that function calls withdraw() again. The second invocation reads the same non-zero balance, passes the same require, and sends the same amount. The loop continues until the vault is drained or gas runs out, and only then does the stack unwind and set the balance to zero once.
sequenceDiagram
participant A as Attacker contract
participant V as Vault
A->>V: withdraw
V->>V: read balance, still full
V->>A: send ether
A->>V: withdraw again from receive
V->>V: read the same balance
V->>A: send ether again
V->>V: set balance to zero, onceThe interesting part of that trace is how little the attacker does. There is no forged signature and no arithmetic overflow; the vault simply answers the same question twice and gets the same answer both times.
Checks, effects, interactions
The ordering rule says every function does its validation first, then all of its own state mutation, and only then talks to anything outside itself. Applied here, the fix is a single moved line.
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "nothing to withdraw"); // checks
balances[msg.sender] = 0; // effects, before control leaves
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "send failed"); // interactions
}
A re-entrant call now reads a zero balance and reverts on the require, and because the outer call bubbles the revert through require(ok), the attacker cannot pocket a partial success either. Note what makes this work: the invariant "the sum of balances equals the contract's ether" is true at every point where control could be lost. That is the property you are really being asked about, and stating it that way is a stronger answer than reciting the acronym.
Why a mutex is still worth having
Ordering protects one function. It does not stop an attacker re-entering through a different function that reads the same state — a transfer that moves credit between accounts, or a rewards claim keyed on the same balance mapping. A non-re-entrant modifier that sets a shared flag on entry and clears it on exit covers the whole group, because the flag is not per-function.
Use both. The guard is a backstop for the case you did not think of, and ordering is what makes the contract correct rather than merely defended. A guard applied to some functions and not others is close to useless, so the decision to add one is a decision about a whole contract.
The variant that catches contracts holding no ether
Assume, wrongly, that re-entrancy is about sending value. It is about control transfer, and modern versions arrive through calls you did not think of as calls: an ERC-777 token hook that runs on transfer, an NFT onERC721Received callback, or an arbitrary swap router address a user supplies as a parameter. In each case a token transfer, which reads like a passive line, is an external call to code chosen by someone else.
The most awkward version is read-only re-entrancy. A view function cannot itself modify state, but a third contract may read it during the window in which your storage is inconsistent — a pool whose reserves have been updated but whose accounting has not, quoting a price that a lending market then uses as collateral valuation. Nothing in your contract is exploited; a contract that trusts you is. Defending it means treating your own view functions as part of the invariant, not as free reads outside it.
Gas forwarding is the other half of this. transfer and send forward a limited stipend, and for a while that was treated as a defence because 2300 gas is not enough to re-enter. It has stopped being reliable: the gas cost of common operations has been repriced by protocol upgrades more than once, so a recipient that works today can break tomorrow. The current guidance is to use call and get the ordering right, rather than to depend on the callee being too poor to attack you.
An external call is a scheduling boundary. Any state your contract needs to be true after the call must already be true before it.
© 2026 Preptima. Originally published at preptima.com.
Likely follow-ups
- Why is relying on the 2300 gas stipend of transfer no longer considered a safe defence?
- How would a cross-function re-entrancy bypass a guard that only protects withdraw?
- What is read-only re-entrancy, and why does it endanger a contract that never sends value?
- How does a pull-payment or withdrawal-queue design remove the problem structurally rather than defending against it?
Related questions
- Why does storage layout dominate the gas cost of a contract?mediumAlso on evm and solidity5 min
- How does a proxy make a deployed contract upgradeable, and what does that put at risk?hardAlso on solidity and smart-contract-security5 min
- A modal passed design and QA review, but keyboard users report they can tab out of it into the page behind, and once they do they cannot get back or close it. Diagnose it and tell me what a correct dialog does.hardSame kind of round: concept4 min
- Edit distance where insert costs 1, delete costs 2 and replace costs 3. Define the DP state and the recurrence, and tell me what changes from the classic version.mediumSame kind of round: coding4 min