The same post-lock agreement expansion bug has a second, stronger sink than the withdraw() freeze above. Even after a pool locks its published scope, the sponsor can still expand the upstream agreement, let that never-published contract drive the shared agreement into CORRUPTED, and then rely on claimExpired()'s scope-blind backstop to auto-resolve bad-faith CORRUPTED after grace. The result is a full sweep of staker principal plus bonus to recoveryAddress from a contract that was never part of pool.getScopeAccounts().
This is not just the accepted section 6 tradeoff that claimExpired() is scope-blind during moderator absence. The deeper implementation break is that the protocol treats the pool's insured set as fixed after lock, while the factory simultaneously requires the pool sponsor to own the upstream agreement, the README says the sponsor "cannot alter scope once locked", and DESIGN separately promises that post-lock agreement additions "do not extend this pool's coverage". In reality, scopeLocked only freezes the pool-local setPoolScope() entrypoint; it does not freeze the upstream agreement that the pool continues to trust through the live AttackRegistry.
Once the pool leaves pre-attack staging, its local scope commitment no longer changes, but the upstream agreement can still add BattleChain accounts after lock and each new account is auto-linked into the already-registered agreement through the _addToBattleChainScope() sync hook and AttackRegistry's registerContractForExistingAgreement() path. That means the agreement-level state trusted by claimExpired() can start reflecting a contract that still does not appear in the pool's frozen scope commitment.
That expanded agreement state then reaches the scope-blind expiry backstop. Section 6 of DESIGN.md accepts that claimExpired() cannot distinguish in-scope from out-of-scope corruption once the agreement itself is CORRUPTED, but that accepted tradeoff assumes the agreement's risk surface still matches the pool's locked coverage set. Here it does not. After the sponsor-added contract drives the shared agreement into active risk and then CORRUPTED, claimExpired() consults only the live agreement-wide registry state and finalizes bad-faith CORRUPTED, after which claimCorrupted() sweeps the whole pool.
This exploit is reachable under the documented control model:
The sponsor creates a pool from an agreement they own, which the factory enforces.
The pool observes a non-staging registry state and permanently locks its local scope commitment.
While the agreement is still non-terminal, the same sponsor calls Agreement.addAccounts() upstream and links a new BattleChain contract into that live agreement.
The pool's published scope remains frozen and still excludes the added contract.
The added contract drives the shared agreement into UNDER_ATTACK, and any caller can seal riskWindowStart through pokeRiskWindow().
The same added contract later drives the agreement into CORRUPTED.
If the moderator is absent through expiry + MODERATOR_CORRUPTED_GRACE, any caller can execute claimExpired(), flip the pool to bad-faith CORRUPTED, and then sweep the full balance through claimCorrupted().
Likelihood:
Reason 1 : The confiscation path materializes after the sponsor expands the live agreement post-lock, the added contract drives UNDER_ATTACK, and the same agreement later reaches terminal CORRUPTED.
Reason 2 : The mechanical sweep occurs after the moderator grace window elapses, when any caller can invoke claimExpired() against the now-mutated agreement state and finalize bad-faith CORRUPTED.
The sponsor can bypass the advertised post-lock scope limitation and confiscate the full pool from a never-published contract. Stakers deposit against one fixed scope, but a later upstream addition can become the breach surface, push the shared agreement into CORRUPTED, and still sweep all principal plus bonus to recoveryAddress.
Run command:
Observed output:
The exploit test proves the full end-to-end path the protocol claims should be impossible after lock: the pool's published scope stays fixed on contract1, contract2 is linked only through the upstream agreement rewrite, and the never-published contract2 still drives UNDER_ATTACK -> CORRUPTED -> claimExpired() -> claimCorrupted() until the entire pool is swept.
Freeze the effective agreement scope at lock and ignore post-lock agreement additions when evaluating active risk, expiry resolution, and corrupted sweeps.
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.