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

stake() does not commit to scope or recoveryAddress, so a sponsor can expand coverage to a vulnerable contract and lock stakers before they exit

Author Revealed upon completion

stake() does not commit to scope or recoveryAddress, so a sponsor can expand coverage to a vulnerable contract and lock stakers before they exit

Description

Stakers review scope and recovery destination before depositing. Scope is the pool's published insurance commitment: it is what the moderator will treat as the breach surface on CORRUPTED, and recoveryAddress receives corrupted or residual sweeps.

stake(uint256 amount) accepts only an amount. While the registry remains in pre-attack staging (NOT_DEPLOYED / NEW_DEPLOYMENT), the sponsor can still call setPoolScope(). setRecoveryAddress() is mutable at any time. There is no expected-scope / expected-recovery / config-hash check at deposit time, and no event-driven commitment that freezes the reviewed parameters for already-deposited stakers.

The worst practical path is not merely narrowing coverage before a pending stake. After honest stakers have already deposited against a reviewed, ostensibly safe scope:

  1. The sponsor adds a known-vulnerable BattleChain account to the pool scope (still allowed while pre-attack).

  2. Stakers may not notice the scope change in time — there is no on-chain stake binding to the prior scope, and watching every PoolScopeUpdated event is an off-chain operational burden.

  3. The sponsor (or agreement flow) moves the registry to ATTACK_REQUESTED and then has the attack accepted into UNDER_ATTACK.

  4. Withdrawals close permanently once risk is observed. Stakers who still believed they underwrote the old scope can no longer exit.

  5. If the added vulnerable contract is breached and the pool is flagged (or mechanically resolved) CORRUPTED, principal and bonus are swept to the sponsor-controlled recoveryAddress.

A narrower pre-stake bait-and-switch (replace scope / redirect recovery before the first deposit lands) is the same root cause with a weaker impact path.

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
// @> No expectedScope / expectedRecovery / config hash.
_assertDepositsAllowed(_observePoolState());
if (!expiryLocked) {
expiryLocked = true;
}
stakeToken.safeTransferFrom(msg.sender, address(this), amount);
}
function setPoolScope(address[] calldata accounts) external onlyOwner {
_observePoolState();
if (scopeLocked) revert ScopePostLockImmutable();
// @> Still mutable after deposits while registry is pre-attack.
_replaceScope(accounts);
}
function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
// @> Mutable at any time, including after stake.
recoveryAddress = newRecoveryAddress;
}

Risk

Likelihood:

  • Occurs whenever stakers have already deposited while the registry is still pre-attack and the sponsor can still mutate scope.

  • Requires the sponsor to expand (or replace) scope toward a vulnerable account, then have the attack path accepted before stakers observe the change and call withdraw().

  • Stakers that do not continuously monitor scope updates will miss the exit window even when withdraw is still technically open for a short period.

Impact:

  • Stakers can be bound to insurance coverage they never reviewed: a sponsor-added vulnerable contract becomes the pool's breach surface.

  • Once UNDER_ATTACK is observed, principal is locked. An in-scope corruption of that added contract can forfeit the full stake + bonus to recoveryAddress.

  • Separately, recoveryAddress can still be redirected after deposit, changing who receives any corrupted / residual sweep relative to what was reviewed.

Proof of Concept

Setup: append the function below to test/unit/ConfidencePool.t.sol (same ConfidencePoolTest harness / BaseConfidencePoolTest helpers already used by the suite), then run:

forge test --match-test testStakeMissingScopeAndRecoveryCommit -vv
  1. Alice reviews the default (safe) scope account and stakes 100 * ONE while the registry is still pre-attack; withdrawals remain open.

  2. The sponsor expands pool scope to include a previously unseen vulnerable account (and can also redirect recoveryAddress).

  3. Before alice notices and withdraws, the registry moves through ATTACK_REQUESTED into UNDER_ATTACK, sealing riskWindowStart.

  4. Alice's withdraw() now reverts with WithdrawsDisabled — she is locked under coverage that includes the added vulnerable contract.

  5. The agreement is marked CORRUPTED and the moderator flags bad-faith CORRUPTED; claimCorrupted() sweeps alice's full principal to the (possibly redirected) recovery address.

// PoC: after deposit, sponsor expands scope with a vulnerable account; if stakers miss the
// withdraw window before UNDER_ATTACK, principal can be seized on in-scope corruption.
function testStakeMissingScopeAndRecoveryCommit() external {
address reviewedAccount = DEFAULT_SCOPE_ACCOUNT;
address vulnerable = makeAddr("vulnerable");
address newRecovery = makeAddr("newRecovery");
agreementContract.setContractInScope(vulnerable, true);
// Alice deposits against the reviewed, pre-attack scope.
_stake(alice, 100 * ONE);
assertTrue(pool.isAccountInScope(reviewedAccount));
assertFalse(pool.isAccountInScope(vulnerable));
assertEq(pool.recoveryAddress(), recovery);
// Sponsor expands coverage to a vulnerable contract and redirects recovery.
address[] memory expanded = new address[](2);
expanded[0] = reviewedAccount;
expanded[1] = vulnerable;
pool.setPoolScope(expanded);
pool.setRecoveryAddress(newRecovery);
assertTrue(pool.isAccountInScope(vulnerable), "vulnerable account now in pool scope");
assertEq(pool.recoveryAddress(), newRecovery);
// Attack is accepted before alice reacts; withdraw escape hatch closes.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.prank(alice);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
// In-scope corruption of the added surface forfeits the locked stake to recovery.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
uint256 recoveryBefore = token.balanceOf(newRecovery);
pool.claimCorrupted();
assertEq(token.balanceOf(newRecovery) - recoveryBefore, 100 * ONE);
assertEq(token.balanceOf(alice), 0, "staker locked under expanded scope and swept");
}

Recommended Mitigation

Require stake-time commitment to material configuration, and either freeze scope once any stake exists or emit / enforce a clear re-consent path before post-deposit scope expansion.

- function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
+ function stake(
+ uint256 amount,
+ bytes32 expectedConfigHash
+ ) external nonReentrant whenPoolNotPaused {
+ bytes32 configHash = keccak256(
+ abi.encode(expiry, recoveryAddress, _scopeAccounts)
+ );
+ if (configHash != expectedConfigHash) revert UnexpectedPoolConfig();
// existing stake logic
}
+ // Optionally: revert setPoolScope when totalEligibleStake != 0, or require
+ // a new staker-visible consent latch before scope can expand after deposits.

Support

FAQs

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

Give us feedback!