A reentrancy attack happens when a contract makes an external call and the callee uses that moment of control to call back into the original contract before it has finished updating its own state. The second call sees stale data and does something the developer assumed could not happen — most often, withdrawing the same funds twice.
It is one of the recurring classes we review for in every Solidity smart contract development engagement, and it is covered briefly in our overview of building production smart contracts. This note goes deeper: how the attack works, the variants that are easy to miss, and the defenses we layer together.
Why reentrancy is possible
In Solidity, when your contract sends Ether or calls another contract, execution moves to that other code. If the recipient is a contract, its receive or fallback function runs, and it can call any public function on your contract. Your function has not finished; it is paused mid-way. Anything you have not yet written to storage is still the old value.
The best-known example is the 2016 attack on The DAO, which exploited this pattern and led to the Ethereum hard fork. The language and tooling have improved since, but the underlying behavior of external calls has not changed.
A vulnerable contract
A minimal vault that lets users deposit and withdraw Ether:
contract VulnerableVault {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "nothing to withdraw");
(bool ok, ) = msg.sender.call{value: amount}(""); // interaction
require(ok, "transfer failed");
balances[msg.sender] = 0; // effect, too late
}
}
The bug is the order. The contract sends the Ether first and zeroes the balance afterwards. An attacker deploys a contract whose receive function calls withdraw() again. Each time the vault sends Ether, control returns to the attacker, the vault's record still shows the full balance, and the second withdraw() succeeds. This repeats until the vault is empty or the attacker stops.
The attacker needs only an ordinary deposit and a contract with a few lines of code. Nothing about the exploit depends on obscure behavior.
The variants that are easy to miss
Single-function reentrancy
The example above: the attacker re-enters the same function that made the external call.
Cross-function reentrancy
The callback enters a different function that reads the same stale state. A withdraw() that is guarded in isolation can still be exploited through a transfer() function that uses the same balance mapping before it has been updated.
Cross-contract reentrancy
Two contracts share state or assumptions. The callback enters the second contract while the first is in an inconsistent state. Systems built from several cooperating contracts are especially exposed, because no single contract's reentrancy guard covers the whole system.
Read-only reentrancy
The attacker does not change your contract's state at all. During a callback, they call a view function, in your contract or a protocol that depends on it, that returns a value computed from mid-update state, such as a price or share ratio. Another protocol that trusts that read can then be exploited. A nonReentrant modifier on state-changing functions does not stop this, because view functions are usually not guarded.
Token-hook reentrancy
External calls are not only call{value: ...}. Token standards with callbacks open the same door: ERC-777 hooks, and the receiver checks in ERC-721 and ERC-1155 safeTransferFrom and safeMint, all hand control to the recipient contract. Treat any function that triggers such a callback as an external call.
How to prevent it
No single measure covers every variant, so we apply several together.
1. Checks-effects-interactions
Order every function the same way: validate inputs and permissions (checks), update your own state (effects), and only then call out (interactions). Applied to the vault:
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "nothing to withdraw");
balances[msg.sender] = 0; // effect first
(bool ok, ) = msg.sender.call{value: amount}(""); // interaction last
require(ok, "transfer failed");
}
Now a re-entrant call sees a zero balance and reverts. This is the foundation, and it is free: no extra gas and no extra dependencies.
2. A reentrancy guard on value-moving functions
A mutex modifier such as OpenZeppelin's nonReentrant reverts if a guarded function is called again while another guarded function is still executing. It is a strong backstop for mistakes in ordering, and it covers cross-function reentrancy when the relevant functions share a guard. Newer OpenZeppelin releases also include a variant based on transient storage, which is cheaper where the target chain supports it. Check the current library documentation for the exact contract name and the minimum Solidity version.
3. Pull payments over push payments
Instead of sending funds to a recipient inside the main logic, record what they are owed and let them withdraw it in a separate call. This isolates the external call from the rest of your state changes and removes a whole category of problems, including a malicious recipient reverting to block everyone else.
4. Do not rely on gas limits
Older advice suggested using transfer or send, which forward a fixed small amount of gas, to prevent callbacks. Gas costs change between network upgrades, so this is not a dependable defense. Use call with the return value checked, together with the patterns above.
5. Treat view functions as part of the attack surface
If other protocols read a value from your contract, consider whether that value can be observed in an inconsistent state during a callback. If it can, guard the read as well, or document that integrators must not trust it mid-call. When you read a price or ratio from another protocol, assume it can be transiently wrong and sanity-check it.
6. Minimize external calls and untrusted recipients
Every external call is a place control leaves your contract. Keep them few, put them last, and be explicit about which addresses can receive them. Where you integrate token standards with callbacks, decide deliberately whether you need them.
How to test for reentrancy
A contract that looks correct in review can still be exploitable, so we test it from the attacker's side:
- Write an attacker contract. A short contract whose
receivefunction calls back into your function, deployed in the test. If the vault is safe, the attack reverts or recovers nothing; if not, the test shows the drain. - State an invariant. For a vault, the Ether held must always be at least the sum of recorded balances. Stateful invariant testing, which runs random sequences of calls against your contracts, will often discover a re-entrant path you did not think of. See Foundry vs Hardhat for how the two main frameworks support this.
- Cover the callbacks. Include receivers that implement ERC-721 and ERC-1155 hooks in your tests, not just plain Ether recipients.
- Run static analysis. Slither includes reentrancy detectors and belongs in CI. It produces false positives and misses subtle variants, so treat it as a first filter, not a verdict.
Review checklist
- Does every function that calls out finish its state updates first?
- Which functions share state with it, and do they share a guard?
- Is any call to a token or contract with callbacks hidden behind a helper or a
safe*function? - Can a view function return a misleading value during an external call?
- Does a test exist with a hostile recipient contract?
- Does the contract hold enough value to justify an independent audit?
Reentrancy defenses are one part of a wider process. A guard modifier does not make a contract safe on its own, and neither does an audit; both work best on top of a clear specification and thorough tests.
Common questions
What is a reentrancy attack in Solidity?
A reentrancy attack occurs when a contract makes an external call, and the called contract calls back into the original contract before its state has been updated. Because the original function has not finished, the second call sees stale data, which can allow funds to be withdrawn more than once.
How do you prevent reentrancy in Solidity?
Follow the checks-effects-interactions order so state is updated before any external call, add a reentrancy guard such as OpenZeppelin's nonReentrant to functions that move value, prefer pull payments over pushing funds, and test with a hostile attacker contract.
Does Solidity 0.8 prevent reentrancy?
No. Solidity 0.8 added automatic overflow checks, but it does not change how external calls transfer control. Reentrancy has to be prevented by how the contract is structured.
What is read-only reentrancy?
Read-only reentrancy is when an attacker re-enters during a callback only to call a view function that returns a value based on mid-update state, such as a price or share ratio, and then uses that misleading value to exploit another protocol that trusts it. Guards on state-changing functions alone do not prevent it.
