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

corruptedClaimDeadline uint32 wrap-around forecloses whitehat bounty window when first good-faith CORRUPTED flag is mined near the 2106 ceiling

Author Revealed upon completion

Description

Root + Impact

Normal behavior: When the moderator flags outcome = CORRUPTED with goodFaith = true and names a whitehat, the contract records corruptedClaimDeadline = _firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW (180 days). The whitehat then has that window to call claimAttackerBounty(), design-intent per docs/DESIGN.md §12. After the window, sweepUnclaimedCorrupted() is permissionlessly callable to recover any unclaimed remainder to recoveryAddress.

The specific issue: in ConfidencePool.flagOutcome, both _firstGoodFaithCorruptedAt (line 366) and corruptedClaimDeadline (line 372) are cast to uint32 without any cap on block.timestamp, unlike the neighboring _markRiskWindowStart (t = min(block.timestamp, expiry) at line 807) and _markRiskWindowEnd (line 824) which were deliberately capped to keep their uint32 casts in range. When the first good-faith CORRUPTED flag is mined with block.timestamp > uint32.max - 180 days (activation window 2105-08-11 UTC through 2106-02-07 UTC), the addition in line 372 wraps modulo 2^32, storing a deadline deep in 1970 — well below the current block.timestamp. As a result:

  • claimAttackerBounty() reverts ClaimWindowExpired immediately at line 437 (comparison uint256 > uint32-zero-extended resolves with block.timestamp > deadline),

  • sweepUnclaimedCorrupted() becomes permissionlessly callable at the same block (line 459 gate block.timestamp <= corruptedClaimDeadline fails),

  • the whitehat's entire bounty (the whole pool: snapshotTotalStaked + snapshotTotalBonus) is swept to recoveryAddress in the same block as the flag.

