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

Good-faith CORRUPTED bounty window can wrap into the past near the uint32 timestamp ceiling

Author Revealed upon completion

Normal behavior: when the moderator flags a pool as good-faith CORRUPTED, the named attacker is entitled to claim the full pool bounty during CORRUPTED_CLAIM_WINDOW. Only after that 180-day window expires should anyone be able to call sweepUnclaimedCorrupted() and redirect unclaimed funds to recoveryAddress.

The issue is that _firstGoodFaithCorruptedAt and corruptedClaimDeadline are stored as uint32, while the deadline is computed by adding a 180-day uint256 window and then casting the result back to uint32. For good-faith CORRUPTED flags at or after 2105-08-11 06:28:16 UTC, the sum exceeds type(uint32).max and truncates into the past. This causes claimAttackerBounty() to revert immediately and lets sweepUnclaimedCorrupted() transfer the full bounty to recoveryAddress.

uint256 public constant override CORRUPTED_CLAIM_WINDOW = 180 days;
uint32 internal _firstGoodFaithCorruptedAt;
uint32 public override corruptedClaimDeadline;
function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
...
if (willBeGoodFaithCorrupted) {
if (_firstGoodFaithCorruptedAt == 0) {
_firstGoodFaithCorruptedAt = uint32(block.timestamp);
}
// @> The 180-day addition can exceed type(uint32).max.
// @> The explicit uint32 cast truncates the deadline into the past.
corruptedClaimDeadline = uint32(_firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW);
} else {
corruptedClaimDeadline = 0;
}
}
function claimAttackerBounty() external nonReentrant {
...
// @> The attacker cannot claim once the wrapped deadline is already below block.timestamp.
if (block.timestamp > corruptedClaimDeadline) revert ClaimWindowExpired();
...
}
function sweepUnclaimedCorrupted() external nonReentrant {
...
// @> The same wrapped deadline makes the recovery sweep available immediately.
if (block.timestamp <= corruptedClaimDeadline) revert ClaimWindowNotExpired();
uint256 amount = stakeToken.balanceOf(address(this));
...
stakeToken.safeTransfer(recoveryAddress, amount);
}

Risk

Likelihood:

Good-faith CORRUPTED flags at or after Unix timestamp 4279415296 (2105-08-11 06:28:16 UTC) compute a deadline larger than type(uint32).max, and the explicit cast truncates the deadline.

Practical exploitation before that date is not realistic because _firstGoodFaithCorruptedAt is derived from block.timestamp, and normal validator timestamp skew cannot move chain time forward by decades.

Impact:

The named attacker receives no usable good-faith claim period and claimAttackerBounty() reverts with ClaimWindowExpired.

Any caller can immediately call sweepUnclaimedCorrupted(), marking the bounty as claimed and transferring the full pool balance reserved for the bounty to recoveryAddress.

Proof of Concept

Save as test/poc/ConfidencePoolCorruptedDeadlineBoundary.t.sol and run:

forge test --match-path test/poc/ConfidencePoolCorruptedDeadlineBoundary.t.sol -vvv

Expected result: 1 passed; 0 failed.

