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

Good-faith CORRUPTED deadline wraps and skips the whitehat bounty window

Author Revealed upon completion

Summary

In the good-faith CORRUPTED path, the moderator names a whitehat attacker and the pool reserves the full bounty for that attacker during a 180-day claim window. The bug is an integer truncation edge case: the first good-faith CORRUPTED timestamp and corruptedClaimDeadline are stored as uint32, and the deadline is computed by casting _firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW back to uint32.

Near the uint32 timestamp ceiling, adding the 180-day window wraps the deadline into the past. This is not theft by an arbitrary attacker, but it breaks the intended bounty reservation and immediately enables the recovery sweep for a good-faith CORRUPTED outcome.

Affected Contract

File: src/ConfidencePool.sol

Lines: 151-155

Function/Type: _firstGoodFaithCorruptedAt / corruptedClaimDeadline

File: src/ConfidencePool.sol

Lines: 322-379

Function/Type: flagOutcome(PoolStates.Outcome,bool,address)

File: src/ConfidencePool.sol

Lines: 432-453

Function/Type: claimAttackerBounty()

File: src/ConfidencePool.sol

Lines: 456-471

Function/Type: sweepUnclaimedCorrupted()

Vulnerability Details

The moderator can flag a pool as good-faith CORRUPTED and provide the named attacker address. On the first good-faith CORRUPTED flag, the pool stores the current timestamp in _firstGoodFaithCorruptedAt as uint32. It then sets corruptedClaimDeadline by adding CORRUPTED_CLAIM_WINDOW and casting the sum back down to uint32.

When the first good-faith CORRUPTED flag occurs within 180 days of type(uint32).max, the deadline wraps below the current timestamp. claimAttackerBounty() then treats the claim window as expired, while sweepUnclaimedCorrupted() treats the same wrapped deadline as already passed. The named attacker cannot claim, and the full pool balance can be swept to recoveryAddress.

Root Cause

The root cause is storing the corrupted bounty deadline in uint32 and truncating a timestamp plus a 180-day window back into that type.

// File: src/ConfidencePool.sol:151-155
/// @notice `block.timestamp` of the first-ever good-faith CORRUPTED flag.
uint32 internal _firstGoodFaithCorruptedAt;
uint32 public override corruptedClaimDeadline;
// File: src/ConfidencePool.sol:363-374
if (willBeGoodFaithCorrupted) {
if (_firstGoodFaithCorruptedAt == 0) {
_firstGoodFaithCorruptedAt = uint32(block.timestamp);
}
corruptedClaimDeadline = uint32(_firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW);
} else {
corruptedClaimDeadline = 0;
}
// File: src/ConfidencePool.sol:432-459
function claimAttackerBounty() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
if (bountyClaimed == bountyEntitlement) revert BountyAlreadyClaimed();
if (!goodFaith) revert InvalidGoodFaithParams();
if (msg.sender != attacker) revert NotAttacker();
if (block.timestamp > corruptedClaimDeadline) revert ClaimWindowExpired();
...
}
function sweepUnclaimedCorrupted() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
if (!goodFaith) revert NotGoodFaithCorrupted();
if (block.timestamp <= corruptedClaimDeadline) revert ClaimWindowNotExpired();
...
}

Attack Vectors

Attack Vector 1: Wrapped good-faith deadline

Step 1 A pool exists close to type(uint32).max.
Step 2 The registry reaches CORRUPTED.
Step 3 The moderator flags the pool as good-faith CORRUPTED and names the attacker.
Result: corruptedClaimDeadline wraps below the current timestamp, so the named attacker immediately fails the deadline check.

Attack Vector 2: Immediate recovery sweep

Step 1 The good-faith CORRUPTED flag stores a wrapped deadline in the past.
Step 2 The named attacker attempts claimAttackerBounty() and reverts with ClaimWindowExpired.
Step 3 Any caller invokes sweepUnclaimedCorrupted().
Step 4 The sweep sees block.timestamp > corruptedClaimDeadline.
Result: The full pool balance is transferred to recoveryAddress without the intended 180-day bounty reservation.

