ROOTVIELROOTVIEL
← Knowledge Base

Web3 & smart contracts

Verified by ROOTVIEL13 min

Cross-Function Reentrancy

During an external call, control re-enters the contract through a different function that shares state with the one still executing. A guard or CEI check applied to only one entrypoint is bypassed because the attacker never re-enters the same function — they enter its sibling.

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
Rootchain
CWE
CWE-841
OWASP
SC05

Definition

Cross-function reentrancy is reentrancy where the re-entered function is not the one that made the external call, but a different function that operates on the same storage. The first function has paused in a half-finished state — a balance read but not yet zeroed, an allowance checked but not yet spent — and the attacker uses the callback to invoke a second function that reads or mutates that shared state before the first function completes its effects.

It is the reason "I added a guard to withdraw" is not the same as "the contract is reentrancy-safe". The classic single-function case loops back into the same function; the cross-function case exploits the fact that contracts are collections of functions over shared storage, and a guard on one door leaves the others open unless they share the lock.

Context

Any contract with multiple state-changing functions that read or write the same mapping or accumulator, and at least one function that performs an external call before finishing its effects, is a candidate. The pattern hides in contracts that look safe function-by-function.

  • ▸A withdraw that pays out before zeroing a balance, next to a transfer that moves the same balance to another account.
  • ▸A claim that sends a reward before marking it claimed, next to a function that recomputes eligibility from the same unmarked state.
  • ▸A redeem that returns assets before burning shares, next to a borrow that values collateral from the same unburned shares.

Solidity & EVM fundamentals

The EVM runs external calls synchronously: when a contract makes a call (including sending native value, which invokes the recipient’s receive/fallback), the callee runs to completion before the caller resumes. During that pause, the caller’s storage is whatever it had written so far — and crucially, every other function of the caller is callable with that same mid-transaction storage visible.

Shared storage
Storage is per-contract, not per-function. A balance mapping touched by withdraw is the same mapping transfer reads; a pause in one function exposes that state to all the others.
Checks-Effects-Interactions (CEI)
Validate, write all state, then call externally last. Applied per function, CEI stops single-function reentrancy; it is insufficient alone if a sibling function exposes the same pre-effect state during the call.
Per-contract guard
A nonReentrant modifier backed by one shared status variable blocks re-entry into ANY function carrying the modifier while one is active. A guard on only some functions leaves the unguarded siblings reachable.

The key insight: CEI and guards work, but their scope matters. CEI must leave no reachable function able to observe or mutate pre-effect state during the call; a guard must cover every function that shares the relevant state, using one shared lock. The bug is almost always a scope mismatch — the defense was applied to one function, not to the set of functions that share the invariant.

Root cause

The root cause is a shared-state invariant that must hold across multiple functions, enforced as if it were local to a single function. The developer reasons about withdraw in isolation — "it is guarded / it follows CEI" — but the invariant ("the sum of what each account can move equals the contract’s backing") is a property of all functions touching that state, and the external call exposes the moment the invariant is temporarily broken to a sibling function.

Equivalently: the external call is placed inside a critical section that spans more code than the single function the developer guarded. The attacker does not break the lock — they walk through a door the lock was never put on.

How the attack works, step by step

  1. 1The attacker identifies two functions over shared state: funcA performs an external call before writing its final effect, and funcB reads or moves that same state.
  2. 2The attacker calls funcA (e.g. withdraw), which validates and then makes a native-value payout to the attacker’s contract BEFORE zeroing the attacker’s balance.
  3. 3Inside the payout callback, the attacker calls funcB (e.g. transfer) — not funcA again. Because funcA has not yet zeroed the balance, funcB sees the full balance and moves it to a second attacker account.
  4. 4The callback returns; funcA resumes and zeroes the (already-moved) balance in the original account. The value now exists twice: once withdrawn, once transferred away.
  5. 5A guard that was only on funcA never fires, because the attacker re-entered funcB; and CEI on funcA alone does not help, because funcB read the stale state during funcA’s external call.

Vulnerable example

A synthetic vault. withdraw pays out before zeroing the balance; transfer moves the same balance. Note that even if withdraw alone carried a guard, transfer here does not share it — and the external call in withdraw exposes the un-zeroed balance to transfer.