// 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 {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
import {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
contract ConfidencePoolCorruptedDeadlineBoundaryPoC is Test {
uint256 internal constant ONE = 1e18;
uint256 internal constant ATTACKER_STAKE_ENTITLEMENT = 100 * ONE;
uint256 internal constant BONUS_ENTITLEMENT = 50 * ONE;
uint256 internal constant TOTAL_BOUNTY_ENTITLEMENT = ATTACKER_STAKE_ENTITLEMENT + BONUS_ENTITLEMENT;
// Any good-faith CORRUPTED flag at or after this timestamp makes
// block.timestamp + CORRUPTED_CLAIM_WINDOW exceed type(uint32).max.
uint256 internal constant FIRST_WRAPPING_FLAG_TIME = uint256(type(uint32).max) - 180 days + 1;
address internal constant DEFAULT_SCOPE_ACCOUNT = address(0xC0FFEE);
address internal moderator = makeAddr("moderator");
address internal recovery = makeAddr("recovery");
address internal attacker = makeAddr("attacker");
address internal staker = makeAddr("staker");
address internal bonusContributor = makeAddr("bonusContributor");
MockERC20 internal token;
MockAttackRegistry internal attackRegistry;
MockSafeHarborRegistry internal safeHarborRegistry;
MockAgreement internal agreement;
ConfidencePool internal pool;
function setUp() external {
token = new MockERC20();
attackRegistry = new MockAttackRegistry();
safeHarborRegistry = new MockSafeHarborRegistry();
agreement = new MockAgreement(address(this));
agreement.setContractInScope(DEFAULT_SCOPE_ACCOUNT, true);
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
safeHarborRegistry.setAgreementValid(address(agreement), true);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
}
function testGoodFaithCorruptedDeadlineWrapsNearUint32CeilingAndRedirectsBounty() external {
uint256 flagTime = FIRST_WRAPPING_FLAG_TIME;
vm.warp(flagTime - 1 days);
_deployPool();
_stake(staker, ATTACKER_STAKE_ENTITLEMENT);
_contributeBonus(bonusContributor, BONUS_ENTITLEMENT);
vm.warp(flagTime);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pool.bountyEntitlement(), TOTAL_BOUNTY_ENTITLEMENT, "attacker should receive whole pool");
assertLt(pool.corruptedClaimDeadline(), block.timestamp, "deadline wrapped into the past");
vm.prank(attacker);
vm.expectRevert(IConfidencePool.ClaimWindowExpired.selector);
pool.claimAttackerBounty();
uint256 recoveryBalanceBefore = token.balanceOf(recovery);
pool.sweepUnclaimedCorrupted();
assertEq(
token.balanceOf(recovery) - recoveryBalanceBefore,
TOTAL_BOUNTY_ENTITLEMENT,
"sweep redirects full bounty to recovery"
);
assertEq(token.balanceOf(attacker), 0, "named attacker receives nothing");
assertEq(pool.bountyClaimed(), TOTAL_BOUNTY_ENTITLEMENT, "bounty is marked claimed after sweep");
}
function _deployPool() internal {
ConfidencePool implementation = new ConfidencePool();
pool = ConfidencePool(Clones.clone(address(implementation)));
pool.initialize(
address(agreement),
address(token),
address(safeHarborRegistry),
moderator,
block.timestamp + 31 days,
ONE,
recovery,
address(this),
_defaultScope()
);
}
function _stake(address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(pool), amount);
pool.stake(amount);
vm.stopPrank();
}
function _contributeBonus(address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(pool), amount);
pool.contributeBonus(amount);
vm.stopPrank();
}
function _defaultScope() internal pure returns (address[] memory accounts) {
accounts = new address[](1);
accounts[0] = DEFAULT_SCOPE_ACCOUNT;
}
}

Recommended Mitigation

Store the good-faith corrupted timestamp and claim deadline as uint256, or reject flags that cannot represent the full 180-day claim window.

- uint32 internal _firstGoodFaithCorruptedAt;
- uint32 public override corruptedClaimDeadline;
+ uint256 internal _firstGoodFaithCorruptedAt;
+ uint256 public override corruptedClaimDeadline;
if (willBeGoodFaithCorrupted) {
if (_firstGoodFaithCorruptedAt == 0) {
- _firstGoodFaithCorruptedAt = uint32(block.timestamp);
+ _firstGoodFaithCorruptedAt = block.timestamp;
}
- corruptedClaimDeadline = uint32(_firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW);
+ corruptedClaimDeadline = _firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW;
}
- function corruptedClaimDeadline() external view returns (uint32);
+ function corruptedClaimDeadline() external view returns (uint256);

Support

FAQs

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

Give us feedback!