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

setPoolScope is gated only on scopeLocked, never on outcome — sponsor can rewrite the published scope after the pool resolves EXPIRED and pays out

Author Revealed upon completion

Description

docs/DESIGN.md §8 states scope "locks permanently on the first interaction observing any other state", and §10 calls the published scope "the binding audit trail" bounding what stakers signed up to insure. The lock is scopeLocked, which _observePoolState flips only when the registry is observed in a state other than NOT_DEPLOYED/NEW_DEPLOYMENT.

setPoolScope gates solely on that flag and never on outcome:

function setPoolScope(address[] calldata accounts) external onlyOwner {
_observePoolState();
@> if (scopeLocked) revert ScopePostLockImmutable(); // only lock check — no outcome check
_replaceScope(accounts);
}

If the agreement never leaves pre-attack staging for the pool's whole life, scopeLocked is never set. The pool still resolves: at expiry, claimExpired's mechanical branch observes the pre-attack state and resolves EXPIRED (src/ConfidencePool.sol:561-571), returning principal + bonus. Every other registry-observing entrypoint is then closed (stake/withdraw/flagOutcome revert OutcomeAlreadySet, pokeRiskWindow no-ops) — except setPoolScope, which keeps re-observing the same pre-attack state, never locks, and succeeds. The sponsor can therefore rewrite _scopeAccounts after the pool is fully settled and drained.

Risk

Likelihood:

  • Applies to any pool whose agreement is simply never pushed into the BattleChain attack flow and expires via the backstop — a normal, common outcome (the "survived" case). No attacker cooperation is needed.

Impact:

  • The on-chain scope commitment stakers relied on at deposit time can be silently overwritten after resolution and payout, breaking the DESIGN.md §8/§10 immutable-audit-trail guarantee.

  • No direct fund loss (claims never consult scope), but it can mislead anyone later trusting the on-chain scope as the record of what the pool actually insured (a dispute, or tooling reading historical pool scopes).

Proof of Concept

test/exploit/L1ScopeRewritePoC.t.sol (extends BaseConfidencePoolTest). exploit rewrites scope after an EXPIRED settlement; control shows the intended lock once the registry advances.

function test_exploit_scopeRewritableAfterExpiredResolution() public {
_stake(alice, 100 * ONE);
agreementContract.setContractInScope(NEW_ACCOUNT, true); // seed so the rewrite validates
// Registry stays pre-attack; pool expires and mechanically resolves EXPIRED.
vm.warp(block.timestamp + 32 days);
vm.prank(alice);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED));
assertEq(pool.scopeLocked(), false); // never locked
assertTrue(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT));
// BUG: owner rewrites the published scope AFTER settlement + payout.
address[] memory next = new address[](1);
next[0] = NEW_ACCOUNT;
pool.setPoolScope(next);
assertTrue(pool.isAccountInScope(NEW_ACCOUNT)); // scope rewritten post-resolution
assertFalse(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT)); // original silently dropped
}
// Control: once the registry leaves staging, scope locks permanently as documented.
function test_control_scopeLocksOnceRegistryAdvances() public {
_stake(alice, 100 * ONE);
agreementContract.setContractInScope(NEW_ACCOUNT, true);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
assertTrue(pool.scopeLocked());
address[] memory next = new address[](1);
next[0] = NEW_ACCOUNT;
vm.expectRevert(); // ScopePostLockImmutable
pool.setPoolScope(next);
}

Result (Foundry 1.7.1, solc 0.8.26, isolated container):

[PASS] test_control_scopeLocksOnceRegistryAdvances() (gas: 354839)
[PASS] test_exploit_scopeRewritableAfterExpiredResolution() (gas: 446412)
Suite result: ok. 2 passed; 0 failed

Recommended Mitigation

Gate setPoolScope on the resolution state as well as scopeLocked, so a resolved pool's scope is immutable regardless of whether the lock latch ever fired:

function setPoolScope(address[] calldata accounts) external onlyOwner {
_observePoolState();
if (scopeLocked) revert ScopePostLockImmutable();
+ if (outcome != PoolStates.Outcome.UNRESOLVED) revert ScopePostLockImmutable();
_replaceScope(accounts);
}

Support

FAQs

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

Give us feedback!