Severity: Medium
A ConfidencePool treats its scope (the flat list of BattleChain accounts the pool insures) as a permanent, one-way commitment. _observePoolState() is supposed to latch scopeLocked = true the first time the agreement is seen in any state past pre-attack staging (NOT_DEPLOYED / NEW_DEPLOYMENT), after which setPoolScope must always revert. This bounds each staker's exposure to exactly the contracts they underwrote at deposit time. The protocol already recognises that the upstream registry can rewind to an earlier state — withdraw() is deliberately hardened against it by gating on the one-way riskWindowStart != 0 latch rather than on live registry state.
The scope lock has no such rewind-resistant latch. scopeLocked is only ever committed by a successful registry observation, and nothing anchors the "agreement has left staging" fact against a later rewind. Two gaps follow. First, the pool's only permissionless sealing entrypoint, pokeRiskWindow(), observes the post-staging state, sets scopeLocked = true, and then reverts with RiskWindowNotReached for any non-active-risk state such as ATTACK_REQUESTED — the EVM rolls the write back, directly contradicting the function's own natspec ("side effects are one-way and safe to call repeatedly"). Second, and decisively, whenever the lock was never durably persisted during the post-staging window, the registry moderator's ordinary rejectAttackRequest() call deletes the agreement's registry record — so _getAgreementState() again returns NOT_DEPLOYED — and setPoolScope succeeds, re-pointing live deposits at a scope the stakers never agreed to.
The upstream transition the exploit relies on is a documented, non-malicious registry operation:
Likelihood:
The agreement sits in ATTACK_REQUESTED for the entire interval between an attack request being filed and the DAO acting on it. requestUnderAttack is callable by the agreement owner (i.e. the pool sponsor) or arises from any attack alarm, so reaching this state is a routine, sponsor-reachable event rather than an edge case.
rejectAttackRequest() returns the agreement to NOT_DEPLOYED by deleting its registry record. This is the registry's documented response to any spurious, unapproved, or withdrawn attack request (soft-reject returns bond, hard-reject slashes it) — a normal DAO operation, not a compromise of a trusted actor.
The lock stays unpersisted across that window whenever the only pool interaction is pokeRiskWindow() (which reverts and rolls the write back) or when there is no pool interaction at all during the brief ATTACK_REQUESTED interval. The sponsor observes scopeLocked == false on-chain and acts only when the exploit is already available.
The pool sponsor is an untrusted actor in the protocol's own trust model (DESIGN §10 enumerates the sponsor trust surface and treats scope immutability as a staker protection against the sponsor). The attacker here is the sponsor; the DAO's rejectAttackRequest is an honest action that merely opens the window.
Impact:
The core staker guarantee — "stakers' exposure is bounded by what they signed up for at deposit time" (DESIGN §8, verbatim) — is broken. Live deposits are re-pointed to a set of contracts the stakers never underwrote, after the point at which the design promises the scope is permanently frozen.
Because scope is the binding on-chain record the moderator uses to decide SURVIVED vs CORRUPTED, a sponsor can swap in a contract they expect to be breached; on the resulting CORRUPTED resolution the entire pool — up to the full principal and bonus of every staker who does not exit before the window recloses — is swept to the sponsor-controlled recoveryAddress. Actively-monitoring stakers retain a withdraw exit, but the permanent-lock invariant exists precisely so passive stakers are not forced to police post-lifecycle scope swaps; those stakers suffer the loss.
Drop-in Foundry test at test/audit/ConfidencePoolM01.t.sol. The first case reproduces the reported path (poke reverts, lock rolled back, scope rewritten). The second case is the important one: it runs the identical exploit with the pokeRiskWindow call deleted entirely, proving the poke is not causally required and that the root cause is the missing rewind-resistant latch. The third case confirms the lock is durable when a single ordinary deposit persists it.
Run:
Result (all three pass; note the poke-free case succeeds):
Two complementary changes. The first honours the pokeRiskWindow natspec and closes the write-then-revert gap; the second — the load-bearing fix — makes the scope commitment durable against a registry rewind by anchoring it to an event the sponsor cannot undo (the first deposit), exactly mirroring the existing one-way expiryLocked latch. Anchoring on the first stake also fixes the "no observation at all" path, which a poke-only fix cannot reach because the registry deletes its own history on reject.
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
Appeals are being carefully reviewed by our judges.
The contest is complete and the rewards are being distributed.