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

corruptedClaimDeadline silently truncates via unguarded uint32 downcast, permanently locking out the named whitehat

Author Revealed upon completion

Root + Impact

Description

  • corruptedClaimDeadline is computed as _firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW (180 days) and stored as uint32, with no bounds check before the cast. The contract guards its other three uint32 timestamp casts this way — expiry at both initialize() and setExpiry(), and riskWindowStart/riskWindowEnd via an explicit if (t > expiry) t = expiry; clamp — but this fourth cast site has neither a clamp nor a revert, only an inline comment acknowledging the gap.

  • If _firstGoodFaithCorruptedAt falls within 180 days of type(uint32).max (2106-02-07), the sum overflows uint32 and the explicit downcast silently wraps rather than reverting, since Solidity's overflow checks apply to arithmetic operators, not narrowing casts.

// Root cause in the codebase with @> marks to highlight the relevant section
if (willBeGoodFaithCorrupted)
{
if (_firstGoodFaithCorruptedAt == 0)
{
_firstGoodFaithCorruptedAt = uint32(block.timestamp);
}
@> // no bounds check before this cast, unlike the other three uint32 sites
corruptedClaimDeadline = uint32(_firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW);
}

Risk

Likelihood:

  • Occurs whenever a pool's expiry and its first good-faith CORRUPTED flag both land within roughly the final six months of the uint32 range — a state already exercised for unrelated purposes in the shipped test suite, but far off in calendar time from current deployments.


Impact:

  • The named whitehat's claimAttackerBounty() reverts on the first attempt, permanently losing their entitled bounty.

The 180-day exclusivity window collapses to zero — sweepUnclaimedCorrupted() becomes callable by anyone immediately, defeating the good-faith incentive.

Proof of Concept

The PoC reuses the identical near-uint32-ceiling setup already present in the shipped test suite (testQuadraticHoldsAtUint32TimestampCeiling), deploying a pool with expiry one second below the ceiling, staking, sealing the risk window, then flagging good-faith CORRUPTED at that boundary. It asserts corruptedClaimDeadline is now less than block.timestamp — proving the wrap — then shows the named attacker's claim reverts ClaimWindowExpired, and immediately after, sweepUnclaimedCorrupted() succeeds with no wait, confirming the exclusivity window is gone.

function test_PoC_CorruptedClaimDeadlineWrapsAndLocksOutNamedAttacker() external
{
uint256 nearCeilingExpiry = type(uint32).max - 1;
vm.warp(nearCeilingExpiry - 31 days);
ConfidencePool ceilPool = _deployPool();
pool = ceilPool;
_stake(alice, 100 * ONE);
_passThroughUnderAttack();
vm.warp(nearCeilingExpiry - 1);
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();
assertGt(token.balanceOf(recovery), recoveryBefore);
}

Recommended Mitigation

Add the same bounds-check-before-cast pattern already used at the expiry cast sites, reverting instead of wrapping when the deadline would exceed type(uint32).max.

+ error CorruptedClaimDeadlineOverflow();
...
if (willBeGoodFaithCorrupted)
{
if (_firstGoodFaithCorruptedAt == 0)
{
_firstGoodFaithCorruptedAt = uint32(block.timestamp);
}
+ uint256 rawDeadline = uint256(_firstGoodFaithCorruptedAt) + CORRUPTED_CLAIM_WINDOW;
+ if (rawDeadline > type(uint32).max) revert CorruptedClaimDeadlineOverflow();
- corruptedClaimDeadline = uint32(_firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW);
+ corruptedClaimDeadline = uint32(rawDeadline);
}

Support

FAQs

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

Give us feedback!