Reentrancy in Solidity smart contracts
Updated October 19, 2026
What reentrancy is
Reentrancy happens when a contract hands control to another contract before it finishes its own work, and that other contract calls back in. Any call to another contract, and any transfer of Ether, hands over control this way (Solidity docs).
If the first contract has not yet updated its state, the call back in sees the old state. The classic case is a withdrawal: the contract sends funds before setting the balance to zero, so the attacker can withdraw the same balance again and again (ethereum.org).
Reentrancy is SWC-107 in the SWC Registry and SC08 in the OWASP Smart Contract Top 10 for 2026 (SC05 in the 2025 list).
Types of reentrancy
- Single-function: the attacker re-enters the same function, as in the example below.
- Cross-function: the attacker re-enters a different function that reads the same state. Slither reports state variables that "can be used in cross function reentrancies" (Slither detectors).
- Cross-contract: a called contract changes the state of another contract you depend on. The Solidity docs warn that reentrancy is not only about Ether transfers but any call, and that multi-contract situations count (Solidity docs).
- Read-only: no state-changing function is re-entered. Instead, another protocol calls a
viewfunction during the callback and reads stale state (OWASP SCWE-137). - Token callbacks: ERC-721 and ERC-1155 safe transfers call a hook on the receiver, which can re-enter the sender (OWASP SCWE-138). The Solv Protocol hack below used this path.
A vulnerable contract
VulnerableVault sends Ether before it sets the caller's balance to zero.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
interface IVault {
function deposit() external payable;
function withdraw() external;
}
// THIS CONTRACT CONTAINS A BUG. DO NOT USE.
contract VulnerableVault is IVault {
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");
// Interaction before effect: control passes to msg.sender here.
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
balances[msg.sender] = 0; // too late
}
} The attack
The attacker deposits 1 ETH and calls withdraw(). Each time the vault sends Ether, the attacker's receive() function runs and calls withdraw() again. The vault still records a 1 ETH balance, so it pays again, until it is empty.
contract Attacker {
IVault public immutable vault;
constructor(IVault _vault) {
vault = _vault;
}
function attack() external payable {
vault.deposit{value: msg.value}();
vault.withdraw();
}
// Runs every time the vault sends ETH. Re-enters withdraw()
// while the vault still records the original balance.
receive() external payable {
if (address(vault).balance >= msg.value) {
vault.withdraw();
}
}
} The fix: checks-effects-interactions and a reentrancy guard
Two standard fixes, used together here:
- Checks-effects-interactions. Run the checks, then write all state changes, and only then call other contracts (Solidity docs).
- A reentrancy lock. OpenZeppelin's
ReentrancyGuardadds anonReentrantmodifier that rejects nested calls to the functions it protects (OpenZeppelin docs). OpenZeppelin also shipsReentrancyGuardTransient, which keeps the lock in transient storage (EIP-1153). The SWC Registry lists both fixes (SWC-107).
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract FixedVault is IVault, ReentrancyGuard {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender]; // checks
require(amount > 0, "nothing to withdraw");
balances[msg.sender] = 0; // effects
(bool ok, ) = msg.sender.call{value: amount}(""); // interactions
require(ok, "transfer failed");
}
} A lock is not a full answer by itself. In the Curve hack below, the guard existed in the source code, but a compiler bug made it fail.
How to test for reentrancy
Write an attacker test
A test with an attacking contract proves whether the bug is real. This Foundry test drains VulnerableVault and shows that FixedVault reverts the attack:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {VulnerableVault, FixedVault, Attacker, IVault} from "../src/Vaults.sol";
contract ReentrancyTest is Test {
address alice = makeAddr("alice");
function _fund(IVault vault) internal {
vm.deal(alice, 10 ether);
vm.prank(alice);
vault.deposit{value: 10 ether}();
}
function test_attackerDrainsVulnerableVault() public {
VulnerableVault vault = new VulnerableVault();
_fund(vault);
Attacker attacker = new Attacker(vault);
attacker.attack{value: 1 ether}();
assertEq(address(vault).balance, 0); // alice's 10 ETH is gone
assertEq(address(attacker).balance, 11 ether); // 1 ETH in, 11 ETH out
}
function test_fixedVaultBlocksReentry() public {
FixedVault vault = new FixedVault();
_fund(vault);
Attacker attacker = new Attacker(vault);
vm.expectRevert(bytes("transfer failed"));
attacker.attack{value: 1 ether}();
assertEq(address(vault).balance, 10 ether); // alice's funds stay
}
} We ran both tests with Foundry 1.8.5, solc 0.8.37 and OpenZeppelin Contracts 5.7.0. Both pass.
Run a static analyzer
Slither has four reentrancy detectors: reentrancy-eth, reentrancy-no-eth, reentrancy-benign and reentrancy-events (Slither detectors). On the code above, Slither 0.11.6 flags VulnerableVault.withdraw() with reentrancy-eth and reports nothing for FixedVault.
slither src/Vaults.sol --detect reentrancy-eth,reentrancy-no-eth Review every external call by hand
Static analyzers match patterns. They miss reentrancy that runs through token hooks, other contracts or view functions read by other protocols. For each external call, ask:
- Is any state written after this call?
- Can the callee be a contract the attacker controls, including through
safeTransferFromhooks? - Can another contract read this contract's state while the call is in progress?
The web3 security checklist has the full list of reentrancy checks to run before an audit.
Scan the whole repository
A smart contract scanner runs these checks across every contract at once. Guardix lists reentrancy among the risk classes each audit covers, and reproduces each critical finding with a working exploit on a mainnet fork (Guardix pricing). On EVMBench, a public benchmark of 117 known high-severity bugs, the paper shows a reentrancy bug in the 2024-08-phi audit (H-06) as an example (arXiv 2603.04915). Guardix detected all 6 known bugs in that audit (EVMBench results). A scan does not replace a manual audit.
Real reentrancy hacks
| Date | Protocol | Loss (reported) | What happened | Sources |
|---|---|---|---|---|
| March 5, 2026 | Solv Protocol | $2.73M (38 SolvBTC) | An ERC-721 receive hook minted tokens during mint(), then mint() minted again; 135 BRO in, 567 million out, looped 22 times | Guardix: DeFi hacks 2026, rekt.news |
| July 2023 | Curve and pools using it | $69M across affected pools | A bug in Vyper 0.2.15, 0.2.16 and 0.3.0 broke the nonreentrant guard in pools holding native ETH | rekt.news, July 31, 2023, Vyper post-mortem |
| 2016 | The DAO | Over 3.6 million ETH | A reentrancy attack drained the contract; Ethereum moved the funds back in the DAO fork on July 20, 2016 | ethereum.org security, ethereum.org history |
Solv Protocol, March 2026. The BitcoinReserveOffering contract used ERC-3525 tokens, which inherit ERC-721 transfer hooks. When mint() pulled in an NFT, the onERC721Received callback minted BRO tokens, and execution then returned to mint(), which minted again. Rekt reports that the contract shipped without an audit (rekt.news). Full entry: DeFi hacks in 2026.
Curve and Vyper, July 2023. Curve's pools had a reentrancy guard in their source code. A storage-slot mismatch in certain older Vyper compiler versions made the guard fail between add_liquidity and remove_liquidity, and attackers re-entered to manipulate LP token prices. Rekt lists JPEG'd, Alchemix, Metronome and Curve's own CRV/ETH pool among the losses (rekt.news).
The DAO, 2016. ethereum.org names the 2016 DAO hack as an example of the withdraw-before-update pattern above (ethereum.org). The DAO fork at block 1,920,000 moved the funds to a contract where holders could withdraw 1 ETH per 100 DAO tokens (ethereum.org history).
More 2026 incidents, sorted by bug class: DeFi hacks in 2026.
FAQ
- What is a reentrancy attack?
- A malicious contract calls back into a vulnerable contract before the first call finishes, while the vulnerable contract still holds outdated state. The attacker repeats an action, such as a withdrawal, that should only happen once.
- How do you prevent reentrancy in Solidity?
- Update state before calling other contracts (checks-effects-interactions), and protect functions that make external calls with a reentrancy lock such as OpenZeppelin's
nonReentrant. - Does Solidity 0.8 prevent reentrancy?
- No. The vulnerable contract on this page compiles under solc 0.8.37, and the test drains it. The compiler does not add a reentrancy check.
- What is read-only reentrancy?
- Another protocol calls a
viewfunction on your contract during a callback and gets stale values, because your contract has not finished updating its state (OWASP SCWE-137). - Can a scanner find every reentrancy bug?
- No. Pattern-based tools catch the direct cases. Reentrancy through token hooks, other contracts or
viewfunctions needs a manual review or an audit that follows each external call.