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

Post-expiry risk observation backdates risk and enables full bad-faith CORRUPTED sweep

Author Revealed upon completion

Post-expiry risk observation backdates risk and enables full bad-faith CORRUPTED sweep

Description

After a pool has expired without observing active risk during the covered term, later active-risk observations should not make the pool eligible for bad-faith corrupted seizure. _markRiskWindowStart() currently caps late observations to expiry, so a post-expiry UNDER_ATTACK observation writes riskWindowStart = expiry. Later, claimExpired() treats that marker as if in-term risk existed and can auto-resolve the pool as bad-faith CORRUPTED.

Vulnerability Details

The risk marker is backdated when observed after expiry:

function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
}

claimExpired() then uses only riskWindowStart != 0 to decide whether a corrupted registry state can seize the pool:

if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
outcome = PoolStates.Outcome.CORRUPTED;
corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
claimsStarted = true;
return;
}

Any caller can persist the late marker through pokeRiskWindow() or through claimExpired() while the registry is active-risk.

Impact

Stakers lose principal and bonus to recoveryAddress even though the first active-risk observation happened after the covered term ended.

Mechanical finality sets claimsStarted, which prevents the moderator from correcting the outcome after the bad-faith path has been selected.

Proof of Concept

function testPostExpiryRisk() external {
_stake(pool, token, alice, 100e18);
_contributeBonus(pool, token, carol, 20e18);
vm.warp(pool.expiry() + pool.MODERATOR_CORRUPTED_GRACE() + 1);
attackRegistry.setAgreementState(address(agreement), IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), pool.expiry());
attackRegistry.setAgreementState(address(agreement), IAttackRegistry.ContractState.CORRUPTED);
pool.claimExpired();
pool.claimCorrupted();
}

Recommended Mitigation

Do not open a risk window after the pool has expired.

function _markRiskWindowStart() internal {
+ if (block.timestamp >= expiry) return;
uint256 t = block.timestamp;
- if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
}

Support

FAQs

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

Give us feedback!