Root + Impact
Description
The first good-faith CORRUPTED flag adds a 180-day claim window to a uint32 timestamp and narrows the sum back to uint32. Near the Unix-time ceiling this wraps the deadline into the past, immediately blocking the attacker and allowing recovery to sweep the entire pool.
A good-faith attacker should have CORRUPTED_CLAIM_WINDOW seconds after the first qualifying outcome flag to claim the snapshotted stake and bonus. Until that deadline, claimAttackerBounty() is open and sweepUnclaimedCorrupted() is closed.
The initializer and setExpiry() accept an expiry through type(uint32).max, but the later deadline calculation does not reserve 180 days of headroom. The addition is evaluated in uint256 and then explicitly truncated. For any first flag after type(uint32).max - 180 days, the stored deadline is (flagTime + 180 days) mod 2^32, which is lower than the current timestamp.
src/ConfidencePool.sol
function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_)
external
onlyModerator
{
if (willBeGoodFaithCorrupted) {
if (_firstGoodFaithCorruptedAt == 0) {
_firstGoodFaithCorruptedAt = uint32(block.timestamp);
}
corruptedClaimDeadline = uint32(_firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW);
} else {
corruptedClaimDeadline = 0;
}
outcomeFlaggedAt = riskWindowEnd;
emit OutcomeFlagged(msg.sender, newOutcome, goodFaith_, attacker_);
}
function claimAttackerBounty() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
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();
uint256 amount = stakeToken.balanceOf(address(this));
if (amount == 0) revert NothingToSweep();
stakeToken.safeTransfer(recoveryAddress, amount);
emit UnclaimedCorruptedSwept(msg.sender, recoveryAddress, amount);
}
The last safe first-flag timestamp is 4,279,415,295 (2105-08-11 06:28:15 UTC). At the next second the intended 180-day window becomes a past timestamp. The attacker loses an entitlement that can equal the pool's complete balance, and any caller can immediately finalize that forfeiture to recoveryAddress.
Risk
Likelihood:
-
The defect occurs deterministically when a pool is first flagged good-faith CORRUPTED during the final 180 days of the uint32 Unix-time range.
-
The affected range begins in August 2105, making practical exposure extremely remote, although the current setters explicitly accept pool terms reaching the boundary.
Impact:
-
The designated attacker loses the entire promised claim window and cannot collect any of bountyEntitlement.
-
The full pool balance becomes immediately sweepable to the sponsor-controlled recovery address.
Proof of Concept
Create test/audit/CP010Uint32ClaimDeadlineWrap.t.sol with the following contents:
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract CP010Uint32ClaimDeadlineWrapTest is BaseConfidencePoolTest {
function test_DeadlineWrapBlocksAttackerAndEnablesImmediateSweep() external {
pool.setExpiry(type(uint32).max);
_stake(alice, 100 * ONE);
vm.warp(uint256(type(uint32).max) - 100 days);
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertLt(block.timestamp, pool.expiry(), "the accepted pool term has not expired");
assertLt(pool.corruptedClaimDeadline(), block.timestamp, "the uint32 deadline wrapped into the past");
assertEq(pool.bountyEntitlement(), 100 * ONE);
vm.prank(attacker);
vm.expectRevert(IConfidencePool.ClaimWindowExpired.selector);
pool.claimAttackerBounty();
uint256 recoveryBefore = token.balanceOf(recovery);
pool.sweepUnclaimedCorrupted();
assertEq(token.balanceOf(recovery) - recoveryBefore, 100 * ONE);
assertEq(token.balanceOf(attacker), 0);
assertEq(pool.bountyClaimed(), pool.bountyEntitlement());
}
}
Run the PoC from the repository root:
-
Execute forge test --offline --match-path test/audit/CP010Uint32ClaimDeadlineWrap.t.sol -vv.
-
Confirm that the test passes, the stored deadline is already below block.timestamp, the attacker claim reverts, and recovery receives all 100 tokens immediately.
Recommended Mitigation
Store the first flag timestamp and claim deadline in a wider type and update the public interface accordingly. Widening is preferable to lowering only the maximum expiry because flagOutcome() may execute after expiry.
src/ConfidencePool.sol
-uint32 internal _firstGoodFaithCorruptedAt;
-uint32 public override corruptedClaimDeadline;
+uint64 internal _firstGoodFaithCorruptedAt;
+uint64 public override corruptedClaimDeadline;
function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_)
external
onlyModerator
{
// ... validation and snapshot logic omitted ...
if (willBeGoodFaithCorrupted) {
if (_firstGoodFaithCorruptedAt == 0) {
- _firstGoodFaithCorruptedAt = uint32(block.timestamp);
+ _firstGoodFaithCorruptedAt = uint64(block.timestamp);
}
- corruptedClaimDeadline = uint32(_firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW);
+ corruptedClaimDeadline = _firstGoodFaithCorruptedAt + uint64(CORRUPTED_CLAIM_WINDOW);
} else {
corruptedClaimDeadline = 0;
}
outcomeFlaggedAt = riskWindowEnd;
emit OutcomeFlagged(msg.sender, newOutcome, goodFaith_, attacker_);
}
src/interfaces/IConfidencePool.sol
-function corruptedClaimDeadline() external view returns (uint32);
+function corruptedClaimDeadline() external view returns (uint64);