Skip to main content
Sigvex

Cairo Reentrancy (Checks-Effects-Interactions)

Detects Cairo functions that write storage after an external call, leaving the contract open to reentrancy through a violated checks-effects-interactions ordering.

Cairo Reentrancy (Checks-Effects-Interactions)

Overview

The checks-effects-interactions (CEI) pattern says a function should validate its preconditions, apply all state changes, and only then interact with other contracts. Reversing the last two steps — interacting before the state change commits — is the structural cause of reentrancy. This detector reports Starknet functions that perform a storage write after an external call within the same function body.

On Starknet, an external call (for example a call_contract_syscall or a dispatcher invocation) hands control to another contract. That callee can call back into the original contract before the original function finishes. If the original function has not yet written its state update, the reentrant call observes stale storage and can act on it — typically draining a balance or double-spending an allowance that the pending write was about to reduce.

Why This Is an Issue

Consider a withdrawal function that sends tokens to the caller and then sets the caller’s balance to zero. Between the send and the zeroing, the caller’s contract regains control with its balance still recorded as full. It calls withdraw again, passes the same balance check, receives another payout, and repeats until the contract is empty. The single missing reordering turns a one-time withdrawal into an unbounded loop.

Starknet’s account-abstraction model and the prevalence of dispatcher-based cross-contract calls make this easy to introduce accidentally. Any external call placed before the corresponding storage update is a candidate, regardless of whether the called contract is currently trusted — trust can change, and the called address is often caller-supplied.

How to Resolve

Reorder the function so every storage write happens before any external call, or guard the entry point with a reentrancy lock.

// Vulnerable: balance is written after the external transfer
#[external(v0)]
fn withdraw(ref self: ContractState, amount: u256) {
    let token = self.token.read();
    token.transfer(get_caller_address(), amount); // hands over control
    let bal = self.balances.read(get_caller_address());
    self.balances.write(get_caller_address(), bal - amount); // too late
}
// Fixed: effects before interactions
#[external(v0)]
fn withdraw(ref self: ContractState, amount: u256) {
    let caller = get_caller_address();
    let bal = self.balances.read(caller);
    assert(bal >= amount, 'insufficient');
    self.balances.write(caller, bal - amount); // effect first
    let token = self.token.read();
    token.transfer(caller, amount);            // interaction last
}

A reentrancy guard component is an acceptable defence-in-depth layer, but reordering to satisfy CEI is the primary fix and removes the vulnerability at its root.

Detection Methodology

For each function the detector scans its operations in order, recording the line of the first external call it sees. From that point onward, any storage write produces a finding that cites both the external-call line and the write line. The ordering check is intra-procedural and order-sensitive: a storage write that precedes every external call is CEI-safe and is not flagged.

Because the detector keys on the first external call only, a function with several calls is reported against the earliest one, which is the conservative choice — a later write is unsafe with respect to all preceding calls.

Limitations

  • The analysis is per function; an external call in one function followed by a write in another that the callee can reach is not modelled.
  • A reentrancy guard applied through a component or modifier is not recognised as a mitigation, so a guarded function may still be reported.
  • Writes that are genuinely safe to perform after a call (for example to unrelated, non-security-critical storage) are reported the same as dangerous ones; triage is left to the reviewer.

References

Sample Sigvex Output

{
  "detector": "cairo-reentrancy-cei",
  "severity": "high",
  "confidence": 0.80,
  "title": "Storage write after external call in `withdraw` at line 6",
  "template": "withdraw",
  "signal": "balances",
  "line": 6,
  "description": "Function `withdraw` performs an external call at line 4 and then writes storage at line 6. The callee can re-enter the contract before the write commits, observing a stale state. This is the canonical reentrancy pattern and violates checks-effects-interactions.",
  "recommendation": "Reorder the function so every storage write happens before any external call, or guard the function with a reentrancy lock (e.g. an OpenZeppelin `ReentrancyGuard`)."
}