Root + Impact
Description
For good-faith CORRUPTED outcomes, the intended behavior is that the named attacker has CORRUPTED_CLAIM_WINDOW to claim the bounty before unclaimed funds can be swept to recoveryAddress.
The issue is that the deadline is stored as uint32 after adding a 180 day window to the current timestamp. If the first good-faith corrupted flag is made near the uint32 timestamp ceiling, the addition wraps the deadline into the past. The attacker's claim window is therefore already expired in the same block, and anyone can immediately sweep the bounty to recoveryAddress.
function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_)
external
nonReentrant
onlyModerator
{
...
if (willBeGoodFaithCorrupted) {
if (_firstGoodFaithCorruptedAt == 0) {
_firstGoodFaithCorruptedAt = uint32(block.timestamp);
}
corruptedClaimDeadline = uint32(_firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW);
} else {
corruptedClaimDeadline = 0;
}
outcomeFlaggedAt = riskWindowEnd;
...
}
function claimAttackerBounty() external nonReentrant {
...
if (block.timestamp > corruptedClaimDeadline) revert ClaimWindowExpired();
...
}
function sweepUnclaimedCorrupted() external nonReentrant {
...
if (block.timestamp <= corruptedClaimDeadline) revert ClaimWindowNotExpired();
...
}
Risk
Likelihood:
-
This occurs when a good-faith corrupted outcome is first flagged within CORRUPTED_CLAIM_WINDOW of the uint32 timestamp ceiling.
-
This occurs before the attacker claims, because the wrapped corruptedClaimDeadline is lower than the current block timestamp immediately after flagOutcome().
Impact:
-
The named good-faith attacker cannot claim the bounty, even though the moderator named them as eligible.
-
The full good-faith corrupted bounty can be swept to recoveryAddress immediately instead of after the intended claim window.
Proof of Concept
Add this test to test/unit/ConfidencePool.branches.t.sol:
function testGoodFaithCorruptedDeadlineWrapsNearUint32Ceiling() external {
uint256 nearUint32Ceiling = uint256(type(uint32).max) - 31 days;
vm.warp(nearUint32Ceiling);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
pool = _deployPool();
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
_passThroughUnderAttack();
vm.warp(uint256(type(uint32).max) - 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertLt(pool.corruptedClaimDeadline(), block.timestamp);
vm.prank(attacker);
vm.expectRevert(IConfidencePool.ClaimWindowExpired.selector);
pool.claimAttackerBounty();
uint256 recoveryBefore = token.balanceOf(recovery);
pool.sweepUnclaimedCorrupted();
assertEq(token.balanceOf(recovery) - recoveryBefore, 150 * ONE);
assertEq(pool.bountyClaimed(), pool.bountyEntitlement());
}
Run:
forge test --match-test testGoodFaithCorruptedDeadlineWrapsNearUint32Ceiling -vvv
Expected result:
[PASS] testGoodFaithCorruptedDeadlineWrapsNearUint32Ceiling()
Recommended Mitigation
Store corrupted claim deadlines in uint256 and validate that the computed deadline does not exceed the supported timestamp range.
- uint32 public override corruptedClaimDeadline;
+ uint256 public override corruptedClaimDeadline;
if (willBeGoodFaithCorrupted) {
if (_firstGoodFaithCorruptedAt == 0) {
- _firstGoodFaithCorruptedAt = uint32(block.timestamp);
+ _firstGoodFaithCorruptedAt = block.timestamp;
}
- corruptedClaimDeadline = uint32(_firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW);
+ corruptedClaimDeadline = _firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW;
} else {
corruptedClaimDeadline = 0;
}
If packed storage is required, reject pool creation or outcome flagging when block.timestamp + CORRUPTED_CLAIM_WINDOW cannot fit in uint32.