Root + Impact
Description
After a successful in-scope attack, the upstream AttackRegistry must first be changed to CORRUPTED. The ConfidencePool moderator can then mark the pool as good-faith CORRUPTED and assign the pool bounty to the whitehat.
The Agreement owner is assigned as the initial AttackRegistry moderator when the Agreement is registered:
s_agreementInfo[agreementAddress] = AgreementInfo({
attackModerator: agreementOwner,
deadlineTimestamp: block.timestamp + PROMOTION_WINDOW,
promotionRequestedTimestamp: 0,
attackRequested: true,
attackApproved: false,
promoted: false,
corrupted: false,
isRegistered: true
});
The attack moderator publishes a successful attack by calling markCorrupted():
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;
}
This creates a gap between a real exploit and the on-chain CORRUPTED signal. During that gap, the ConfidencePool moderator cannot flag the correct outcome. flagOutcome(CORRUPTED) requires the upstream state to already be CORRUPTED:
function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_)
external
onlyModerator
{
if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) {
revert OutcomeAlreadySet();
}
IAttackRegistry.ContractState state = _observePoolState();
if (newOutcome == PoolStates.Outcome.CORRUPTED) {
if (state != IAttackRegistry.ContractState.CORRUPTED) revert InvalidOutcome();
}
}
At the pool expiry, anyone can call claimExpired(). When the registry still reports UNDER_ATTACK or PROMOTION_REQUESTED, the function resolves the pool as EXPIRED and immediately sets claimsStarted:
if (state == IAttackRegistry.ContractState.PRODUCTION) {
outcome = PoolStates.Outcome.SURVIVED;
outcomeFlaggedAt = riskWindowEnd;
} else {
outcome = PoolStates.Outcome.EXPIRED;
outcomeFlaggedAt = expiry;
}
claimsStarted = true;
The sponsor can therefore delay markCorrupted() until after claimExpired():
A whitehat completes a successful in-scope attack shortly before the pool expiry.
The sponsor still controls the upstream attack-moderator role and leaves the Agreement as UNDER_ATTACK.
The sponsor can do it to not pay an attacker at all or to save his own staked amount in the confidence pool.
At expiry, the sponsor or any other account calls claimExpired().
The pool becomes EXPIRED, starts claims, and pays stakers.
The sponsor publishes the real CORRUPTED state afterward.
The pool moderator tries to flag good-faith CORRUPTED, but the call reverts with OutcomeAlreadySet.
The whitehat receives nothing from the ConfidencePool.
There is no point where the pool moderator can apply the correct result:
Before claimExpired: registry is UNDER_ATTACK -> flagOutcome reverts with InvalidOutcome
After claimExpired: claimsStarted is true -> flagOutcome reverts with OutcomeAlreadySet
If the sponsor contributed a bonus, then the sponsor can also recover most of its own bonus when the pool has not observed the risk window before the final minute. A large sponsor stake becomes the first observation, which clamps all earlier stakes to the same effective entry time. The k=2 time factor is then equal for all stakers, so the bonus is split by stake amount.
For example:
Existing stake: 10,000 tokens
Sponsor-funded bonus: 100,000 tokens
Sponsor's late stake: 990,000 tokens
Time until expiry: 60 seconds
The sponsor owns 99% of the total stake and receives 99% of the bonus. The whitehat receives zero from the ConfidencePool. This bonus recovery is an impact amplifier, it is not required for the incorrect expiry result.
Risk
Likelihood:
A successful in-scope attack must occur shortly before the pool expiry.
The pool must still be unresolved when expiry is reached.
The upstream state must remain UNDER_ATTACK or PROMOTION_REQUESTED until claimExpired() executes.
Deliberate sponsor control requires the sponsor to still hold the AttackRegistry moderator role, which is the default assignment but can be transferred.
Impact:
A good-faith whitehat can lose the entire ConfidencePool bounty.
The pool pays stakers under EXPIRED even though a covered attack succeeded before expiry.
With an unobserved risk window and enough capital, the sponsor can recover most of the bonus through a late stake.
The separate Safe Harbor Agreement bounty is not affected by this finding.
Proof of Concept
Add the following test to test/unit/DelayedCorruptionExpiry.t.sol and run:
forge test --match-contract DelayedCorruptionExpiryTest -vv
pragma solidity 0.8.26;
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract DelayedCorruptionExpiryTest is BaseConfidencePoolTest {
function testPoC_sponsorRecoversBonusBeforePublishingCorruption() external {
address sponsor = carol;
_stake(alice, 10_000 * ONE);
_contributeBonus(sponsor, 100_000 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
assertEq(pool.riskWindowStart(), 0);
vm.warp(pool.expiry() - 60);
_stake(sponsor, 990_000 * ONE);
assertEq(pool.riskWindowStart(), pool.expiry() - 60);
vm.warp(pool.expiry());
vm.prank(sponsor);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED));
assertTrue(pool.claimsStarted());
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
vm.prank(alice);
pool.claimExpired();
assertEq(token.balanceOf(alice), 11_000 * ONE, "Alice gets principal plus 1% of bonus");
assertEq(token.balanceOf(sponsor), 1_089_000 * ONE, "sponsor recovers stake plus 99% of bonus");
assertEq(token.balanceOf(attacker), 0, "whitehat receives no ConfidencePool bounty");
}
}
Recommended Mitigation
One practical fix is to add a short post-expiry challenge window so an unresolved pool cannot immediately pay out while the Agreement is still attackable.
uint32 public expiredAt;
uint32 public challengeDeadline;
uint32 public constant EXPIRY_CHALLENGE_WINDOW = 3 days;
function claimExpired() external nonReentrant {
if (block.timestamp < expiry) revert PoolNotExpired();
IAttackRegistry.ContractState state = _observePoolState();
if (
outcome == PoolStates.Outcome.UNRESOLVED
&& (state == IAttackRegistry.ContractState.UNDER_ATTACK
|| state == IAttackRegistry.ContractState.PROMOTION_REQUESTED)
) {
if (expiredAt == 0) {
expiredAt = uint32(block.timestamp);
challengeDeadline = uint32(block.timestamp + EXPIRY_CHALLENGE_WINDOW);
}
revert ChallengeWindowActive();
}
}
function finalizeExpired() external nonReentrant {
if (expiredAt == 0 || block.timestamp < challengeDeadline) revert ChallengeWindowActive();
if (outcome != PoolStates.Outcome.UNRESOLVED) revert InvalidOutcome();
}