FoundrySolidityLayer 2
7.25 ETH
Submission Details
Severity: medium
Valid

Sponsor = registry attackModerator; can forge CORRUPTED with no breach and drain 100% of staker principal to own recoveryAddress

Author Revealed upon completion

Description

// @audit sponsor = agreement owner = attackModerator; can forge CORRUPTED with no breach
// @audit ConfidencePoolFactory.sol:82 forces msg.sender == agreement.owner()
// @audit AttackRegistry.sol:793 sets attackModerator = agreementOwner
// @audit markCorrupted requires NO breach proof

ConfidencePoolFactory.createPool forces msg.sender == agreement.owner() at line 82. The BattleChain AttackRegistry sets attackModerator = agreement.owner() at registration. markCorrupted() is onlyAttackModerator and accepts zero breach proof: just the agreement address. The pool sponsor, who controls recoveryAddress, IS the registry's per-agreement attackModerator.

// @audit Sponsor forced to equal agreement owner
if (IAgreement(agreement).owner() != msg.sender) revert UnauthorizedCreator();
// @audit Agreement owner IS attackModerator (OOS dependency)
attackModerator = agreementOwner;
// @audit markCorrupted requires NO breach proof (OOS)
function markCorrupted(address agreement) external onlyAttackModerator { ... }
// @audit CORRUPTED gate reads registry state alone — same gate for all payouts
if (state != IAttackRegistry.ContractState.CORRUPTED) revert InvalidOutcome();
// @audit Auto-CORRUPTED backstop trusts registry unconditionally
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
outcome = PoolStates.Outcome.CORRUPTED;
claimsStarted = true;
// @audit claimCorrupted sweeps full balance to recoveryAddress — permissionless
uint256 toSweep = stakeToken.balanceOf(address(this));
stakeToken.safeTransfer(recoveryAddress, toSweep);

The sponsor can forge CORRUPTED with no real breach, then sweep 100% of staker principal + bonus to their own recoveryAddress via two paths. Slow path: after expiry + 180d, permissionless claimExpired auto-resolves bad-faith CORRUPTED. Fast path: the moderator, following §5's instruction, flags CORRUPTED immediately — no grace period. The only non-sponsor step is DAO approveAttack, which is indistinguishable from honest protocol onboarding. DESIGN.md §6 and §11 do not cover this: §6 assumes a real breach, §11 covers only the DAO-controlled registry pointer, not the per-agreement attackModerator.

Proof of Concept

The PoC below traces the full attack: stakers are withdraw-trapped after riskWindowStart seals, claimExpired auto-CORRUPTED fires after the grace period, the moderator's SURVIVED rescue is foreclosed by claimsStarted, and 100% of principal reaches recoveryAddress.

// PoC: test/unit/PocSponsorForgedCorruptedDrain.t.sol
vm.warp(riskWindowTime);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
vm.warp(expiry + 180 days + 1);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED));
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 sponsorBefore = token.balanceOf(recoveryAddress);
pool.claimCorrupted();
assertEq(token.balanceOf(recoveryAddress) - sponsorBefore, 100 ether + bonus);

Risk

The sponsor holds both roles by compile-time invariant (createPool forces msg.sender == agreement.owner(); the registry sets attackModerator = agreementOwner). The only non-sponsor step, DAO approveAttack, is indistinguishable from honest protocol onboarding. Every pool's sponsor IS its agreement's attackModerator.

Impact

100% of staker principal + bonus drained to sponsor-controlled recoveryAddress with no real breach. Scales to full TVL of any pool.

Recommended Mitigation

Gate the auto-CORRUPTED backstop on a sponsor-independent moderator attestation so a forged registry signal cannot trigger principal sweeps.

function claimExpired() external nonReentrant {
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
+ if (!moderatorAttested[agreement]) {
+ outcome = PoolStates.Outcome.EXPIRED;
+ claimsStarted = true;
+ return;
+ }
outcome = PoolStates.Outcome.CORRUPTED;
}
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 2 days ago
Submission Judgement Published
Validated
Assigned finding tags:

Sponsor is their own agreement's attackModerator, so a no-breach markCorrupted() lets claimExpired's auto-CORRUPTED backstop sweep the whole pool to their recoveryAddress

Impact- High Every token in the pool leaves: all staker principal plus the entire bonus, sent to an address the sponsor picked and can still change after people have deposited. Stakers have no defense once it starts, because withdraw is dead the moment riskWindowStart seals, and the mechanical resolution sets claimsStarted, which locks the moderator out of any later correction. Likelihood - Very Low The sponsor holds the role by default on every pool, and markCorrupted carries no penalty since it leaves the bond claimable, so arming this is free and unilateral once the DAO has approved the agreement into UNDER_ATTACK. What it still needs is the pool's outcome moderator staying silent for the whole term plus the 180-day grace, and one flagOutcome(SURVIVED) call anywhere in that stretch defeats it outright.

Support

FAQs

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

Give us feedback!