synthetic — do not use; illustrates the flaw
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; // UNSAFE: withdraw calls out before its effect; transfer shares the state. contract VulnerableVault { mapping(address => uint256) public balance; function deposit() external payable { balance[msg.sender] += msg.value; } function withdraw() external { uint256 bal = balance[msg.sender]; require(bal > 0, "no balance"); // Interaction BEFORE effect: recipient can re-enter here. (bool ok, ) = msg.sender.call{value: bal}(""); require(ok, "pay failed"); // Effect AFTER the call: during the callback, balance was still 'bal'. balance[msg.sender] = 0; } // Sibling function over the SAME state, with no shared guard. function transfer(address to, uint256 amount) external { require(balance[msg.sender] >= amount, "insufficient"); balance[msg.sender] -= amount; // reads the not-yet-zeroed balance balance[to] += amount; // ...moving value out before withdraw finishes } }
Note

The attacker’s receive() calls transfer(secondWallet, bal) during withdraw’s payout. withdraw then zeroes a balance that has already been moved, double-spending it. A nonReentrant modifier on withdraw alone would NOT stop this, because the re-entry target is transfer.

Impact

  • ▸Integrity / double-spend: shared accounting (balances, shares, rewards) can be counted twice, breaking the contract’s core conservation invariant.
  • ▸Guard evasion by design: defenses scoped to a single function create false confidence; reviewers who verified "withdraw is guarded" may sign off on a reachable sibling path.
  • ▸Composability of the flaw: in contracts with many state-changing functions, the number of funcA/funcB pairs grows, and only one exploitable pair is needed.
Note

Honesty: identifying a funcA-calls-out-before-effect plus a sibling funcB over the same state is a SIGNAL. It is a FINDING only when a concrete attacker contract is shown, in a controlled run, to break a conservation invariant via the cross-function path.

Detection: static, dynamic, symbolic, fuzzing, formal

Static
Detect external calls that precede state writes (CEI violations), then map which other functions read/write that same storage without sharing a reentrancy lock. A pair (caller-before-effect, sibling-sharing-state) is the structural signal.
Dynamic
In a controlled environment, deploy an attacker whose callback invokes each candidate sibling and check whether any conservation invariant (e.g. sum of balances == contract holdings) is violated.
Symbolic
Explore whether a path exists in which, during an external call made by funcA, funcB is reachable and observes pre-effect state; solvers can enumerate reachable entrypoints during the callback.
Fuzzing / invariant
Encode the global invariant (conservation of value/shares) and let a stateful fuzzer, allowed to re-enter through a callback, search for any function ordering that breaks it — this naturally covers cross-function sequences.
Formal
Prove that every function preserves the shared invariant even when interleaved at any external-call point; a proof over all interleavings is strong, but requires a faithful model of reentry.

Statuses stay distinct: a CEI-violation plus shared-state match is a heuristic SIGNAL; a fuzzer counterexample is a tool-generated artifact to be replayed; a lab reproduction is EVIDENCE of a real path; a proof over interleavings is the only near-guarantee, within its model. A clean invariant run is UNKNOWN, never "safe".

How autonomous agents investigate it

An autonomous investigator frames cross-function reentrancy as: find a function that yields control before finishing its effects, then search the set of sibling functions reachable during that window for one that breaks a shared invariant. It climbs the epistemic ladder rather than trusting a pattern match.

  1. 1Observation: build the storage-access map — which functions read/write each storage slot — and list every external call and whether effects precede it.
  2. 2Hypothesis: for each caller-before-effect function, propose each sibling touching the same slots as a candidate re-entry target; record each as a lead.
  3. 3Execution paths: synthesize an attacker contract whose receive()/fallback calls the candidate sibling, and run attacker -> funcA -> callback -> funcB on a disposable local chain or a local fork.
  4. 4Fuzzing / invariant checks: assert the conservation invariant and let the fuzzer interleave functions through the callback; a violation is a candidate counterexample to replay.
  5. 5Adversarial review: try to refute — does funcB actually read pre-effect state, or is it independently guarded? is the invariant truly broken or merely transiently re-arranged and restored? Refuted leads are recorded as refuted.
Note

A sibling that "looks" reachable is a hypothesis. A fuzzer violation is an artifact to replay. Only a replayed, deterministically verified invariant break via the cross-function path is a finding. An LLM judging the attack "plausible" decides nothing.

How Rootchain analyzes it

Rootchain is evidence-first: a static caller/sibling match or a model’s suspicion is a lead. Rootchain promotes it to a Finding only by executing the attack in a controlled environment — a disposable LOCAL blockchain or a LOCAL fork of real on-chain state — and confirming a broken invariant with a deterministic, AI-free verifier. There is no mode to attack a live public network.

Epistemic ladder
Observation → Hypothesis → Experiment → Evidence → Verification → Finding. Here, Evidence is a controlled run where a cross-function re-entry breaks a conservation invariant that holds in the non-reentrant baseline.
AI composes, never decides
The model proposes attacker contracts and funcA/funcB orderings; a deterministic verifier checks the conservation invariant (e.g. total credited == total backing) before and after, and decides success or failure without the model.
Signal vs Finding
A CEI/shared-state match is a Signal with provenance. A Finding requires the reproducible controlled demonstration confirmed by the verifier.
Partial

