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

corruptedClaimDeadline` truncates at the `uint32` ceiling, so a pool valid at construction hands the whitehat's entire bounty to `recoveryAddress`

Author Revealed upon completion

Root + Impact

Root causeflagOutcome computes corruptedClaimDeadline = uint32(_firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW) (src/ConfidencePool.sol:372). The addition widens to uint256, but the explicit uint32(...) cast truncates silently rather than reverting, so any good-faith CORRUPTED flag raised within 180 days of the uint32 ceiling (2106-02-07) wraps the deadline back to 1970.

ImpactclaimAttackerBounty gates on block.timestamp > corruptedClaimDeadline (:437), so the named whitehat's 180-day window is expired on arrival and their claim reverts ClaimWindowExpired. sweepUnclaimedCorrupted then transfers the entire pool to recoveryAddress. The whitehat's full entitlement is redirected to the sponsor.

Description

  • CORRUPTED_CLAIM_WINDOW is 180 days, and _firstGoodFaithCorruptedAt anchors the deadline once so the moderator cannot mint a fresh window by toggling the outcome. Every other uint32 timestamp in the contract is bounded by a guarded setter — expiry is range-checked at :625 (newExpiry > type(uint32).max reverts ExpiryTooFar) and the risk-window marks are capped at expiry.

  • The CORRUPTED-path deadline has no such bound. The contract-level natspec claims CORRUPTED-path timestamps are "bounded by block.timestamp + 180 days" — but that sum is precisely the expression that overflows, and the cast that consumes it truncates instead of reverting.

  • The in-code comment at :370 declares this "out of scope" on the grounds that it only fires "within 180 days of the 2106 ceiling." The PoC below defeats that reasoning: it constructs a pool whose 31-day expiry sits exactly at the ceiling and which passes every guard the contract enforces — the _MIN_EXPIRY_LEAD 30-day check and the ExpiryTooFar ceiling check both succeed. The contract therefore accepts a configuration whose bounty it structurally cannot pay. The window is not merely shortened; it lands ~4.28 billion seconds in the past.

// src/ConfidencePool.sol:363-374 — flagOutcome
if (willBeGoodFaithCorrupted) {
if (_firstGoodFaithCorruptedAt == 0) {
@> _firstGoodFaithCorruptedAt = uint32(block.timestamp);
}
// Reuses the original window on re-entry — which may already be in the past, leaving
// nothing to claim. Intended: the deadline must never be extendable.
@> // Sum stays in uint32 unless flagged within 180 days of the 2106 ceiling; out of scope.
// forge-lint: disable-next-line(unsafe-typecast)
@> corruptedClaimDeadline = uint32(_firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW);
} else {
corruptedClaimDeadline = 0;
}
// src/ConfidencePool.sol:437 — claimAttackerBounty consumes the wrapped value
@> if (block.timestamp > corruptedClaimDeadline) revert ClaimWindowExpired();

Risk

Likelihood:

  • Fires only when a good-faith CORRUPTED flag is raised within 180 days of 2106-02-07. _firstGoodFaithCorruptedAt is real block.timestamp, so there is no way to reach the condition early — this is ~80 years out, and is the sole reason the rating is Low rather than High.

  • Within that window it is not an edge case but the only possible outcome: every good-faith CORRUPTED flag wraps, with no configuration that avoids it.

  • The pool that triggers it is valid by the contract's own rules, so nothing at construction or configuration time warns the sponsor, the contributor, or the whitehat.

Impact:

  • The named whitehat loses 100% of bountyEntitlement (snapshotTotalStaked + snapshotTotalBonus — the entire pool per docs/DESIGN.md §12). Their claim reverts on the first attempt.

  • Those funds do not stay locked; sweepUnclaimedCorrupted routes the whole pool to recoveryAddress. The good-faith disclosure incentive inverts: the sponsor whose contracts were breached collects the bounty owed to the researcher who disclosed it.

  • Bounded by the fact that the contract's uint32 timestamp scheme is inherently unusable past 2106 regardless, so this is a sharp edge on an already-declared horizon rather than an independent lifetime limit.

Proof of Concept

Executed against the repo's harness. Passes on unmodified source:

forge test --match-path test/unit/AuditPoC.t.sol --match-test corruptedClaimDeadline -vv
[PASS] test_poc_corruptedClaimDeadline_wrapsAtUint32Ceiling() (gas: 3420016)
Suite result: ok. 1 passed; 0 failed; 0 skipped
function test_poc_corruptedClaimDeadline_wrapsAtUint32Ceiling() public {
uint256 ceiling = type(uint32).max;
vm.warp(ceiling - 31 days);
// This is a valid pool: its 31-day expiry is exactly the uint32 ceiling, while the
// minimum 30-day lead-time check still passes.
ConfidencePool nearCeiling = ConfidencePool(Clones.clone(address(new ConfidencePool())));
nearCeiling.initialize(
agreement,
address(token),
address(safeHarborRegistry),
moderator,
ceiling,
ONE,
recovery,
address(this),
_defaultScope()
);
_stakeInto(nearCeiling, alice, 10 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
nearCeiling.pokeRiskWindow();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
nearCeiling.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
// `firstGoodFaithCorruptedAt + 180 days` exceeds uint32 and the explicit cast wraps it
// to a timestamp in 1970, even though the pool accepted a valid expiry at the ceiling.
assertLt(
uint256(nearCeiling.corruptedClaimDeadline()),
block.timestamp,
"DEFECT: 180-day bounty deadline wrapped into the past"
);
vm.prank(attacker);
vm.expectRevert(IConfidencePool.ClaimWindowExpired.selector);
nearCeiling.claimAttackerBounty();
uint256 recoveryBefore = token.balanceOf(recovery);
nearCeiling.sweepUnclaimedCorrupted();
assertEq(
token.balanceOf(recovery) - recoveryBefore,
10 * ONE,
"wrapped deadline lets recovery sweep immediately"
);
}

Recommended Mitigation

Saturate the deadline at the uint32 ceiling rather than truncating past it, so the window shortens gracefully instead of inverting. This keeps the "never extendable" property the existing comment protects, since the clamp can only ever lower the value.

// Reuses the original window on re-entry — which may already be in the past, leaving
// nothing to claim. Intended: the deadline must never be extendable.
- // Sum stays in uint32 unless flagged within 180 days of the 2106 ceiling; out of scope.
- // forge-lint: disable-next-line(unsafe-typecast)
- corruptedClaimDeadline = uint32(_firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW);
+ // Saturate rather than truncate: past the uint32 ceiling the cast would wrap the
+ // deadline into 1970 and expire the whitehat's window on arrival.
+ uint256 deadline = _firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW;
+ corruptedClaimDeadline =
+ deadline > type(uint32).max ? type(uint32).max : uint32(deadline);

Alternatively, reject the unpayable configuration at the boundary require expiry + CORRUPTED_CLAIM_WINDOW <= type(uint32).max in initialize / setExpiry so a pool that cannot honor its own bounty is never created.

Support

FAQs

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

Give us feedback!