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

Sponsor can front-run registry transition to bait-and-switch pool scope, trapping stakers

Author Revealed upon completion

Root + Impact

Description

  • According to DESIGN.md, the pool's scope is a fixed commitment, and stakers deposit funds under the strict invariant that their "exposure is bounded by what they signed up for at deposit time."

  • The setPoolScope function completely fails to enforce this invariant because it lacks a stake-based lock. While the expiry parameter correctly locks on the first deposit via expiryLocked, setPoolScope only locks when the BattleChain registry transitions out of its NEW_DEPLOYMENT / NOT_DEPLOYED staging states. This allows a malicious sponsor to front-run the registry transition to UNDER_ATTACK with a call to setPoolScope, swapping the scope to a highly vulnerable or already-breached contract right as the exit door (withdraw()) permanently closes, trapping the stakers.

function setPoolScope(address[] calldata accounts) external onlyOwner {
// aderyn-ignore-next-line(unchecked-return)
_observePoolState();
@> if (scopeLocked) revert ScopePostLockImmutable(); // Fails to check if users have already staked
_replaceScope(accounts);
}

This violates the explicit invariant in DESIGN.md §8: “stakers’ exposure is bounded by what they signed up for at deposit time.”

Risk

Likelihood:

  • A malicious sponsor observes a highly vulnerable or breached contract that is NOT in the pool's current scope.

  • The sponsor monitors the mempool for the BattleChain registry's state transition to UNDER_ATTACK and perfectly front-runs it with a setPoolScope transaction to include the breached contract.

Impact:

  • Stakers are permanently trapped underwriting a completely different, fraudulent risk profile than the one they reviewed and agreed to at deposit time.

  • The sponsor fraudulently guarantees a CORRUPTED outcome, effectively stealing the stakers' principal and bonus.

Proof of Concept

The PoC demonstrates how a malicious Sponsor can exploit the missing stake-based lock on setPoolScope to trap stakers.

  1. The Sponsor seeds the pool with a benign SafeContract scope.

  2. Alice reviews the safe scope and deposits her funds.

  3. When the BattleChain registry transitions to UNDER_ATTACK, the Sponsor front-runs the transition with a call to setPoolScope, swapping the scope to a HighlyVulnerableContract.

  4. Because the registry is still in NEW_DEPLOYMENT when the Sponsor's transaction executes, the scopeLocked check is bypassed.

  5. The UNDER_ATTACK transition lands immediately after, permanently disabling withdrawals (WithdrawsDisabled) and trapping Alice in a fraudulent scope she never agreed to.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {ConfidencePool} from "src/ConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
contract ScopeBaitAndSwitchPoCTest is BaseConfidencePoolTest {
function testScopeBaitAndSwitch() external {
// Initial setup: Pool is in NEW_DEPLOYMENT (pre-attack staging)
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
// 1. Sponsor sets a safe initial scope (e.g. well-audited contract)
address[] memory safeScope = new address[](1);
safeScope[0] = makeAddr("SafeContract");
agreementContract.setContractInScope(safeScope[0], true);
pool.setPoolScope(safeScope);
// 2. Alice reviews the safe scope and decides to stake 100 tokens
_stake(alice, 100 * ONE);
// Verify Alice staked under the assumption of the safe scope
assertEq(pool.eligibleStake(alice), 100 * ONE);
// 3. The registry is about to transition to UNDER_ATTACK.
// The Sponsor sees this transaction in the mempool and front-runs it
// by calling setPoolScope() with a malicious or already-breached scope.
address[] memory maliciousScope = new address[](1);
maliciousScope[0] = makeAddr("HighlyVulnerableContract");
agreementContract.setContractInScope(maliciousScope[0], true);
// Front-run succeeds because the registry is technically still in NEW_DEPLOYMENT
// This contradicts DESIGN.md which states stakers' exposure is bounded by deposit time.
pool.setPoolScope(maliciousScope);
// 4. The registry state transition lands, moving to UNDER_ATTACK
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
// 5. Alice sees the scope was changed to a vulnerable contract and tries to withdraw
vm.startPrank(alice);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw(); // ALICE IS TRAPPED!
vm.stopPrank();
// Verify the scope is now locked to the malicious scope, successfully trapping Alice
assertEq(pool.getScopeAccounts()[0], maliciousScope[0]);
}
}

Recommended Mitigation

To ensure stakers' exposure remains strictly bounded by what they signed up for, setPoolScope must be locked upon the first stake. We recommend utilizing the existing expiryLocked latch, which is permanently set to true on the very first deposit.

function setPoolScope(address[] calldata accounts) external onlyOwner {
// aderyn-ignore-next-line(unchecked-return)
_observePoolState();
- if (scopeLocked) revert ScopePostLockImmutable();
+ if (scopeLocked || expiryLocked) revert ScopePostLockImmutable();
_replaceScope(accounts);
}

Support

FAQs

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

Give us feedback!