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

Invalid registry state permanently locks moderator out after auto-resolution

Author Revealed upon completion

TL;DR

When the registry returns an out-of-bounds state (255), flagOutcome reverts for both SURVIVED and CORRUPTED. claimExpired auto-resolves to EXPIRED. Once claimsStarted=true, the moderator is permanently locked out; even after the registry is fixed. This combines L-03 (deadline persistence) with L-04 (enum validation) into a worse composite: the pool becomes irreversibly EXPIRED.

Description

L-04 established that an out-of-bounds registry state (e.g., 255) blocks bonus distribution. L-03 established that _firstGoodFaithCorruptedAt persists across reflags. This finding combines both: the moderator cannot flag ANY outcome during the invalid state, and after claimExpired resolves the pool, the moderator can NEVER flag; even after the registry is fixed.

The chain of events:

  1. Registry returns state=255 (governance error, protocol migration, or partial compromise)

  2. _observePoolState responds:

    • scopeLocked = true (255 passes the gate at line 787)

    • _isActiveRiskState(255) = false → riskWindowStart stays 0

    • _isTerminalState(255) = false → riskWindowEnd stays 0

    • Deposits allowed (255 not in blocked list), withdrawals blocked (255 not in pre-attack list)

  3. Moderator tries flagOutcome; reverts InvalidOutcome:

    • SURVIVED path (line 338): requires state==PRODUCTION || state==CORRUPTED → 255 fails both

    • CORRUPTED path (line 347): requires state==CORRUPTED → 255 fails

  4. claimExpired auto-resolves to EXPIRED (line 561-570), sets claimsStarted = true (line 575)

  5. Registry is fixed; returns PRODUCTION or CORRUPTED

  6. Moderator tries flagOutcome; reverts OutcomeAlreadySet (line 327: outcome != UNRESOLVED && claimsStarted == true)

  7. Pool is permanently EXPIRED. No moderator override possible. No bonus. No CORRUPTED sweep path. Stakers get principal only.

The critical amplification: in L-04 alone, fixing the registry WOULD allow the moderator to flag (state=PRODUCTION → flagOutcome(SURVIVED) works). But once claimExpired has auto-resolved, the claimsStarted latch blocks any moderator action. The invalid registry state doesn't just delay the moderator; it permanently disables them.

Attack Path

  1. Registry returns out-of-bounds state (255). This could result from a governance error, a protocol upgrade that changes the enum, or a partial registry compromise.

  2. _observePoolState seals scope but not risk window. Stakers are trapped; can deposit but not withdraw, no bonus will be paid.

  3. Pool expires. claimExpired is called by any user. Since state=255 is not PRODUCTION and not CORRUPTED (with risk window), it falls to the EXPIRED branch.

  4. Registry is fixed (returns PRODUCTION or CORRUPTED). The moderator attempts to override.

  5. flagOutcome reverts OutcomeAlreadySet; claimsStarted is true and outcome is EXPIRED.

  6. Stakers can only claim principal via claimExpired. No bonus. The bonus pool goes to recoveryAddress via sweepUnclaimedBonus.

The moderator's corrective power; the ability to override an incorrect outcome; is permanently destroyed. Even a brief registry glitch (one block of state=255) during or after expiry can trigger irreversible EXPIRED resolution. In contrast to the D43/D45 race (where the moderator CAN race against claimExpired), here the moderator CANNOT intervene at all; flagOutcome itself reverts during the invalid state.

For a pool with 100 ETH staked + 20 ETH bonus: stakers lose the 20 ETH bonus to recoveryAddress, and the moderator cannot correct this even after the registry is fixed.

Scope Eligibility

ConfidencePool.sol is in contest scope. The flagOutcome (line 322) and claimExpired (line 512) functions are the affected surfaces. The root cause; missing enum validation on registry return; is in _getAgreementState (line 740). All three functions are in the in-scope contract.

Severity Calibration

CodeHawks severity rules: Low findings are "issues that are not exploitable under normal circumstances but represent poor coding practices or deviations from best practices that could theoretically lead to issues."

This finding requires a compromised/buggy registry returning an invalid enum value. Under normal operation, the registry returns valid states (0-6). The finding is a defense-in-depth concern: if the trusted registry ever returns unexpected data, the pool becomes irreversibly locked. Severity: Low.

Known-Issue Distinction

L-04 (submitted concurrently) identifies the missing enum validation as a standalone issue. L-03 identifies _firstGoodFaithCorruptedAt persistence as a standalone issue. This finding (L-05) is the AMPLIFICATION: combining the invalid state with auto-resolution creates a permanent moderator lockout that neither L-03 nor L-04 alone captures. In L-04 alone, fixing the registry restores moderator function. In L-05, the moderator is permanently disabled because claimsStarted is set by claimExpired during the invalid state.

Risk

  1. Registry returns an out-of-bounds enum value (e.g., 255). Likelihood: very low; registry is a trusted DAO singleton (DESIGN.md §11). This requires a governance error, protocol migration that changes the enum, or partial registry compromise. Never observed (code is pre-launch).

  2. claimExpired is called during the invalid state. Likelihood: moderate; anyone can call it after expiry. A griefer could intentionally trigger this.

  3. Registry is subsequently fixed. Likelihood: high. DAO would correct a governance error.

Historical occurrence: No instances observed (code is pre-launch).

Proof of Concept

forge test --match-test test_registryInvalidState_permanentExpired -vvv
// Add this test to BughuntFinal contract
function test_registryInvalidState_permanentExpired() public {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 20 * ONE);
// Registry returns invalid state (cannot reproduce with MockAttackRegistry
// which only supports valid enum values; but the structural analysis
// confirms the vulnerability: flagOutcome reverts InvalidOutcome,
// claimExpired auto-resolves EXPIRED, claimsStarted=true blocks reflag)
}

Recommended Mitigation

The root cause is the missing enum bound validation (L-04). Adding require(uint8(state) <= 6) in _getAgreementState prevents state=255 from ever reaching _observePoolState, closing the entire chain. A moderator emergency override that bypasses claimsStarted when the original resolution was auto-EXPIRED (not moderator-flagged) would also provide defense-in-depth.

Support

FAQs

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

Give us feedback!