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

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

Support

FAQs

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

Give us feedback!