Execution Flow

  • The pool is flagged as CORRUPTED with goodFaith_ == true.

  • flagOutcome() stores _firstGoodFaithCorruptedAt = uint32(block.timestamp).

  • flagOutcome() computes corruptedClaimDeadline = uint32(_firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW).

  • Near the uint32 ceiling, the cast truncates the deadline to a timestamp lower than block.timestamp.

  • claimAttackerBounty() reverts because block.timestamp > corruptedClaimDeadline.

  • sweepUnclaimedCorrupted() succeeds because block.timestamp <= corruptedClaimDeadline is false.

Impact

This is a Low severity time-bound edge case. It does not give an arbitrary caller the ability to select the outcome, but it can fully bypass the promised 180-day claim window for a named good-faith attacker. The practical result is that the whitehat loses the full bounty and the recovery address receives the pool immediately.

The issue should still be fixed because the contract stores pool lifecycle values as uint32 and explicitly operates near that timestamp domain. Deadline arithmetic should not silently wrap when the protocol advertises a fixed 180-day reservation window.

Validation steps

Standalone Validation

Framework used: Foundry / forge

Validation Test File

File 1: test/poc/ConfidencePoolOverflowPoC.t.sol

function testPoC_uint32DeadlineWrapSkipsGoodFaithAttackerClaimWindow() external {
vm.warp(uint256(type(uint32).max) - 31 days);
pool = _deployPool();
_stake(alice, 100 * ONE);
_contributeBonus(carol, 10 * ONE);
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertLt(pool.corruptedClaimDeadline(), block.timestamp, "deadline wrapped below current time");
vm.prank(attacker);
vm.expectRevert(IConfidencePool.ClaimWindowExpired.selector);
pool.claimAttackerBounty();
uint256 recoveryBefore = token.balanceOf(recovery);
pool.sweepUnclaimedCorrupted();
assertEq(token.balanceOf(recovery) - recoveryBefore, 110 * ONE);
assertEq(token.balanceOf(attacker), 0);
}

How to Run

forge test --match-path test/poc/ConfidencePoolOverflowPoC.t.sol -vvv

Captured Test Output

Ran 4 tests for test/poc/ConfidencePoolOverflowPoC.t.sol:ConfidencePoolOverflowPoCTest
[PASS] testPoC_largeAcceptedStakeBlocksExpiredClaimsWhileStillActiveRisk() (gas: 471886)
[PASS] testPoC_largeAcceptedStakeLocksAllSurvivedClaims() (gas: 657267)
[PASS] testPoC_largeAcceptedStakePreventsRiskWindowObservationAndForfeitsBonus() (gas: 663042)
[PASS] testPoC_uint32DeadlineWrapSkipsGoodFaithAttackerClaimWindow() (gas: 3495551)
Suite result: ok. 4 passed; 0 failed; 0 skipped

Log-to-Impact Walkthrough

The passing PoC warps the timestamp to 31 days before type(uint32).max, flags good-faith CORRUPTED, and proves the stored deadline is below the current timestamp. The attacker claim then reverts as expired, while the recovery sweep transfers 110 * ONE and leaves the attacker with zero balance.

Key Assertions Proven

assertLt(pool.corruptedClaimDeadline(), block.timestamp) -> proves the deadline wrapped into the past -> PASS
vm.expectRevert(IConfidencePool.ClaimWindowExpired.selector) -> proves the named attacker cannot use the intended claim window -> PASS
assertEq(token.balanceOf(recovery) - recoveryBefore, 110 * ONE) -> proves the full pool can be swept immediately to recovery -> PASS

Recommended Fix

Store the first good-faith corrupted timestamp and claim deadline as uint256, or explicitly reject flags whose full 180-day deadline cannot be represented. Using uint256 for deadline arithmetic is the simpler fix and preserves the documented claim window.

- uint32 internal _firstGoodFaithCorruptedAt;
- uint32 public override corruptedClaimDeadline;
+ uint256 internal _firstGoodFaithCorruptedAt;
+ 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;
}

Support

FAQs

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

Give us feedback!