FoundrySolidityLayer 2
7.25 ETH
Submission Details
Severity: medium
Valid

Permissionless auto-CORRUPTED backstop trusts a sponsor-controlled registry flag, letting a malicious sponsor sweep all staker funds

Author Revealed upon completion

Root + Impact

Description

  • When the pool's DAO moderator is unavailable, anyone may call claimExpired after expiry + MODERATOR_CORRUPTED_GRACE; if the registry reads CORRUPTED, the pool mechanically resolves bad-faith CORRUPTED and claimCorrupted sweeps the entire pool (staker principal + bonus) to recoveryAddress. The pool treats a CORRUPTED registry state as proof of a real breach.

  • The registry's CORRUPTED flag is set by markCorrupted, gated by onlyAttackModerator, and attackModerator defaults to the agreement owner — the same entity that is the pool sponsor and controls recoveryAddress. markCorrupted requires no proof of an attack and returns the bond rather than slashing it, so a sponsor can self-certify CORRUPTED for free and drain the pool to themselves.

// src/ConfidencePool.sol — claimExpired() auto-CORRUPTED backstop
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) revert AgreementCorruptedAwaitingModerator();
@> outcome = PoolStates.Outcome.CORRUPTED; // no check on WHO set CORRUPTED
@> corruptedReserve = snapshotTotalStaked + snapshotTotalBonus; // whole pool → recoveryAddress
claimsStarted = true;
}
// lib/battlechain-safe-harbor-contracts/src/AttackRegistry.sol
@> function markCorrupted(address a) external onlyAttackModerator(a) { // attackModerator == agreementOwner
// no attestation that an attack actually happened
s_agreementInfo[a].corrupted = true;
@> _markBondClaimable(a); // bond RETURNED, not slashed
}
// _registerAgreement: s_agreementInfo[agreementAddress].attackModerator = agreementOwner;

Risk

Likelihood:

  • The registry assigns attackModerator = agreementOwner at registration, and that owner is the pool sponsor who also controls recoveryAddress — one entity holds every role needed.

  • The pool's DAO outcomeModerator stays inactive through the full pool term plus the 180-day grace (the exact scenario the permissionless backstop exists for).

Impact:

  • A malicious sponsor sweeps 100% of staker principal and bonus to their own recoveryAddress with no real breach.

  • The protocol's core guarantee — that only a genuine, DAO-attested breach moves staker funds to recovery — is broken.

Proof of Concept

Mechanism verified against the real dependency (AttackRegistry.markCorrupted is onlyAttackModerator;
_registerAgreement sets attackModerator = agreementOwner; markCorrupted calls _markBondClaimable).
The pool-side mechanical sweep is exercised in
test/fuzz/ConfidencePool.mitigationBreak.t.sol:

function test_escalate_KI1_pokeMitigationConvertsEscapeToCorrupted() external {
_stake(attackerStaker, 100 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow(); // sponsor self-seals riskWindowStart
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED); // sponsor markCorrupted()
vm.warp(uint256(pool.expiry()) + 180 days + 1); // moderator absent through grace
pool.claimExpired(); // mechanical bad-faith CORRUPTED
assertEq(uint8(pool.outcome()), uint8(PoolStates.Outcome.CORRUPTED));
pool.claimCorrupted();
assertEq(token.balanceOf(recovery), 100 * ONE); // full pool swept to sponsor-controlled recovery
}

Recommended Mitigation

Refuse the permissionless auto-CORRUPTED path when the registry's attack moderator is the agreement owner
(i.e. not an independent party):

if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) revert AgreementCorruptedAwaitingModerator();
+ // A sponsor-set CORRUPTED is not trustworthy for the scope-blind mechanical backstop.
+ if (IAttackRegistry(safeHarborRegistry.getAttackRegistry()).getAttackModerator(agreement) == IAgreement(agreement).owner())
+ revert AgreementCorruptedAwaitingModerator();
outcome = PoolStates.Outcome.CORRUPTED;
corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
claimsStarted = true;
}
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!