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

A truncated good-faith deadline can immediately redirect the full attacker bounty to recovery

Author Revealed upon completion

Description

  • Normally, a good-faith CORRUPTED outcome gives the named attacker an exclusive 180-day window to claim the pool’s snapshotted principal and bonus. Recovery may sweep the remainder only after that deadline.

  • The first good-faith timestamp is stored as uint32. The contract adds the uint256 180-day window and explicitly narrows the result back to uint32 without checking that the derived deadline fits.

  • During the final 180 days before type(uint32).max, the deadline wraps into the past. The attacker’s claim immediately expires, while sweepUnclaimedCorrupted immediately transfers the full pool to
    recoveryAddress.

if (willBeGoodFaithCorrupted) {
if (_firstGoodFaithCorruptedAt == 0) {
_firstGoodFaithCorruptedAt = uint32(block.timestamp);
}
// @> The addition occurs in uint256 and succeeds.
// @> The explicit uint32 conversion then silently truncates
// @> a deadline greater than type(uint32).max.
corruptedClaimDeadline =
uint32(
_firstGoodFaithCorruptedAt
+ CORRUPTED_CLAIM_WINDOW
);
}
The wrapped value is trusted by both beneficiary paths:
function claimAttackerBounty() external nonReentrant {
// @> A wrapped deadline is already less than block.timestamp.
if (block.timestamp > corruptedClaimDeadline) {
revert ClaimWindowExpired();
}
// ...
}
function sweepUnclaimedCorrupted() external nonReentrant {
// @> The same wrapped value immediately enables recovery.
if (block.timestamp <= corruptedClaimDeadline) {
revert ClaimWindowNotExpired();
}
uint256 amount = stakeToken.balanceOf(address(this));
stakeToken.safeTransfer(recoveryAddress, amount);
}

Risk

Likelihood: Very Low

  • This occurs when the first good-faith CORRUPTED flag is submitted after timestamp 4,279,415,295, during the final 180 days of the uint32 domain.

  • This affects valid pools because the factory requires only 30 days of expiry headroom, leaving sufficient time to create and fund a pool at the exact wrapping boundary.

Impact:

  • The correctly named attacker loses the entire bounty without receiving the promised exclusive claim window.

  • Any caller can immediately redirect the full principal-plus-bonus balance to recoveryAddress.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {ConfidencePoolFactory} from "src/ConfidencePoolFactory.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
/// "Good-faith claim deadline truncation immediately redirects the attacker's full bounty to recovery".
contract GoodFaithClaimDeadlineTruncationImmediatelyRedirectsTheAttackersFullBountyToRecoveryPoC is
BaseConfidencePoolTest
{
uint256 internal constant CLAIM_WINDOW = 180 days;
function test_PoC_ExactFirstWrapRedirectsFullBountyToRecovery() external {
uint256 lastSafeTimestamp = uint256(type(uint32).max) - CLAIM_WINDOW;
uint256 firstWrappingTimestamp = lastSafeTimestamp + 1;
// The exact previous second works as intended.
ConfidencePool safePool = _createFactoryPoolAt(lastSafeTimestamp);
_fundAndFlagGoodFaith(safePool, attacker);
assertEq(safePool.corruptedClaimDeadline(), type(uint32).max);
vm.expectRevert(IConfidencePool.ClaimWindowNotExpired.selector);
safePool.sweepUnclaimedCorrupted();
vm.prank(attacker);
safePool.claimAttackerBounty();
assertEq(token.balanceOf(attacker), 100 ether);
// One second later, firstGoodFaith + 180 days is 2^32 and truncates to zero.
address wrappedAttacker = makeAddr("wrappedAttacker");
ConfidencePool wrappedPool = _createFactoryPoolAt(firstWrappingTimestamp);
_fundAndFlagGoodFaith(wrappedPool, wrappedAttacker);
assertEq(wrappedPool.corruptedClaimDeadline(), 0);
vm.prank(wrappedAttacker);
vm.expectRevert(IConfidencePool.ClaimWindowExpired.selector);
wrappedPool.claimAttackerBounty();
vm.expectRevert(IConfidencePool.MustClaimBountyFirst.selector);
wrappedPool.claimCorrupted();
// The wrapped deadline is already in the past, so anyone can immediately sweep the full bounty.
uint256 recoveryBefore = token.balanceOf(recovery);
wrappedPool.sweepUnclaimedCorrupted();
assertEq(token.balanceOf(recovery) - recoveryBefore, 100 ether);
}
function _createFactoryPoolAt(uint256 timestamp) internal returns (ConfidencePool created) {
vm.warp(timestamp);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
ConfidencePool implementation = new ConfidencePool();
ConfidencePoolFactory factoryImplementation = new ConfidencePoolFactory();
ERC1967Proxy proxy = new ERC1967Proxy(
address(factoryImplementation),
abi.encodeCall(
ConfidencePoolFactory.initialize,
(address(safeHarborRegistry), address(implementation), moderator)
)
);
ConfidencePoolFactory factory = ConfidencePoolFactory(address(proxy));
factory.setStakeTokenAllowed(address(token), true);
address poolAddress = factory.createPool(
agreement, address(token), timestamp + 31 days, ONE, recovery, _defaultScope()
);
created = ConfidencePool(poolAddress);
}
function _fundAndFlagGoodFaith(ConfidencePool target, address namedAttacker) internal {
token.mint(alice, 100 ether);
vm.startPrank(alice);
token.approve(address(target), 100 ether);
target.stake(100 ether);
vm.stopPrank();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
target.pokeRiskWindow();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
target.flagOutcome(PoolStates.Outcome.CORRUPTED, true, namedAttacker);
}
}

Run with:

forge test --mt test_PoC_ExactFirstWrapRedirectsFullBountyToRecovery

Recommended Mitigation

Use a wider type for the first-good-faith timestamp and derived deadline. The public interface should be updated to match the wider deadline type.

- uint32 private _firstGoodFaithCorruptedAt;
- uint32 public override corruptedClaimDeadline;
+ uint64 private _firstGoodFaithCorruptedAt;
+ uint64 public override corruptedClaimDeadline;
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);
}

As a smaller but more restrictive alternative, retain uint32 and reject any derived deadline exceeding type(uint32).max before performing the cast.

Support

FAQs

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

Give us feedback!