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

Rebinding a locked scope account to another agreement makes its corruption impossible to report

Author Revealed upon completion

Root + Impact

Description

A confidence pool permanently commits to a local list of covered accounts. The design explicitly states that if the underlying agreement is later narrowed, the pool's locked scope remains the binding source of truth.

However, pool resolution is always gated by the registry state of the pool's original agreement address:

function flagOutcome(
PoolStates.Outcome newOutcome,
bool goodFaith_,
address attacker_
) external onlyModerator {
IAttackRegistry.ContractState state =
_observePoolState();
...
if (newOutcome == PoolStates.Outcome.CORRUPTED) {
...
// @> Only the original agreement's state is checked.
if (
state
!= IAttackRegistry.ContractState.CORRUPTED
) {
revert InvalidOutcome();
}
}
}

The moderator's off-chain scope judgment is therefore only usable when the original agreement itself is CORRUPTED. If an account in the pool's locked scope is removed from that agreement and rebound to another agreement, corruption under the new binding agreement cannot be reflected in the pool.

This is reachable through normal upstream functionality.

After an agreement's commitment period expires, its owner may remove accounts:

function removeAccounts(
string memory caip2ChainId,
string[] memory accountAddresses
) external onlyOwner {
// @> Removal is permitted after the commitment window expires.
if (block.timestamp < s_cantChangeUntil) {
revert Agreement__CannotReduceScopeDuringCommitment();
}
...
if (isBattleChain) {
_removeFromBattleChainScope(
accountAddresses[i]
);
}
}

Removing a BattleChain account invokes the Attack Registry and deletes its binding:

function unregisterContractForExistingAgreement(
address contractAddress
) external {
...
if (
s_contractToAgreement[contractAddress]
!= msg.sender
) {
return;
}
// @> The contract is no longer bound to the original agreement.
delete s_contractToAgreement[contractAddress];
}

The account can subsequently be registered under another agreement.

The Confidence Pool Factory does not require the underlying agreement's commitment period to cover the pool's expiry. It checks only that expiry is at least 30 days away, that the agreement is valid, and that the creator owns it:

if (
expiry < block.timestamp + _MIN_EXPIRY_LEAD
) {
revert ExpiryTooSoon();
}
if (
!safeHarborRegistry.isAgreementValid(agreement)
) {
revert InvalidAgreement();
}
if (
IAgreement(agreement).owner() != msg.sender
) {
revert UnauthorizedCreator();
}

There is no check equivalent to:

IAgreement(agreement).getCantChangeUntil()
>= expiry

An account can therefore be detached and rebound while the pool still holds locked staker funds.

The attack path is:

  1. Agreement A1 includes contracts X and Y.

  2. A confidence pool is created against A1 with its local scope containing only X.

  3. Stakers deposit.

  4. A1 enters UNDER_ATTACK, permanently locking the pool scope and disabling withdrawals.

  5. After A1's commitment period expires, the sponsor removes X from A1.

  6. The Attack Registry deletes the X → A1 binding.

  7. X is rebound to agreement A2.

  8. X is successfully exploited and A2 becomes CORRUPTED.

  9. The remaining contract Y survives, so A1 becomes PRODUCTION.

  10. The pool moderator attempts to flag the pool as CORRUPTED because X, which remains in the pool's immutable scope, was the breach surface.

  11. The call reverts because the pool reads A1 == PRODUCTION.

  12. The pool must instead resolve as SURVIVED, returning principal and bonus to stakers.

The upstream interface expressly warns that membership in an agreement's scope does not prove that it is the account's binding agreement because multiple agreements may contain the same account.

The project design nevertheless states that a removed account remains covered by the pool's locked local commitment.

The implementation cannot enforce that commitment because it consults only the original agreement's registry state. :contentReference[oaicite:3]{index=3}

Risk

A sponsor can make an in-scope corruption unreportable by moving the covered account to another agreement before the pool expires.

The consequences are:

  • stakers recover principal despite an in-scope contract being corrupted;

  • stakers may also receive the complete bonus pool;

  • the good-faith attacker cannot receive the pool bounty;

  • the recovery address cannot receive principal under a bad-faith corruption;

  • the moderator cannot correct the outcome because flagOutcome(CORRUPTED) always reverts;

  • the mechanical expiry path also reads only the stale original agreement and resolves SURVIVED or EXPIRED.

The maximum incorrectly distributed value is the entire pool balance: all staked principal plus all contributed bonus.

The impact is High because the protocol's principal-at-risk mechanism can be bypassed completely.

The likelihood is Medium because no DAO compromise or registry corruption is required. The sponsor uses normal agreement-owner operations after the agreement commitment period expires. Upstream account removal deletes the existing Attack Registry binding.

Proof of Concept

Add the following test helper:

