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

The sponsor is their own agreement's attack moderator: they can author the CORRUPTED verdict that sweeps the pool to their own recovery address, for free and with no breach

Author Revealed upon completion

Description

  • The pool treats a registry reading of CORRUPTED as exogenous evidence of a breach. §1 states it outright: "Only the terminal CORRUPTED state is evidence of an actual breach." §5, §6 and §11 all reason from that premise.

  • The premise is false: the party who writes that flag is the party who receives the swept funds.

// AttackRegistry.sol:750,753 - only the agreement's owner may register it
agreementOwner = agreement.owner();
if (msg.sender != agreementOwner) revert AttackRegistry__NotAgreementOwner(msg.sender, agreementOwner);
// AttackRegistry.sol:793 (and :825) - ...and that owner is appointed its attack moderator
@> attackModerator: agreementOwner,
// AttackRegistry.sol:322 - who alone may declare the agreement corrupted, with NO evidence
@> function markCorrupted(address agreementAddress) external onlyAttackModerator(agreementAddress) {
// ConfidencePoolFactory.sol:82,99 - and createPool forces the pool's owner to be that same address
@> if (IAgreement(agreement).owner() != msg.sender) revert UnauthorizedCreator();
IConfidencePool(pool).initialize(..., msg.sender, accounts); // msg.sender becomes owner_

pool.owner() also controls setRecoveryAddress:611, which has no lifecycle gate. One key holds the verdict, the payout destination, and the pool. The verdict is free: markCorrupted accepts no proof and calls _markBondClaimable (AttackRegistry.sol:333 -> BondManager.sol:219), which refunds the bond rather than slashing it.

  • What the call actually does: it deletes the pool's permissionless resolution and arms a 180-day fuse. With riskWindowStart sealed (any stake or poke during the normal UNDER_ATTACK phase), claimExpired's CORRUPTED branch returns before the EXPIRED branch is ever reached, so the staker-favourable outcome becomes structurally unreachable:

// ConfidencePool.sol:532 - once state == CORRUPTED && riskWindowStart != 0, this branch always wins
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
@> revert AgreementCorruptedAwaitingModerator(); // :541 - frozen
}
@> outcome = PoolStates.Outcome.CORRUPTED; // :543 - fuse burns
corruptedReserve = snapshotTotalStaked + snapshotTotalBonus; // :545
claimsStarted = true; // :549
...
return; // :554 - EXPIRED unreachable
}

So one free call converts "stakers recover principal + bonus at expiry" into "stakers recover nothing unless the DAO flags SURVIVED within 180 days of expiry". If the DAO does not, claimCorrupted:413 sweeps stakeToken.balanceOf(address(this)), every staker's principal and the whole bonus to the sponsor. withdraw is already latched shut throughout by riskWindowStart != 0.

Why the existing carve-outs do not cover this

  • §6 asks exactly this question and checks the wrong input. Its pokeRiskWindow subsection argues the loss cannot be manufactured: "The out-of-scope + absent-moderator loss above is the accepted consequence regardless of who seals the window. A poke does not 'manufacture' it." That audits one of the backstop's two inputs, the riskWindowStart seal and correctly finds it outcome-neutral. It never asks whether the other input, the CORRUPTED flag, can be manufactured. It can, by the beneficiary, for free.

  • §6 accepted consequence presupposes a real breach: "if the moderator is absent for the full grace window AND the breach was out-of-scope, stakers lose principal + bonus." That accepts an unlucky coincidence. No breach need occur, and the trigger is sponsor-held and profitable, a risk modelled as an accident is not an accepted risk when it is a cost-free action for the party who gains.

  • §10's "Sponsor trust surface" lists exactly three levers, recoveryAddress, expiry, scope and tells stakers to "verify pool parameters before depositing"; the CORRUPTED verdict is not among them and lives in the registry, where no pool parameter reveals it. §11's carve-out covers only a registry that is "bricked or repointed" and promises "No funds can be stolen... principal returned to stakers rather than swept", here the registry is honest and unmodified, and principal is swept. Across all three documents markCorrupted appears once (§1 line 23), noting which states it is reachable from, never who may call it.

Risk

Severity: Medium - High Impact x Low Likelihood.