This is a mature execute-and-verify family, but maturity is never complete. If Rootchain cannot construct an attacker path that the verifier confirms, it reports UNKNOWN, not "secure". A clean run means the attempt failed within scope — not that the contract is safe.

Mitigation

Make the defense scope match the invariant scope, in deterministic code.

  • ▸Apply Checks-Effects-Interactions rigorously: complete ALL state effects before any external call, so no sibling can observe pre-effect state during the callback.
  • ▸Use one shared reentrancy lock across every function that touches the shared invariant — not just the function that makes the call. A single nonReentrant status covering the whole set closes sibling doors.
  • ▸Minimize and isolate external calls; prefer pull-payment patterns so value transfer runs with the least shared state exposed.
  • ▸Design around narrow, explicit invariants (conservation of balances/shares) and assert them, so the set of functions a lock must cover is identifiable.

Corrected example

The corrected vault does both: withdraw follows CEI (zero the balance before paying out), and a shared nonReentrant lock covers both withdraw and transfer. Why it works: after CEI, the callback sees a zeroed balance, so transfer cannot double-spend; and even if some effect were ordered imperfectly, the shared lock blocks re-entry into any covered sibling while withdraw is active.

synthetic — illustrates the fix and its limits
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract SaferVault { mapping(address => uint256) public balance; uint256 private _status; // 1 = idle, 2 = entered (ONE shared lock) modifier nonReentrant() { require(_status != 2, "reentrant"); _status = 2; _; _status = 1; } function deposit() external payable { balance[msg.sender] += msg.value; } function withdraw() external nonReentrant { uint256 bal = balance[msg.sender]; require(bal > 0, "no balance"); // Effect FIRST (CEI): zero before the external call. balance[msg.sender] = 0; // Interaction LAST: even if recipient re-enters, balance is 0 // and the shared lock blocks transfer() anyway. (bool ok, ) = msg.sender.call{value: bal}(""); require(ok, "pay failed"); } // Sibling shares the SAME lock, so it cannot be re-entered mid-withdraw. function transfer(address to, uint256 amount) external nonReentrant { require(balance[msg.sender] >= amount, "insufficient"); balance[msg.sender] -= amount; balance[to] += amount; } }
Note

Limits of the fix: the shared lock must cover EVERY function that touches the shared state — a new function added later without the modifier reopens the class. CEI plus a shared lock addresses reentrancy; it does not address unrelated logic errors or economic attacks on the same state.

Verifying the fix

Verify by attempting the exploit against the fixed contract in a controlled environment and confirming it fails, with reproducible tests (conceptually Foundry for scripted attacks, Echidna for property search).

  1. 1Reproduction test (must now fail): deploy the attacker whose receive() calls transfer during withdraw; assert it reverts ("reentrant") or that the conservation invariant still holds afterward.
  2. 2Invariant property (Echidna-style): assert sum of all balances plus paid-out value equals total deposited, and let the fuzzer interleave withdraw/transfer through a callback; the property should hold across the budget.
  3. 3Coverage check: confirm every state-changing function that touches balance carries the shared lock, so no sibling path is left uncovered.
  4. 4Baseline check: confirm normal deposit/withdraw/transfer still work for honest users.
Note

State results precisely: "the cross-function exploit no longer reproduces and the invariant held within the tested budget" is a reproduced-defense result, not immunity. A clean fuzzing run is UNKNOWN beyond its search space; only a proof over all interleavings approaches a guarantee, within its model.

Limitations

  • ▸Coverage gaps: a shared lock protects only the functions it is applied to; later additions or inherited functions can silently reopen the class.
  • ▸Delegatecall and external library calls can execute code in this contract’s context and may need separate reasoning.
  • ▸Fuzzing/symbolic search is bounded; no counterexample means UNKNOWN, not safety.
  • ▸The invariant must be correctly stated; a wrong or too-weak invariant can pass while the real conservation property is still breakable.

References

  1. [1]SWC-107 — Reentrancy (SWC Registry)
  2. [2]Solidity docs — Security Considerations (Checks-Effects-Interactions, reentrancy)
  3. [3]OWASP Smart Contract Top 10 — SC05 Reentrancy Attacks
  4. [4]MITRE CWE-841 — Improper Enforcement of Behavioral Workflow
  5. [5]OpenZeppelin — ReentrancyGuard (reference implementation)
  6. [6]Foundry Book (forge test)

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

Related articles