contract MockBindingAttackRegistry {
mapping(address agreement =>
IAttackRegistry.ContractState state
) internal states;
mapping(address account =>
address agreement
) internal bindings;
function setAgreementState(
address agreement,
IAttackRegistry.ContractState state
) external {
states[agreement] = state;
}
function bind(
address account,
address agreement
) external {
bindings[account] = agreement;
}
function getAgreementState(
address agreement
)
external
view
returns (IAttackRegistry.ContractState)
{
return states[agreement];
}
function getAgreementForContract(
address account
) external view returns (address) {
return bindings[account];
}
}

Add the following test to a contract inheriting BaseConfidencePoolTest:

function test_ReboundScopedAccountCorruptionCannotBeReported()
external
{
address agreement2 = makeAddr("agreement2");
MockBindingAttackRegistry bindingRegistry =
new MockBindingAttackRegistry();
safeHarborRegistry.setAttackRegistry(
address(bindingRegistry)
);
bindingRegistry.setAgreementState(
agreement,
IAttackRegistry.ContractState.NEW_DEPLOYMENT
);
bindingRegistry.bind(
DEFAULT_SCOPE_ACCOUNT,
agreement
);
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
// The original agreement enters active risk.
bindingRegistry.setAgreementState(
agreement,
IAttackRegistry.ContractState.UNDER_ATTACK
);
pool.pokeRiskWindow();
assertTrue(pool.scopeLocked());
assertTrue(
pool.isAccountInScope(
DEFAULT_SCOPE_ACCOUNT
)
);
// Simulate the agreement owner removing the account after
// its commitment window expires.
agreementContract.setContractInScope(
DEFAULT_SCOPE_ACCOUNT,
false
);
// The pool's immutable local commitment still includes it.
assertTrue(
pool.isAccountInScope(
DEFAULT_SCOPE_ACCOUNT
)
);
assertFalse(
agreementContract.isContractInScope(
DEFAULT_SCOPE_ACCOUNT
)
);
// The account is rebound to a second agreement.
bindingRegistry.bind(
DEFAULT_SCOPE_ACCOUNT,
agreement2
);
assertEq(
bindingRegistry.getAgreementForContract(
DEFAULT_SCOPE_ACCOUNT
),
agreement2
);
// The account is corrupted under its new binding agreement.
bindingRegistry.setAgreementState(
agreement2,
IAttackRegistry.ContractState.CORRUPTED
);
// The old agreement's remaining contracts survive.
vm.warp(block.timestamp + 1 days);
bindingRegistry.setAgreementState(
agreement,
IAttackRegistry.ContractState.PRODUCTION
);
// The moderator knows that the pool-scoped account was the
// breach surface, but the contract rejects CORRUPTED because
// it reads only the original agreement.
vm.prank(moderator);
vm.expectRevert(
IConfidencePool.InvalidOutcome.selector
);
pool.flagOutcome(
PoolStates.Outcome.CORRUPTED,
true,
attacker
);
// SURVIVED is the only accepted moderator outcome.
vm.prank(moderator);
pool.flagOutcome(
PoolStates.Outcome.SURVIVED,
false,
address(0)
);
uint256 aliceBefore =
token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
// Alice receives principal plus the complete bonus even though
// the only account in the pool's locked scope was corrupted.
assertEq(
token.balanceOf(alice) - aliceBefore,
150 * ONE
);
assertEq(
token.balanceOf(attacker),
0,
"whitehat receives no bounty"
);
assertEq(
token.balanceOf(address(pool)),
0
);
}

Recommended Mitigation

The simplest robust mitigation is to ensure that the underlying agreement cannot remove any covered account during the pool's complete term.

At pool creation, require the agreement commitment period to cover the pool expiry:

uint256 commitmentEnd =
IAgreement(agreement).getCantChangeUntil();
if (commitmentEnd < expiry) {
revert AgreementCommitmentTooShort(
commitmentEnd,
expiry
);
}

The same condition must be enforced in setExpiry():

uint256 commitmentEnd =
IAgreement(agreement).getCantChangeUntil();
if (newExpiry > commitmentEnd) {
revert AgreementCommitmentTooShort(
commitmentEnd,
newExpiry
);
}

Additionally, when the scope locks, verify that every locally scoped account is currently bound to the pool's agreement:

IAttackRegistry registry =
IAttackRegistry(
safeHarborRegistry.getAttackRegistry()
);
for (
uint256 i;
i < _scopeAccounts.length;
++i
) {
address account = _scopeAccounts[i];
if (
registry.getAgreementForContract(account)
!= agreement
) {
revert AccountNotBoundToAgreement(
account,
agreement
);
}
}

Checking only at pool creation is insufficient because accounts may still be removed later. The agreement commitment must remain active through at least expiry.

Support

FAQs

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

Give us feedback!