Web3 & smart contracts
Verified by ROOTVIEL13 minCross-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
- 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.
- 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.
- 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.
- 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.
- 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 } }
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.
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.
- 1Observation: build the storage-access map — which functions read/write each storage slot — and list every external call and whether effects precede it.
- 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.
- 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.
- 4Fuzzing / invariant checks: assert the conservation invariant and let the fuzzer interleave functions through the callback; a violation is a candidate counterexample to replay.
- 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.
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.
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; } }
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).
- 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.
- 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.
- 3Coverage check: confirm every state-changing function that touches balance carries the shared lock, so no sibling path is left uncovered.
- 4Baseline check: confirm normal deposit/withdraw/transfer still work for honest users.
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]SWC-107 — Reentrancy (SWC Registry)
- [2]Solidity docs — Security Considerations (Checks-Effects-Interactions, reentrancy)
- [3]OWASP Smart Contract Top 10 — SC05 Reentrancy Attacks
- [4]MITRE CWE-841 — Improper Enforcement of Behavioral Workflow
- [5]OpenZeppelin — ReentrancyGuard (reference implementation)
- [6]Foundry Book (forge test)
References point to public, well-known technical sources for further study.
Related articles