ConfidencePool.sol
363: if (willBeGoodFaithCorrupted) {
364: if (_firstGoodFaithCorruptedAt == 0) {
365: // forge-lint: disable-next-line(unsafe-typecast)
366: _firstGoodFaithCorruptedAt = uint32(block.timestamp); // @> no cap on block.timestamp (contrast with lines 807 & 824)
367: }
368: // Reuses the original window on re-entry — which may already be in the past, leaving
369: // nothing to claim. Intended: the deadline must never be extendable.
370: // Sum stays in uint32 unless flagged within 180 days of the 2106 ceiling; out of scope.
371: // forge-lint: disable-next-line(unsafe-typecast)
372: corruptedClaimDeadline = uint32(_firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW); // @> wraps to 1970 in the [uint32.max - 180d, uint32.max] window
373: } else {

Risk

Likelihood:

  • Activation window is fixed and deterministic: any first good-faith CORRUPTED flagOutcome whose block.timestamp falls in [4_279_415_295, 4_294_967_295] (i.e. 2105-08-11 06:28:15 UTC through 2106-02-07 06:28:15 UTC, exactly the last 180 days of uint32 seconds) triggers the bug. flagOutcome has no upper-bound check on block.timestamp, and expiry may legitimately be set up to type(uint32).max (ExpiryTooFar check at line 197 only enforces expiry_ <= uint32.max), so a pool can still be unresolved when the chain reaches the activation window.

  • The stored deadline wrap is one-directional: it always lands below block.timestamp for any t in the activation window (t + 180d - 2^32 ∈ [~63K, ~12.3M], all ≪ t ≈ 4.3e9), so the bug deterministically fires rather than being probabilistic.

Impact:

  • The whitehat named by the moderator receives zero bounty — claimAttackerBounty() reverts ClaimWindowExpired even in the same block as the flag, contradicting the documented CORRUPTED_CLAIM_WINDOW = 180 days reservation in protocol-readme.md L95 and docs/DESIGN.md §12 ("the pool is reserved for the named whitehat for CORRUPTED_CLAIM_WINDOW (180 days)").

  • The whole pool (snapshotTotalStaked + snapshotTotalBonus, plus any donation dust) is permissionlessly swept to recoveryAddress by any caller via sweepUnclaimedCorrupted() — funds the moderator explicitly intended for the whitehat go to the recovery address instead.

  • The state is unrecoverable without a contract upgrade: _firstGoodFaithCorruptedAt is single-write (if (_firstGoodFaithCorruptedAt == 0) at line 364), so every subsequent moderator re-flag recomputes corruptedClaimDeadline from the same wrapped base at line 372 and stays broken; no on-chain action by the moderator, staker, or whitehat can restore the 180-day window.


Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
contract CorruptedDeadlineWrapPoC is Test {
uint256 constant ONE = 1e18;
address constant SCOPE = address(0xC0FFEE);
uint32 constant U32_MAX = type(uint32).max; // 2106-02-07 06:28:15 UTC
MockERC20 token;
MockAttackRegistry attackRegistry;
MockSafeHarborRegistry safeHarborRegistry;
MockAgreement agreementContract;
ConfidencePool pool;
address agreement;
address moderator = makeAddr("moderator");
address recovery = makeAddr("recovery");
address whitehat = makeAddr("whitehat");
address alice = makeAddr("alice");
address sponsor = makeAddr("sponsor");
address bystander = makeAddr("bystander");
function setUp() public {
token = new MockERC20();
attackRegistry = new MockAttackRegistry();
safeHarborRegistry = new MockSafeHarborRegistry();
agreementContract = new MockAgreement(address(this));
agreement = address(agreementContract);
agreementContract.setContractInScope(SCOPE, true);
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
safeHarborRegistry.setAgreementValid(agreement, true);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
// Stake in 2025-12-01 — far inside uint32, all pre-attack state fits cleanly.
vm.warp(1_764_547_200);
address[] memory scope = new address[](1);
scope[0] = SCOPE;
ConfidencePool impl = new ConfidencePool();
pool = ConfidencePool(Clones.clone(address(impl)));
// Longest expiry the factory/init allows: the uint32 ceiling itself (passes ExpiryTooFar).
pool.initialize(agreement, address(token), address(safeHarborRegistry),
moderator, U32_MAX, ONE, recovery, address(this), scope);
}
function test_PoC_corruptedClaimDeadline_uint32Wrap() public {
// 1. alice stakes 100; sponsor adds 50 bonus. Pool holds 150.
token.mint(alice, 100 * ONE);
vm.startPrank(alice);
token.approve(address(pool), 100 * ONE);
pool.stake(100 * ONE);
vm.stopPrank();
token.mint(sponsor, 50 * ONE);
vm.startPrank(sponsor);
token.approve(address(pool), 50 * ONE);
pool.contributeBonus(50 * ONE);
vm.stopPrank();
assertEq(token.balanceOf(address(pool)), 150 * ONE);
// 2. Registry: UNDER_ATTACK (seal riskWindowStart) -> CORRUPTED.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// 3. Warp into the bug window: 2105-12-01, in (uint32.max - 180d, uint32.max).
uint256 flagTime = 4_289_068_800; // 2105-12-01 00:00 UTC
assertGt(flagTime, uint256(U32_MAX) - 180 days, "inside bug window");
assertLt(flagTime, uint256(U32_MAX), "still fits uint32 — no general uint32 break");
vm.warp(flagTime);
// 4. Trusted moderator explicitly intends to pay the whitehat the full pool for 180 days.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
// 5. BUG: stored deadline wrapped into the 1970s, below flagTime.
uint32 storedDeadline = pool.corruptedClaimDeadline();
assertLt(uint256(storedDeadline), flagTime, "deadline wrapped below flagTime");
// 6. Whitehat's bounty call reverts in the same block — zero claim window.
vm.prank(whitehat);
vm.expectRevert(IConfidencePool.ClaimWindowExpired.selector);
pool.claimAttackerBounty();
// 7. Any bystander sweeps the entire pool to recoveryAddress immediately.
vm.prank(bystander);
pool.sweepUnclaimedCorrupted();
// 8. Whitehat got nothing; recovery got the 150 the moderator earmarked for whitehat.
assertEq(token.balanceOf(whitehat), 0);
assertEq(token.balanceOf(recovery), 150 * ONE);
}
}

Run: forge test --match-test test_PoC_corruptedClaimDeadline_uint32Wrap -vv


Recommended Mitigation

Apply the same expiry-cap pattern that already exists in _markRiskWindowStart / _markRiskWindowEnd (lines 807, 824). Either bound block.timestamp to expiry (the pool's own lifecycle) or guard the addition against the uint32 ceiling:

if (willBeGoodFaithCorrupted) {
if (_firstGoodFaithCorruptedAt == 0) {
- // forge-lint: disable-next-line(unsafe-typecast)
- _firstGoodFaithCorruptedAt = uint32(block.timestamp);
+ uint256 t = block.timestamp;
+ if (t > expiry) t = expiry;
+ if (t > type(uint32).max - CORRUPTED_CLAIM_WINDOW) {
+ t = type(uint32).max - CORRUPTED_CLAIM_WINDOW;
+ }
+ // forge-lint: disable-next-line(unsafe-typecast)
+ _firstGoodFaithCorruptedAt = uint32(t);
}
// 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);
+ // forge-lint: disable-next-line(unsafe-typecast)
+ corruptedClaimDeadline = uint32(uint256(_firstGoodFaithCorruptedAt) + CORRUPTED_CLAIM_WINDOW);
} else {
corruptedClaimDeadline = 0;
}

This widens the addition to uint256 (no wrap) and keeps the stored uint32 value in range by clamping block.timestamp first — matching the existing min(t, expiry) approach used for riskWindowStart / riskWindowEnd.

Support

FAQs

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

Give us feedback!