ROOTVIELROOTVIEL
← Knowledge Base

Web3 & smart contracts

Verified by ROOTVIEL13 min

Reentrancy

A contract makes an external call before it finishes updating its own state, so the callee can call back into the contract mid-execution and act on stale values — classically draining a vault by withdrawing repeatedly before any balance is zeroed.

Classification

Domain
Web3 & smart contracts
ROOTVIEL support
Verified by ROOTVIEL — A deterministic, LLM-free verifier settles a specific property under stated assumptions — either a structural property over a graph, or an effect reproduced in execution. It does not by itself mean the whole system is secure, nor that an end-to-end exploit was run beyond what the evidence scope states.
Evidence scope
Observed in execution — Reproduced in an isolated sandbox or local fork — the effect was actually triggered under stated conditions.
Analysis family
RootRaven
CWE
CWE-841
OWASP
SC05

Definition

Reentrancy is the class of bugs in which a contract hands control to an external party in the middle of an operation whose invariants are not yet re-established, and that external party calls back into the contract — re-entering it — while the contract still believes the first operation is only partway done. Because the contract’s bookkeeping has not yet caught up with the funds or assets it has already committed to move, the re-entrant call reads and acts on a state that is a lie.

The canonical form is single-function reentrancy against a vault: a withdraw function that sends ether (or tokens) to the caller before it decrements the caller’s recorded balance. But the family is broader — it includes cross-function reentrancy (re-entering a different function that shares state), cross-contract reentrancy (re-entering through a second contract that reads the first’s not-yet-updated state), and read-only reentrancy (a view function returning a mid-transaction value that another protocol trusts).

Context

Reentrancy is specific to systems where control can transfer to untrusted code during a state-changing operation. On the EVM that happens constantly: a plain ether transfer invokes the recipient’s fallback/receive function, and any call to an ERC-20/ERC-721/ERC-777 token, an AMM, or a lending market may hand execution to code the caller does not control. ERC-777 and ERC-721 hooks (tokensReceived, onERC721Received) are particularly sharp edges because a transfer that looks passive actually calls back into the recipient.

  • ▸Vaults, staking pools and escrow contracts that pay out before updating balances.
  • ▸Protocols that integrate hook-bearing tokens (ERC-777, ERC-721, ERC-1155) where a transfer calls the receiver.
  • ▸Lending and AMM integrations where a view like getReserves() or convertToAssets() is read mid-transaction by a third party (read-only reentrancy).
  • ▸Multi-function flows where an external call in function A leaves state that function B trusts.

Technical fundamentals

The EVM executes a transaction as a single call tree. When contract A calls contract B, A’s frame is suspended and B runs to completion (or reverts) before A resumes. B is free to call back into A during that window. Nothing in the machine prevents re-entry; the only protection is the invariant discipline the developer writes into A.

The useful mental model is an invariant that must hold at every externally observable point. A function temporarily breaks an invariant (funds promised but balance not yet reduced) and is responsible for restoring it before yielding control. An external call yields control. So a function that makes an external call while an invariant is broken has created a window in which that broken invariant is observable and exploitable.

Checks-Effects-Interactions (CEI)
An ordering discipline: validate inputs (checks), update your own state (effects), then talk to the outside world (interactions) — so no invariant is broken at the moment you yield control.
External call
Any operation that can transfer control to code you do not control: call/send/transfer of value, a token transfer with hooks, or a call to an arbitrary address.
Reentrancy guard
A mutex (a storage flag) that reverts if a protected function is entered while already executing, collapsing the re-entry window regardless of call ordering.
Read-only reentrancy
Re-entry that does not change state but is observed: a view function returns a mid-transaction value, and a trusting external protocol prices or accounts on that corrupted reading.

Root cause

The defect is an ordering and trust-boundary error: the contract performs an interaction (an external call that can transfer control) while one of its own invariants is still broken, and it assumes execution will return to it in the same state it left. That assumption is false whenever the callee is untrusted, because the callee can re-enter and observe or exploit the broken invariant.

It is not fundamentally about ether vs tokens, nor about a specific low-level call primitive. It is about where the state update sits relative to the yield of control. Put the effect after the interaction and the bug exists; put it before and, for the single-function case, it does not. The SWC Registry catalogs this as reentrancy (SWC-107).

