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

Auto-CORRUPTED backstop trusts a sponsor-fabricable precondition: the sponsor is their own agreement's attackModerator, so DESIGN §6/§11's "out-of-scope misfortune" framing understates moderator liveness as the sole control against deliberate theft

Author Revealed upon completion

Description

  • A Confidence Pool settles CORRUPTED only when the outcomeModerator says so. As a liveness backstop, claimExpired() lets anyone mechanically finalize a bad-faith CORRUPTED once the agreement's registry state is CORRUPTED and the moderator has stayed silent for expiry + MODERATOR_CORRUPTED_GRACE (180 days), sweeping the entire pool to the sponsor-set recoveryAddress so funds are not trapped when the DAO becomes permanently unavailable. DESIGN.md §6 accepts the resulting principal loss, and §11 places a compromised registry out of model.

  • The backstop treats registry-CORRUPTED as ground truth, but the sponsor is their own agreement's attackModerator and can therefore declare that state with no breach whatsoever. AttackRegistry._registerAgreement assigns attackModerator = agreementOwner, and markCorrupted is gated on that role alone, taking no breach evidence. The same address is also the pool creator (ConfidencePoolFactory.createPool requires IAgreement(agreement).owner() == msg.sender) and sets recoveryAddress with no constraint separating it from the agreement owner. One untrusted party thus holds the trigger, the destination, and the profit motive. §6 documents this loss as following a genuine breach that happened to be out-of-scope — an external accident; §11 covers only a registry compromised or repointed to report false state. Neither describes the actual vector: the agreement owner legitimately exercising their own registry authority to declare a fabricated corruption, with the registry behaving exactly as designed and no trusted entity compromised. The moderator-liveness assumption is therefore load-bearing against deliberate, single-party-controlled theft, not merely the passive misfortune the docs describe.

// src/ConfidencePool.sol:529-555 — claimExpired(), the permissionless auto-CORRUPTED backstop
// address(0) moderator marks a mechanical auto-resolution (no decision-maker).
// Auto-CORRUPTED requires both registry CORRUPTED and an observed risk window;
// otherwise falls through to EXPIRED (returns principal).
@> if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
// Scope-blind by design: this forces CORRUPTED for any corrupted agreement, even
// one whose breach was out-of-scope (where the moderator would have flagged
// SURVIVED). See MODERATOR_CORRUPTED_GRACE for the trust assumption this encodes.
//
// @> The encoded trust assumption is that registry-CORRUPTED implies a real breach.
// @> It does not: the agreement OWNER is that agreement's `attackModerator`
// @> (AttackRegistry._registerAgreement: attackModerator = agreementOwner) and
// @> `markCorrupted` requires no breach evidence — only the role and the state.
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
@> outcome = PoolStates.Outcome.CORRUPTED; // fabricated state accepted as ground truth
outcomeFlaggedAt = riskWindowEnd;
corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
claimsStarted = true;
emit OutcomeFlagged(address(0), PoolStates.Outcome.CORRUPTED, false, address(0));
return;
}
// src/ConfidencePool.sol:408-426 — claimCorrupted(), the sweep
function claimCorrupted() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
...
uint256 toSweep = stakeToken.balanceOf(address(this)); // whole pool: principal + bonus
...
@> stakeToken.safeTransfer(recoveryAddress, toSweep); // destination set by the same sponsor
}
// src/ConfidencePool.sol:611-618 — nothing separates the destination from the fabricator
function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
@> // no constraint that newRecoveryAddress != IAgreement(agreement).owner()
recoveryAddress = newRecoveryAddress;
...
}

Risk

Likelihood:

  • The role concentration holds for every pool created through ConfidencePoolFactory, by construction: createPool requires IAgreement(agreement).owner() == msg.sender, and AttackRegistry._registerAgreement assigns attackModerator = agreementOwner. The sponsor is always their own agreement's attack moderator — this is the default state of every deployment, not a misconfiguration.

  • markCorrupted succeeds on role plus registry state alone and takes no breach evidence, so a sponsor who has reached UNDER_ATTACK declares corruption at will. Reaching UNDER_ATTACK runs through the DAO's approveAttack, which is the normal pool lifecycle — that approval is how the risk window opens in the first place, and it vouches for the attack request, not against a later fabrication.

  • riskWindowStart gets sealed by any ordinary pool interaction or the permissionless pokeRiskWindow() during the UNDER_ATTACK interval — routine, and stakers are economically incentivised to seal it for their own bonus.

  • Realised loss additionally requires the outcomeModerator to remain absent for the entire expiry + 180 days. This is the sole remaining guardrail and it is a liveness assumption rather than a code control — which is precisely why this is filed as a documentation / threat-model gap at Low, not as a code defect. A present moderator defeats the vector completely (proven below).

Impact:

  • Stakers who price a pool from DESIGN.md §6 materially underestimate the risk they are underwriting: the docs present the CORRUPTED precondition as a genuine-but-out-of-scope breach, so moderator liveness reads as a backstop against misfortune. It is in fact the only control standing against a sponsor-controlled, on-demand theft lever whose trigger, destination and beneficiary are one untrusted address. This changes the character of the assumption, and greatly raises its likelihood, without being documented as such.

  • When the outcomeModerator stays absent for the full grace window, the fabricated path routes 100% of staker principal plus bonus to the sponsor's recoveryAddress, the sponsor having risked only the (typically far smaller) bonus as bait — a profitable, deliberate theft rather than the accident §6 describes.

  • No code control distinguishes an owner-self-declared corruption from a DAO-adjudicated one, and nothing prevents recoveryAddress == IAgreement(agreement).owner() — the configuration that makes the lever profitable is the default one, and stakers are given no documented signal to treat it as a red flag.

