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

`corruptedClaimDeadline` uint32 addition wraps for good-faith `CORRUPTED` flags near the 2106 ceiling, diverting the attacker's entire bounty to `recoveryAddress` in the same block

Author Revealed upon completion

Root + Impact

Description

  • Describe the normal behavior in one or more sentences

  • Explain the specific issue or problem in one or more sentences

// Root cause in the codebase with @> marks to highlight the relevant section
**Affected functions:** `flagOutcome`, `claimAttackerBounty`, `sweepUnclaimedCorrupted`, `initialize` (permits vulnerable `expiry` today)
**Affected actors:** The good-faith attacker entitled to `snapshotTotalStaked + snapshotTotalBonus`
### Description
`flagOutcome(CORRUPTED, goodFaith=true, attacker)` computes the attacker's 180-day claim window as `uint32(_firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW)`. Both operands are `uint256` in the expression, but the result is truncated back to `uint32`. When the flag executes within 180 days of the uint32 ceiling (approximately 2106-02-07 UTC), the sum exceeds `2^32` and truncates to a tiny second-count near the Unix epoch. That value is already in the past relative to `block.timestamp`. `claimAttackerBounty` reverts with `ClaimWindowExpired` at the block of the flag. `sweepUnclaimedCorrupted` becomes callable in the same block and drains the entire corrupted reserve to `recoveryAddress`. The good-faith attacker entitled to `snapshotTotalStaked + snapshotTotalBonus` receives zero.
```solidity
// ConfidencePool.sol:365-375
if (goodFaith_) {
if (_firstGoodFaithCorruptedAt == 0) {
// forge-lint: disable-next-line(unsafe-typecast)
_firstGoodFaithCorruptedAt = uint32(block.timestamp);
}
// 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); // @> wraps
}
```
`CORRUPTED_CLAIM_WINDOW == 180 days` (`:45`). Both `_firstGoodFaithCorruptedAt` and `corruptedClaimDeadline` are `uint32` (`:155`). For any flag executed at `T_flag > type(uint32).max180 days`, the intermediate `uint256` sum exceeds `2^32` and the outer `uint32(...)` cast truncates to `(T_flag + 180 days) mod 2^32`.
Downstream gates read that value as an absolute timestamp:
```solidity
// claimAttackerBounty() -- src/ConfidencePool.sol:437
if (block.timestamp > corruptedClaimDeadline) revert ClaimWindowExpired();
// sweepUnclaimedCorrupted() -- src/ConfidencePool.sol:459
if (block.timestamp <= corruptedClaimDeadline) revert ClaimWindowNotExpired();
```
The rightful bounty claim reverts and the recovery-address sweep opens in the same block as the flag.
### Invariant violated
The design intent, documented at `:145-155`, is that the good-faith attacker receives a full 180-day window starting from the first `CORRUPTED` flag, and that the moderator cannot mint a fresh window by re-flagging. Flags in the wrap zone produce a zero-length (already-expired) window instead, and route the full entitlement to `recoveryAddress` in the same block. This violates both the window guarantee and the DESIGN.md priority of the attacker's claim over the sweep short-circuit.
### Attack path
1. A pool is created with `expiry` near or at `type(uint32).max`. The current `initialize` accepts any `expiry` up to and including `type(uint32).max`; the PoC uses exactly that value.
2. Time advances naturally, over decades, into the last 180 days before the uint32 ceiling (approximately 2105-08-10 through 2106-02-07).
3. Registry reaches `CORRUPTED`; moderator calls `flagOutcome(CORRUPTED, goodFaith=true, attacker)`.
4. `corruptedClaimDeadline` truncates to a small second-count near the Unix epoch.
5. Rightful attacker calls `claimAttackerBounty()`. Reverts with `ClaimWindowExpired`.
6. Any address calls `sweepUnclaimedCorrupted()` in the same block. Succeeds, transferring `snapshotTotalStaked + snapshotTotalBonus` to `recoveryAddress`.
No malicious registry state, moderator compromise, or nonstandard token behavior is required. The only external precondition is natural time reaching the wrap zone.
### Why this is not an accepted DESIGN.md behavior
The developer comment at `ConfidencePool.sol:370-371` states *"Sum stays in uint32 unless flagged within 180 days of the 2106 ceiling; out of scope."* That is a temporal-reachability waiver, not a semantic acceptance. DESIGN.md does not describe or accept a zero-length attacker window as a valid resolution mode. The mechanism reproduces today on unmodified code; only the trigger requires natural time to reach 2105-08, and `initialize` permits pool creation with an `expiry` that would survive to that point. A future refactor that widens `expiry` or timestamp representation without also widening this arithmetic would pull the wrap zone into practical range. The waiver should be encoded structurally, not left as a comment.

