FoundrySolidityLayer 2
7.25 ETH
Submission Details
Impact: low
Likelihood: low

Permissionless zero-stake caller can trigger claimExpired()'s finality latch, broadening the accepted §5 race beyond self-interested stakers

Author Revealed upon completion

Root + Impact

Description

According to docs/DESIGN.md §5, when a pool expires while the registry reports CORRUPTED but riskWindowStart == 0, claimExpired() intentionally resolves the pool to EXPIRED instead of CORRUPTED. This conservative fallback exists because the automatic expiry path cannot determine whether the corruption falls within the committed scope, leaving that judgment to the moderator.

The design document explicitly frames this race as one initiated by a staker who permissionlessly resolves the pool to EXPIRED in order to recover their own principal before the moderator flags the correct outcome. This is presented as an accepted tradeoff based on the assumption that the caller is an economically interested participant.

However, the implementation allows any address, including one with zero stake and no economic relationship to the pool, to trigger this irreversible transition.

When claimExpired() executes while the outcome is still UNRESOLVED, it performs the automatic resolution and immediately enables claim finality:

if (outcome == PoolStates.Outcome.UNRESOLVED) {
IAttackRegistry.ContractState state = _observePoolState();

...
outcome = PoolStates.Outcome.EXPIRED;
outcomeFlaggedAt = expiry;
...
claimsStarted = true;

}Only after claimsStarted has been permanently enabled does the function verify whether the caller actually has any claimable stake:

As a result, a caller with no stake simply pays gas, receives nothing, and exits successfully while permanently locking the pool into the mechanically chosen outcome.

Once claimsStarted becomes true, flagOutcome() can no longer modify the unresolved outcome:

if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) {
revert OutcomeAlreadySet();
}

Therefore, during the documented riskWindowStart == 0 edge case, any external account can permanently foreclose the moderator's opportunity to record the correct CORRUPTED outcome.

This wider permissionless surface is not reflected in the design rationale, which consistently describes the race as originating from participating stakers.

Risk

Likelihood:

  • Fires automatically any time a pool reaches expiry while the registry reads CORRUPTED and riskWindowStart == 0 and the moderator has not yet flagged — a narrow but reachable state (registry skipped straight from NEW_DEPLOYMENT/pre-attack to CORRUPTED with nobody having interacted with or poked the pool during any active-risk state in between).

  • Requires only that the caller notice the pool's public expiry timestamp and the public registry state — no privileged information, no capital, and (per the soft-success path) no stake in the pool.

Impact:

  • Forecloses the moderator's ability to flag the in-scope-breach CORRUPTED outcome for this pool, permanently routing any stakers who haven't yet claimed toward EXPIRED (principal returned, bonus swept to recoveryAddress per §5) instead of the correct CORRUPTED sweep — the exact outcome-mismatch DESIGN.md §5 acknowledges as the accepted worst case of this race, just reachable by a wider actor set (any address, not only a self-refunding staker) than the design doc's stated rationale describes.

  • No direct fund theft or protocol insolvency; the affected value (stakers' principal being returned rather than swept) moves in the staker-favorable direction, consistent with the documented "conservative EXPIRED is the staker-favorable default."

Proof of Concept

Recommended Mitigation

- - claimsStarted = true;
+ -
+ // Only latch finality once real value has moved, preserving the moderator's
+ // correction window against a zero-stake, zero-cost trigger:
+ }
+ if (userEligible == 0 && msg.sender has no eligibleStake) {
+ // do not set claimsStarted here; let a real stake-holder's claim (or the
+ // moderator's flag) be the actual finality event
+ } else {
+ claimsStarted = true;
+ }

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!