How the attack works, step by step

  1. 1The victim contract exposes withdraw(), which sends the caller their recorded balance via an external call and only afterwards sets that balance to zero.
  2. 2The attacker deploys a contract, deposits some funds so it has a non-zero recorded balance, then calls withdraw().
  3. 3The victim sends ether to the attacker contract, which triggers the attacker’s receive()/fallback() — control is now in attacker code while the victim’s balance for the attacker is still non-zero.
  4. 4The attacker’s fallback calls withdraw() again. The balance check still passes (it was never zeroed), so the victim sends another payment.
  5. 5This recurses until the victim’s ether is drained or gas runs out; only as the call stack unwinds does the balance finally get set to zero — too late.

Cross-function and cross-contract variants replace step 4: instead of re-entering the same function, the attacker re-enters a different function (or a second contract) that reads the same not-yet-updated state and lets the attacker extract value a second way.

Vulnerable example

A synthetic, illustrative vault. The external payment happens before the balance is updated, so a re-entrant caller sees a stale, still-positive balance:

synthetic Solidity — do not deploy; illustrates the flaw
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; // UNSAFE: interaction happens before the effect (state update). contract Vault { mapping(address => uint256) public balanceOf; function deposit() external payable { balanceOf[msg.sender] += msg.value; } function withdraw() external { uint256 amount = balanceOf[msg.sender]; require(amount > 0, "nothing to withdraw"); // INTERACTION first: transfers control to msg.sender's receive(). (bool ok, ) = msg.sender.call{value: amount}(""); require(ok, "transfer failed"); // EFFECT last: by now the attacker has re-entered and drained more. balanceOf[msg.sender] = 0; } } // The attacker's receive() calls Vault.withdraw() again before // balanceOf[attacker] is ever set to zero.

Corrected example

Two independent defenses, applied together. First, Checks-Effects-Interactions: zero the balance before making the external call, so a re-entrant call sees a balance of zero and reverts the check. Second, a reentrancy guard (mutex): even if some future refactor reintroduces a bad ordering, the guard reverts any re-entry into a protected function.

synthetic Solidity — CEI plus a reentrancy guard
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; // SAFER: effect before interaction, and a mutex as defense in depth. contract Vault { mapping(address => uint256) public balanceOf; uint256 private _locked = 1; // 1 = unlocked, 2 = locked modifier nonReentrant() { require(_locked == 1, "reentrant call"); _locked = 2; _; _locked = 1; } function deposit() external payable { balanceOf[msg.sender] += msg.value; } function withdraw() external nonReentrant { uint256 amount = balanceOf[msg.sender]; require(amount > 0, "nothing to withdraw"); // EFFECT first: invariant is restored before we yield control. balanceOf[msg.sender] = 0; // INTERACTION last: a re-entrant withdraw() now sees balance 0. (bool ok, ) = msg.sender.call{value: amount}(""); require(ok, "transfer failed"); } }

Why it works: at the moment control leaves the contract, the invariant "balanceOf reflects funds still owed" holds again, so there is nothing stale to exploit. The guard is a second, ordering-independent net that also covers cross-function reentrancy into any other function sharing the same lock.

Note

Limits of the fix: a single mutex does not stop cross-contract or read-only reentrancy if a separate contract reads this one’s state through an unguarded view, and it does not protect against a guard applied inconsistently across functions that share state. A guard also adds a storage slot and gas cost, and protects only the functions that actually carry the modifier. CEI should be the primary control; the guard is defense in depth, not a substitute for correct ordering.

Impact

  • ▸Direct loss of funds: a vault, pool or escrow can be drained to zero in a single transaction.
  • ▸Accounting corruption: double-counted withdrawals, mints or reward claims that violate conservation of value.
  • ▸Read-only reentrancy: an integrating protocol prices or liquidates on a mid-transaction view and loses value even though the "reentered" contract never changed state.
  • ▸Cascading failure: a drained collateral or liquidity contract can render dependent protocols insolvent.

Detection

Static analysis can locate the structural precondition: an external call followed by a state write to storage the function also reads, or a value-transferring call on a path that lacks a reentrancy guard. This flags candidates but over-reports — not every such ordering is exploitable. Dynamic analysis is more decisive: deploy the contract to a local chain or a local fork, deploy a malicious re-entrant caller, and attempt the drain. An observed balance change beyond what the attacker funded is strong evidence. The honest signal is a proven re-entry that violates an invariant; a static match alone is a lead, not a confirmed finding.

