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

Agreement's attackModerator defaults to the Sponsor, letting them unilaterally self-declare CORRUPTED and steal the pool's full principal + bonus -- with NO pool-moderator involvement required at all

Author Revealed upon completion

Root + Impact

Description

  • flagOutcome(CORRUPTED, ...) is meant to reflect a genuine security breach, confirmed by the moderator's off-chain judgement against the live AttackRegistry's CORRUPTED state. The permissionless claimExpired() auto-resolution fallback exists purely as a backstop for a PERMANENTLY-ABSENT moderator, scope-blind by design and documented as staker-pessimistic but rare in practice.

  • The registry's CORRUPTED signal is set via AttackRegistry.markCorrupted(), gated only by onlyAttackModerator -- a role that defaults to the agreement's OWNER at registration, with zero on-chain proof of an exploit required. Since ConfidencePoolFactory.createPool requires the Sponsor to be the Agreement owner, the Sponsor is -- by default -- the same address authorized to unilaterally flip their OWN agreement to CORRUPTED, after just one routine DAO approveAttack() call.

    Critically, this does NOT require the pool's own outcomeModerator to be deceived or complicit at all: if the moderator simply does nothing, the permissionless claimExpired() auto-CORRUPTED fallback mechanically finalizes bad-faith CORRUPTED on its own once expiry + MODERATOR_CORRUPTED_GRACE (180 days) elapses -- callable by anyone, including the Sponsor. The moderator's only defense is to PROACTIVELY investigate and flag SURVIVED (explicitly permitted even when the registry shows CORRUPTED, as the "out of scope" escape hatch) before that window closes -- amplified further since a single markCorrupted() call compromises every pool backed by the same agreement.

    Verified live on BattleChain testnet right now: bondToken/verifiedBondAmount/feeAmount are all zero (path is free), and the project's own demo agreement (already DAO-approved, UNDER_ATTACK) already has attackModerator == owner.

} else if (newOutcome == PoolStates.Outcome.CORRUPTED) {
if (!goodFaith_) {
if (attacker_ != address(0)) revert InvalidGoodFaithParams();
}
@> if (state != IAttackRegistry.ContractState.CORRUPTED) revert InvalidOutcome();
}
// claimExpired()'s PERMISSIONLESS fallback -- needs NO moderator at all:
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
@> outcome = PoolStates.Outcome.CORRUPTED; // fires with ZERO flagOutcome() calls, ever
claimsStarted = true;
return;
}
function claimCorrupted() external nonReentrant {
...
@> uint256 toSweep = stakeToken.balanceOf(address(this));
stakeToken.safeTransfer(recoveryAddress, toSweep);
}
// Dependency root cause: AttackRegistry._registerAgreement
// s_agreementInfo[agreementAddress] = AgreementInfo({
@> // attackModerator: agreementOwner, <-- defaults to the Sponsor
// ...
// });

Risk

Likelihood:

  • Reason 1 Confirmed live right now: bondToken, verifiedBondAmount, and feeAmount are all zero on the real BattleChain testnet BondManager -- the entire path is free. Confirmed live on the project's own real, DAO-approved demo agreement (currently UNDER_ATTACK): getAttackModerator() returns the exact same address as owner().

  • Reason 2 The attack succeeds even with a completely passive pool moderator -- no deception, no complicity required. Every pool where the moderator doesn't proactively investigate and flag SURVIVED within 180 days of expiry resolves this way automatically, amplified for free across every pool the Sponsor backs with the same self-corrupted agreement.

Impact:

  • Impact 1 Steals stakers' full LOCKED PRINCIPAL, not just bonus -- once risk genuinely opens, withdraw() is permanently disabled, so stakers can't protect themselves even after real risk is confirmed on-chain.

  • Impact 2 Requires zero ongoing cooperation from any honest party after the initial routine DAO approval -- the mechanical 180-day auto-resolve path means the Sponsor doesn't even need to successfully deceive anyone, just wait out a passive moderator.

Proof of Concept

Verified at three escalating levels. (1) Base mechanism (local PoC): real risk window opens, withdraw() locks, registry set to CORRUPTED (== self-declared markCorrupted()), moderator honestly flags bad-faith CORRUPTED, claimCorrupted() sweeps 1500. (2) Escalation -- zero moderator involvement (local PoC): moderator NEVER calls flagOutcome at all; after expiry + 180 days, ANY random address calls claimExpired() -- outcome mechanically resolves CORRUPTED with zero moderator involvement, ever; claimCorrupted() still sweeps 1500. A control test confirms the one real defense (active SURVIVED override) does work if the moderator actually investigates in time. (3) Live BattleChain testnet: read-only confirms the real demo agreement's attackModerator == owner right now; a real signed requestUnderAttack() tx (0xb862d84d1bea8438cdaf3205a2930a99d769cd5bad35298b61aef6bfaf09180b, gasUsed 701660, success) on our own fresh agreement confirms the path is free and sets attackModerator to us. Stopped before approveAttack()/markCorrupted() since that requires the real DAO, which we don't control and won't bypass.
function test_PoC_passiveModerator_autoCorruptedStillDrainsPool() external {
_stake(alice, 1_000 * ONE);
address donor = makeAddr("genuineBonusDonor");
_contributeBonus(donor, 500 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// pool's outcomeModerator does NOTHING -- no flagOutcome call, ever, in either direction
vm.warp(pool.expiry() + pool.MODERATOR_CORRUPTED_GRACE() + 1);
vm.prank(makeAddr("anyoneAtAll"));
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED));
pool.claimCorrupted();
// recoveryAddress gains 1500 * ONE -- 100% of principal + bonus, zero moderator involvement
}

Recommended Mitigation

Require the Sponsor to transfer attackModerator away from themselves as an on-chain precondition of pool creation. Separately, consider requiring an ACTIVE moderator confirmation before the auto-CORRUPTED fallback can fire, since total moderator silence currently defaults to the worst outcome for stakers.
if (!safeHarborRegistry.isAgreementValid(agreement)) revert InvalidAgreement();
if (IAgreement(agreement).owner() != msg.sender) revert UnauthorizedCreator();
+ address attackRegistry = safeHarborRegistry.getAttackRegistry();
+ if (IAttackRegistry(attackRegistry).getAttackModerator(agreement) == msg.sender) {
+ revert SponsorCannotBeAttackModerator();
+ }
markCorrupted() sets state at the AGREEMENT level, not per-pool. A Sponsor backing several pools with the same agreement can drain ALL of them with a single markCorrupted() call, at no extra on-chain cost.

Support

FAQs

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

Give us feedback!