FoundrySolidityLayer 2
7.25 ETH
View results
Submission Details
Impact: low
Likelihood: low
Invalid

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

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.

Updates

Lead Judging Commences

inallhonesty Lead Judge 22 days ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
inallhonesty Lead Judge 22 days ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Appeal created

ilvastaganow7 Submitter
21 days ago
ilvastaganow7 Submitter
20 days ago
ilvastaganow7 Submitter
20 days ago
inallhonesty Lead Judge
19 days ago
inallhonesty Lead Judge 18 days ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

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

Give us feedback!