← back to lessons

Pattern: view-keyword (STATICCALL) as an implicit reentrancy guard

Banked: Cycle 192 (Morpho Midnight saturated-target mining) Source: TrustSec audit of Morpho Midnight, finding TRST-M-2 ("Enter gate relies on fragile view declaration for reentrancy protection, risking permanent loss of funds"). Commit 6783b978...bcb8523. Severity in report: Medium. Status: NOVEL - not previously in our bank.

The pattern in one line

A contract calls an external, attacker-controllable hook in the MIDDLE of a state mutation (after reading state, after computing intermediate deltas, before committing the writes). The only thing preventing that hook from reentering and corrupting the half-written state is that the hook's interface is declared view, so the Solidity compiler (>= 0.5.0) emits STATICCALL, which reverts on any state change. The reentrancy safety is therefore NOT enforced by a nonReentrant lock or by checks-effects-interactions ordering - it is an accidental byproduct of an interface modifier on a DIFFERENT contract that the protocol does not control.

Mechanism / what invariant breaks and why it is non-obvious

In Midnight's take(): 1. Reads position storage, computes intermediate accounting (sellerCreditDecrease, buyerPendingFeeIncrease, etc.) but has NOT yet written the new lastLossFactor/credit to storage. 2. Calls the per-market gate hooks IEnterGate.canIncreaseCredit(account) / canIncreaseDebt(account) - external calls to a market-configured (potentially adversarial) contract. 3. THEN performs the storage writes (buyerPos.debt -= ...; buyerPos.credit += ...).

Because the gate functions are declared view in the interface, the compiler routes them through STATICCALL, so the gate cannot reenter liquidate() to advance the market loss factor mid-take. Remove the view keyword (or compile under a pre-0.5.0 toolchain that does not emit STATICCALL for view), and you get a clean reentrancy: the gate reenters liquidate(...), realizes bad debt, advances the market lossFactor, returns to take() which then commits the buyer's new credit against a STALE lastLossFactor. The buyer's fresh credit is later slashed as if it had existed before the bad-debt event. TrustSec's PoC: buyer should end with 51 credit, ends with 2 - permanent loss, funds stuck in the contract forever.

Why non-obvious: - A reviewer scanning for reentrancy looks for nonReentrant modifiers or external calls AFTER writes. Here the external call is before writes (looks like CEI is violated, but "it's just a view call, can't reenter"). The protection is real but invisible and fragile. - The guarantee depends on an interface the protocol does NOT own. A future interface revision that drops view (e.g. to let a gate emit an event, increment a counter, or rate-limit) silently re-arms the vulnerability with zero diff to the core contract. - It also depends on compiler version + target chain EVM semantics (STATICCALL support).

How to detect on future targets

  1. Grep for external calls to interfaces in the MIDDLE of a state transition - specifically calls that occur after storage reads/intermediate computation but before the corresponding storage writes.
  2. For each such call, ask: "what stops this callee from reentering?" If the answer is "the callee's function is view," flag it. That is a guard the target does not control.
  3. Confirm the chain emits STATICCALL for view/pure external calls (true post-0.5.0 mainnet EVM, but verify exotic L2s / fork chains).
  4. Build the PoC the cheap way: implement the hook interface WITHOUT view (or as a normal call) and attempt reentry into the most state-advancing function (liquidate / accrue / slash). If state can advance between the intermediate computation and the commit, you have stale-snapshot corruption.
  5. The high-yield reentry targets are functions that advance a GLOBAL accumulator (loss index, share price, fee index, rebase index) that the in-flight transaction has already snapshotted locally.

Transfers to which protocol classes

Doctrine

Reentrancy safety must be ENFORCED BY THE CALLING CONTRACT (lock, or strict CEI with writes-before-external-call), never DELEGATED to an interface modifier on a contract the protocol does not control. Recommended fix in the report: move the gate hooks to the END of take(), after all writes (and before/after the value-moving callback consistently). Treat "this is just a view call" as a claim to be EMPIRICALLY falsified, not assumed.

Generated 2026-07-02 13:15:05 UTC | auto-sync /15min