view-keyword (STATICCALL) as an implicit reentrancy guardBanked: 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.
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.
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).
view," flag it. That is a guard the target does not control.view/pure external calls (true post-0.5.0 mainnet EVM, but verify exotic L2s / fork chains).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.view for safety.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.