Proof of Concept

Runnable suite: test/unit/ZZZSponsorFabricatedCorrupted.poc.t.sol — 3 tests, all passing under forge test (solc 0.8.26, via-IR, optimizer 200). Registry states are staged on MockAttackRegistry to represent the outcome of the real chain the sponsor drives (UNDER_ATTACK == DAO approveAttack; CORRUPTED == sponsor markCorrupted); the real-stack reachability of markCorrupted with no breach is covered by the dependency's own passing AttackRegistryTest.testMarkCorrupted().

function test_theft_sponsorFabricatedCorrupted_moderatorAbsent_sweepsPrincipal() public {
uint256 P = 100 ether; // honest staker principal
uint256 B = 40 ether; // bonus (sponsor's bait)
_stake(alice, P);
_contributeBonus(bob, B);
_sealUnderAttack(); // DAO approve (routine) + permissionless poke seals riskWindowStart
_fabricateCorrupted(); // sponsor markCorrupted — fabricated, no breach
// No shortcut: before expiry+grace the backstop defers to the (still-possible) moderator.
vm.warp(uint256(pool.expiry()) + 1);
vm.prank(alice);
vm.expectRevert(IConfidencePool.AgreementCorruptedAwaitingModerator.selector);
pool.claimExpired();
// Moderator absent for the entire 180-day grace.
vm.warp(uint256(pool.expiry()) + pool.MODERATOR_CORRUPTED_GRACE() + 1);
vm.prank(alice);
pool.claimExpired(); // permissionless auto bad-faith CORRUPTED
pool.claimCorrupted(); // sweep
assertEq(token.balanceOf(recovery), P + B, "THEFT: entire pool -> sponsor recoveryAddress");
assertEq(token.balanceOf(alice), 0, "staker principal lost");
}

The other two legs bound the claim honestly rather than inflate it:

  • test_refuted_presentModerator_flagsSurvived_refundsStakers — mid-grace, an honest moderator's flagOutcome(SURVIVED) (which accepts a CORRUPTED registry) refunds alice the full P + B, recoveryAddress receives 0, and the later claimExpired() reverts InvalidOutcome. The sponsor can neither pre-empt, block, nor hijack this valve (outcomeModerator is init-only, no setter).

  • test_noShortcut_autoCorruptedBlockedForEntireGrace — the backstop cannot fire one second before expiry + grace.

So the vector is real and reachable, and it is fully defeated by a present moderator. The finding is the undocumented character of that dependency, not a claim that the guardrail is breakable.

Recommended Mitigation

The scope-blind backstop is deliberate — re-defaulting it to EXPIRED would let genuine in-scope corruption escape. The fix should therefore target the role concentration, not the backstop's default. Breaking the trigger↔destination link is a two-line change that makes the lever unprofitable while leaving the backstop's liveness purpose intact:

// src/ConfidencePool.sol — setRecoveryAddress (and the equivalent assignment in initialize)
function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
+ // The agreement owner is that agreement's `attackModerator` in the registry and can
+ // therefore declare CORRUPTED with no breach. Routing the bad-faith sweep back to that
+ // same party concentrates trigger, destination and profit in one untrusted address.
+ if (newRecoveryAddress == IAgreement(agreement).owner()) revert InvalidRecoveryAddress();
address oldRecoveryAddress = recoveryAddress;
recoveryAddress = newRecoveryAddress;
emit RecoveryAddressUpdated(oldRecoveryAddress, newRecoveryAddress);
}

(IAgreement is already imported at ConfidencePool.sol:17 and agreement is the public state var at :61; the pool already calls IAgreement(agreement).isContractInScope() at :768, so this compiles as-is.)

Stronger alternative — route the permissionless auto-CORRUPTED sweep to a neutral or timelocked escrow rather than directly to the sponsor-set address, leaving the moderator-flagged CORRUPTED path unchanged.

At minimum, document it:

// docs/DESIGN.md §6
- The auto-CORRUPTED backstop is scope-blind: a pool can resolve CORRUPTED on a breach that
- was out of its own scope, where the moderator would have flagged SURVIVED. Stakers lose
- principal only when the moderator is absent for the full grace window.
+ The auto-CORRUPTED backstop is scope-blind: a pool can resolve CORRUPTED on a breach that
+ was out of its own scope, where the moderator would have flagged SURVIVED. Stakers lose
+ principal only when the moderator is absent for the full grace window.
+
+ SECURITY-CRITICAL: the CORRUPTED precondition is sponsor-fabricable. The agreement owner
+ is that agreement's `attackModerator` in the AttackRegistry and can call `markCorrupted`
+ with no breach at all. Moderator liveness is therefore load-bearing against DELIBERATE,
+ sponsor-controlled theft — not only against the accidental out-of-scope loss described
+ above. Stakers should treat `recoveryAddress == agreement.owner()` as a red flag before
+ depositing.

Support

FAQs

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

Give us feedback!