Risk

Likelihood:

~ Temporal reachability is ~80 years out (wrap zone begins ~2105-08-10 UTC).
~ No malicious action required. Natural time is sufficient once a long-lived pool exists.
~ Pool creation with an `expiry` surviving to 2105 is not prevented by any code-level check. It depends purely on off-chain governance never approving such a pool over the intervening period.
~ Explicit developer waiver at `ConfidencePool.sol:370-371` acknowledges the mechanism as known.

Impact:

~ Full attacker-bounty diversion to `recoveryAddress`, same block, permissionless.
~ The rightful attacker (named at flag time as the good-faith exploiter entitled to `snapshotTotalStaked + snapshotTotalBonus`) receives zero.
~ Any caller can trigger `sweepUnclaimedCorrupted` in the same transaction as the flag. No coordination or privilege required.
~ Direct violation of the DESIGN priority "attacker's 180-day claim window is honored before the recovery-address short-circuit is available."

Proof of Concept

Three tests, run against unmodified `src/ConfidencePool.sol`:
| Scenario | Test | Command |
| --- | --- | --- |
| Deadline wraps when flagged in the last 180 days before uint32 ceiling | `test/audit/CP005_DeadlineWrap.t.sol::test_CP005_deadlineWrapsWhenFlaggedNearUint32Ceiling` | `forge test --match-test test_CP005_deadlineWrapsWhenFlaggedNearUint32Ceiling -vvvv` |
| Boundary control: last valid second before the wrap zone still produces a valid window | `test/audit/CP005_DeadlineWrap.t.sol::test_CP005_boundary_lastValidSecondBeforeWrap` | `forge test --match-test test_CP005_boundary_lastValidSecondBeforeWrap -vvvv` |
| Negative control: normal flag well below the wrap zone gives a full 180-day window | `test/audit/CP005_DeadlineWrap.t.sol::test_CP005_negativeControl_normalFlagGivesFullWindow` | `forge test --match-test test_CP005_negativeControl_normalFlagGivesFullWindow -vvvv` |
The primary test:
``solidity
function test_CP005_deadlineWrapsWhenFlaggedNearUint32Ceiling() external {
ConfidencePool p = _deployPoolWithMaxExpiry(); // expiry = type(uint32).max
_stakeToPool(p, attacker, 100 * ONE); // named attacker also stakes
// ... carol contributes 40 * ONE bonus ...
uint256 expectedEntitlement = 100 * ONE + 40 * ONE;
vm.warp(BASE_TIMESTAMP + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
p.pokeRiskWindow();
assertGt(p.riskWindowStart(), 0, "risk window sealed under attack");
uint32 tFlag = UINT32_MAX - uint32(90 days); // 90 days before ceiling
vm.warp(tFlag);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
p.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
uint32 expectedWrapped = uint32(uint256(tFlag) + CORRUPTED_CLAIM_WINDOW);
assertEq(p.corruptedClaimDeadline(), expectedWrapped, "deadline reflects uint32 wrap");
assertLt(uint256(p.corruptedClaimDeadline()), block.timestamp,
"wrapped deadline is already in the past");
vm.prank(attacker);
vm.expectRevert(IConfidencePool.ClaimWindowExpired.selector);
p.claimAttackerBounty();
uint256 recoveryBefore = token.balanceOf(recovery);
p.sweepUnclaimedCorrupted(); // permissionless, same block
uint256 recoveryGain = token.balanceOf(recovery) - recoveryBefore;
assertEq(recoveryGain, expectedEntitlement, "entire entitlement diverted to recovery");
assertEq(token.balanceOf(attacker), 0, "rightful bounty claimant received nothing");
}
``
`CP005_DeadlineWrap.t.sol`
``solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
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 {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
// flagOutcome(CORRUPTED, goodFaith=true) sets corruptedClaimDeadline via a uint32 truncation of
// (_firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW). Within 180 days of the uint32 ceiling
// (~2106-02-07) the sum wraps to a small second-count, so the deadline is already in the past.
// claimAttackerBounty reverts and sweepUnclaimedCorrupted opens in the same block.
contract CP005DeadlineWrap is BaseConfidencePoolTest {
uint32 internal constant UINT32_MAX = type(uint32).max;
uint256 internal constant CORRUPTED_CLAIM_WINDOW = 180 days;
// Base setUp uses a 31-day expiry that cannot survive the warp to 2106.
function _deployPoolWithMaxExpiry() internal returns (ConfidencePool p) {
ConfidencePool implementation = new ConfidencePool();
p = ConfidencePool(Clones.clone(address(implementation)));
p.initialize(
agreement,
address(token),
address(safeHarborRegistry),
moderator,
uint256(UINT32_MAX),
ONE,
recovery,
address(this),
_defaultScope()
);
}
function _stakeToPool(ConfidencePool p, address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(p), amount);
p.stake(amount);
vm.stopPrank();
}
function test_CP005_deadlineWrapsWhenFlaggedNearUint32Ceiling() external {
ConfidencePool p = _deployPoolWithMaxExpiry();
_stakeToPool(p, attacker, 100 * ONE);
token.mint(carol, 40 * ONE);
vm.startPrank(carol);
token.approve(address(p), 40 * ONE);
p.contributeBonus(40 * ONE);
vm.stopPrank();
uint256 expectedEntitlement = 100 * ONE + 40 * ONE;
vm.warp(BASE_TIMESTAMP + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
p.pokeRiskWindow();
assertGt(p.riskWindowStart(), 0, "risk window sealed under attack");
uint32 tFlag = UINT32_MAX - uint32(90 days); // 90 days before the ceiling
vm.warp(tFlag);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
p.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
// Expected wrap: (uint32.max - 90d + 180d) mod 2^32 = 90d - 1
uint32 expectedWrapped = uint32(uint256(tFlag) + CORRUPTED_CLAIM_WINDOW);
assertEq(p.corruptedClaimDeadline(), expectedWrapped, "deadline reflects uint32 wrap");
assertLt(
uint256(p.corruptedClaimDeadline()),
block.timestamp,
"wrapped deadline is already in the past"
);
vm.prank(attacker);
vm.expectRevert(IConfidencePool.ClaimWindowExpired.selector);
p.claimAttackerBounty();
uint256 recoveryBefore = token.balanceOf(recovery);
uint256 poolBalance = token.balanceOf(address(p));
assertEq(poolBalance, expectedEntitlement, "pool holds full entitlement pre-sweep");
p.sweepUnclaimedCorrupted();
uint256 recoveryGain = token.balanceOf(recovery) - recoveryBefore;
assertEq(recoveryGain, expectedEntitlement, "entire entitlement diverted to recovery");
assertEq(token.balanceOf(attacker), 0, "rightful bounty claimant received nothing");
}
function test_CP005_negativeControl_normalFlagGivesFullWindow() external {
ConfidencePool p = _deployPoolWithMaxExpiry();
_stakeToPool(p, attacker, 100 * ONE);
vm.warp(BASE_TIMESTAMP + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
p.pokeRiskWindow();
uint256 tFlag = BASE_TIMESTAMP + 2 days;
vm.warp(tFlag);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
p.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(
uint256(p.corruptedClaimDeadline()),
tFlag + CORRUPTED_CLAIM_WINDOW,
"deadline is +180d, un-wrapped"
);
assertGt(
uint256(p.corruptedClaimDeadline()),
block.timestamp,
"deadline is in the future"
);
vm.prank(attacker);
p.claimAttackerBounty();
assertEq(token.balanceOf(attacker), 100 * ONE, "attacker received full entitlement");
}
// Threshold sits exactly at uint32.max - CORRUPTED_CLAIM_WINDOW.
function test_CP005_boundary_lastValidSecondBeforeWrap() external {
ConfidencePool p = _deployPoolWithMaxExpiry();
_stakeToPool(p, attacker, 100 * ONE);
vm.warp(BASE_TIMESTAMP + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
p.pokeRiskWindow();
uint32 tSafe = UINT32_MAX - uint32(CORRUPTED_CLAIM_WINDOW);
vm.warp(tSafe);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
p.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(uint256(p.corruptedClaimDeadline()), uint256(UINT32_MAX), "sits on the ceiling");
assertGt(uint256(p.corruptedClaimDeadline()), block.timestamp, "still in the future");
vm.prank(attacker);
p.claimAttackerBounty();
assertEq(token.balanceOf(attacker), 100 * ONE, "boundary flag pays out normally");
}
}
``
Boundary test confirms the threshold is exactly `type(uint32).max − CORRUPTED_CLAIM_WINDOW`.
``bash
forge test --match-path test/audit/CP005_DeadlineWrap.t.sol -vvvv
``
### Observed result
```text
[PASS] test_CP005_boundary_lastValidSecondBeforeWrap()
[PASS] test_CP005_deadlineWrapsWhenFlaggedNearUint32Ceiling()
[PASS] test_CP005_negativeControl_normalFlagGivesFullWindow()
Suite result: ok. 3 passed; 0 failed; 0 skipped
```

Recommended Mitigation

Widen the deadline to `uint256` at the storage level, or guard the sum.
```diff
-uint32 public override corruptedClaimDeadline;
+uint256 public override corruptedClaimDeadline;
// ConfidencePool.sol:365-372
if (goodFaith_) {
if (_firstGoodFaithCorruptedAt == 0) {
_firstGoodFaithCorruptedAt = uint32(block.timestamp);
}
- corruptedClaimDeadline = uint32(_firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW);
+ corruptedClaimDeadline = uint256(_firstGoodFaithCorruptedAt) + CORRUPTED_CLAIM_WINDOW;
}
```
If `uint32` storage must be preserved for slot-packing reasons, guard the sum instead:
``solidity
uint256 deadline = uint256(_firstGoodFaithCorruptedAt) + CORRUPTED_CLAIM_WINDOW;
if (deadline > type(uint32).max) revert DeadlineOverflow();
corruptedClaimDeadline = uint32(deadline);
``
If the intent is to keep the "out of scope" waiver, encode it structurally by rejecting `initialize` calls where `expiry > type(uint32).max − CORRUPTED_CLAIM_WINDOW`. The wrap zone becomes unreachable by construction rather than by governance discipline over 80 years.
### Regression properties
~ Flags at any `T_flag ≤ type(uint32).max − CORRUPTED_CLAIM_WINDOW` continue to produce the intended `T_flag + 180 days` deadline unchanged.
~ Boundary flag exactly at `type(uint32).max − CORRUPTED_CLAIM_WINDOW` produces a deadline of exactly `type(uint32).max`, still greater than `block.timestamp`, still un-wrapped.
~ Post-mitigation flag one second into the wrap zone reverts atomically (Option B) or produces the correct forward deadline (Option A). No other pool state is disturbed.
~ Re-flag semantics remain preserved. `_firstGoodFaithCorruptedAt` is set only once, so the moderator cannot mint a fresh window by re-flagging.

Support

FAQs

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

Give us feedback!