Likelihood: Low. Deleting the EXPIRED path and arming the fuse is unilateral, free and certain. Completing the sweep additionally needs the DAO to not flag SURVIVED for the whole expiry + 180d window; a live moderator defeats it, and §6 asserts the backstop "should never fire in practice." That is a genuine barrier and the reason this is not filed higher. It is amplified by the moderator being unrotatable per-pool, outcomeModerator is written once at ConfidencePool.sol:207 with no setter, while the factory does expose setDefaultOutcomeModerator - so ordinary DAO key rotation turns "moderator absent" from a tail risk into routine key hygiene.

Impact: High. The sweep transfers every staker's principal plus the entire bonus to an address the sponsor controls, with no breach and no cost. Short of that, staker funds are unreachable for up to 180 days past expiry with no permissionless recovery, and the pool's fate is moved from a mechanical outcome to DAO discretion.

Proof of Concept

The identity collapse is checkable against BattleChain testnet (chainId 627) at block 16000 - no test needed:

AG=0xE550894617Ac4C1bbc019C2AA5D47495a0F07716 # live demo Agreement
AR=0xdD029a6374095EEb4c47a2364Ce1D0f47f007350 # live AttackRegistry
RPC=https://testnet.battlechain.com
cast call $AG "owner()(address)" --rpc-url $RPC --block 16000
# 0x3EfcFc441c1e70A141850D4da0d5C7314952Fc3d
cast call $AR "getAttackModerator(address)(address)" $AG --rpc-url $RPC --block 16000
# 0x3EfcFc441c1e70A141850D4da0d5C7314952Fc3d <- the same address

The agreement's owner, whom createPool:82,99 forces to be the pool's owner and recoveryAddress controller, is the party the registry lets declare that agreement corrupted

The pool-side consequence reproduces with no RPC against the existing BaseConfidencePoolTest harness. Both PASS, the first shows the fuse burning, the second the freeze before it:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
contract H2SponsorAuthoredCorruptionTest is BaseConfidencePoolTest {
function test_sponsorAuthoredCorruptionSweepsPoolToRecovery() public {
_stake(alice, 1000 * ONE);
_contributeBonus(bob, 500 * ONE);
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
vm.prank(alice);
pool.withdraw();
vm.warp(uint256(pool.expiry()) + pool.MODERATOR_CORRUPTED_GRACE());
pool.claimExpired();
pool.claimCorrupted();
assertEq(token.balanceOf(recovery), 1500 * ONE);
assertEq(token.balanceOf(alice), 0);
}
function test_sponsorAuthoredCorruptionFreezesPrincipalForGraceWindow() public {
_stake(alice, 1000 * ONE);
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.warp(uint256(pool.expiry()) + pool.MODERATOR_CORRUPTED_GRACE() - 1);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
vm.prank(alice);
pool.withdraw();
vm.expectRevert(IConfidencePool.AgreementCorruptedAwaitingModerator.selector);
vm.prank(alice);
pool.claimExpired();
assertEq(token.balanceOf(address(pool)), 1000 * ONE);
}
}

A fork run against the live testnet confirms the chain end-to-end: creating a real pool against the live SafeHarborRegistry as the real agreement owner, calling the real markCorrupted (state flips to CORRUPTED, bond refunded, no breach), then sweeping the pool to that same address after the grace window

Recommended Mitigation

The party holding the trigger must not be the party receiving the funds. Refuse the scope-blind backstop when the CORRUPTED attestation was authored by the sweep's beneficiary, and fall through to EXPIRED. IAttackRegistry.getAttackModerator already exposes the query.

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;
+ // The CORRUPTED flag is written by the agreement owner, which `createPool` forces
+ // to equal this pool's owner - the same key controlling `recoveryAddress`.
+ // Self-attested corruption cannot justify a sweep without independent judgement.
+ address attackRegistry = safeHarborRegistry.getAttackRegistry();
+ if (IAttackRegistry(attackRegistry).getAttackModerator(agreement) != owner()) {
+ outcome = PoolStates.Outcome.CORRUPTED;
+ outcomeFlaggedAt = riskWindowEnd;
+ corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
+ claimsStarted = true;
+ emit OutcomeFlagged(address(0), PoolStates.Outcome.CORRUPTED, false, address(0));
+ return;
+ }
}

This keeps the backstop's purpose, a permanently-absent moderator on a third-party-attested breach, while removing the sponsor's ability to pay themselves. The same predicate applied to the AgreementCorruptedAwaitingModerator deferral stops a self-authored flag stalling resolution. §6's "A poke does not 'manufacture' it" analysis should also be re-run against the CORRUPTED input, and §10 should disclose that the sponsor is the agreement's attack moderator

Support

FAQs

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

Give us feedback!