How RootRaven analyzes it

Support level
Verified — reentrancy is a core execute-and-verify family: RootRaven composes a candidate attack and proves it by running it.
The truth it decides
Whether a contract can be re-entered mid-execution in a way that violates its invariants — covering the single-contract/vault case and the cross-contract case.
Evidence required
A real re-entrant transaction, executed in a controlled environment, that leaves the contract in a state its own invariants forbid (e.g. more value withdrawn than deposited).

RootRaven is evidence-first. A static ordering match (external call before state update) is treated as an Observation, which becomes a Hypothesis ("this withdraw can be re-entered to over-draw"). The AI then composes a candidate exploit — a malicious caller whose fallback re-enters — but the AI never decides whether the bug is real. The candidate is executed as a real attack attempt on a disposable local blockchain or a local fork of real on-chain state; there is no mode that attacks a live public network. A deterministic, AI-free verifier then checks a hard invariant: did the attacker extract more than they funded, or did a guarded balance go negative-equivalent? Only if that verifier confirms does the lead become a Finding.

Verified

Signal is not Finding. A static match with no successful re-entry in the sandbox is reported as a signal or UNKNOWN, never as a vulnerability — and a clean run is never reported as "secure", only as "no re-entry was proven within the tested scope". Reentrancy is among RootRaven’s more mature families, but maturity on single-contract vaults does not mean every cross-contract or read-only variant is proven end-to-end; that caveat stays visible.

Mitigation

Order correctly first, then add belt-and-braces. The load-bearing control is state ordering; the guard and limits reduce blast radius when ordering slips.

  • ▸Preventive: apply Checks-Effects-Interactions rigorously — update all state your function reads before any external call.
  • ▸Preventive: add a reentrancy guard (e.g. OpenZeppelin ReentrancyGuard) to every state-changing function that makes external calls, and share one lock across functions that share state to cover cross-function reentrancy.
  • ▸Preventive: be explicit about hook-bearing tokens (ERC-777/721/1155) — a "transfer" can call back; guard those paths too.
  • ▸Read-only reentrancy: have external consumers refuse to read your views during an active lock, or expose a reentrancy-aware getter; do not let integrators price on a mid-transaction value.
  • ▸Corrective: scope and rate-limit payout authority (per-call caps, pull-over-push withdrawals) so a missed guard does not mean total loss.

Verifying the mitigation

Verify by trying to defeat the fix in a controlled environment, not by reading the code and declaring victory. Deploy the patched contract to a local chain or local fork, deploy the same malicious re-entrant caller, and confirm the drain reverts (the guard fires or the zeroed balance fails the check). Repeat for cross-function paths that share the lock, and for any view that an integrator reads. State the result as "the re-entry attempt failed within the tested scope", never as proof of immunity against all variants.

Note

A passing test proves the specific attack you ran does not work. It does not prove the absence of an untested variant (cross-contract, read-only, a different hook). Expand the attack set rather than concluding from one green run.

Common mistakes

  • ▸Believing switching from .call to .transfer/.send prevents reentrancy — the 2300-gas stipend is a fragile, deprecated defense that breaks with gas repricing and does not stop all re-entry.
  • ▸Applying a reentrancy guard to withdraw() but not to a sibling function that reads the same balances (cross-function reentrancy).
  • ▸Fixing the ether path but ignoring token hooks (ERC-777 tokensReceived) that also hand control to the caller.
  • ▸Forgetting read-only reentrancy: your state is guarded, but an integrator reads your unguarded view mid-transaction and gets robbed.
  • ▸Treating a single clean dynamic run as proof of safety instead of as evidence against one specific attack.

References

  1. [1]SWC Registry — SWC-107 Reentrancy
  2. [2]SWC Registry (overview)
  3. [3]OWASP Smart Contract Top 10 — SC05 Reentrancy
  4. [4]OpenZeppelin Contracts — ReentrancyGuard
  5. [5]MITRE CWE-841 (Improper Enforcement of Behavioral Workflow)

References point to public, well-known technical sources for further study.

Related articles