89555 sc high a staker who times a withdrawal to the exact period start second halves the incident payout and keeps the difference
Submitted on Aug 19th 2026 at 20:35:16 UTC by @Kuzomo for Audit Comp | Firelight
Report ID: #89555
Report Type: Smart Contract
Report severity: High
Target: https://github.com/immunefi-team/audit-comp-firelight/blob/v1_audit_ready/contracts/core/lib/Checkpoints.sol
Impacts:
Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield
Description
Brief/Intro
totalAssetsAt(periodStart) is the single historical value behind both the allocator's capacity basis and the vault's incident payout cap. It is not a snapshot of the state as the period began: upperLookupRecent selects the most recent checkpoint with a key less than or equal to the search key, so any vault state change landing in a block whose block.timestamp equals the period start is folded into that period's "starting" figure. An ordinary withdrawal in that one-second window therefore rewrites the period's exposure basis after the period has already started, and the staker who lands it keeps the difference. In the run below a redeem at the exact boundary drops totalAssetsAt(periodStart) from 1,000,000,000 to 500,000,000, an incident payout requesting 1,000,000,000 pays only 500,000,000, and the redeeming staker walks away with 500,000,000. The identical sequence one second later pays the beneficiary in full and leaves that staker with nothing. The 500,000,000 the beneficiary loses is exactly the 500,000,000 the staker keeps. When an allocation was committed earlier in that same block, the contract then rejects its own commitment: recommitting at the capacity it just accepted reverts MatchingCapacityOverflow, and recommitting at the period's new maximum reverts TotalAllocationOverflow.
Vulnerability Details
The lookup is inclusive of the search key:
/**
* @dev Returns the value in the last (most recent) checkpoint with a key lower or equal than the search key,
* or zero if there is none.
*/
function upperLookupRecent(Trace256 storage self, uint48 key) internal view returns (uint256) {
uint208 idx = self._trace.upperLookupRecent(key);
return idx > 0 ? self._values[idx] : 0;
}https://github.com/immunefi-team/audit-comp-firelight/blob/42f9ea5e43d88b35197b901acc2a8c24b314fa62/contracts/core/lib/Checkpoints.sol#L217-L224
totalAssetsAt is exactly that lookup (https://github.com/immunefi-team/audit-comp-firelight/blob/42f9ea5e43d88b35197b901acc2a8c24b314fa62/contracts/core/FirelightVault.sol#L456-L458), and redeem/withdraw push a post-request total keyed on Time.timestamp() through _logTrace (https://github.com/immunefi-team/audit-comp-firelight/blob/42f9ea5e43d88b35197b901acc2a8c24b314fa62/contracts/core/FirelightVault.sol#L1044-L1056). A period start is an exact second, computed as pc.epoch + periodsSinceEpoch * pc.duration (https://github.com/immunefi-team/audit-comp-firelight/blob/42f9ea5e43d88b35197b901acc2a8c24b314fa62/contracts/core/FirelightVault.sol#L1123-L1128), so a redeem in a block carrying that timestamp writes a checkpoint at exactly the key the period-start lookup resolves to.
Two independent consumers read that one value. The payout cap:
https://github.com/immunefi-team/audit-comp-firelight/blob/42f9ea5e43d88b35197b901acc2a8c24b314fa62/contracts/core/FirelightVault.sol#L909-L927
and the allocator's capacity basis:
https://github.com/immunefi-team/audit-comp-firelight/blob/42f9ea5e43d88b35197b901acc2a8c24b314fa62/contracts/core/CoverOrderAllocator.sol#L696-L725
commitAllocation must run in the period it commits for, so the earliest it can execute is the period-start block itself. A redeem later in that same block therefore rewrites the basis the commitment was validated against, after validation. _validateMatchingCapacity proves this directly when re-run (https://github.com/immunefi-team/audit-comp-firelight/blob/42f9ea5e43d88b35197b901acc2a8c24b314fa62/contracts/core/CoverOrderAllocator.sol#L394-L403).
This also defeats a guarantee the payout function states for itself. Lines 918-920 keep withdrawals requested during the capture period inside activePayableAmount because "otherwise exposed assets could escape by withdrawing first". The boundary redeem keeps those assets nominally payable but lowers the other term of the Math.min, so the assets escape through the cap instead.
A secondary amplifier sits in the same library: push replaces the stored value when a key repeats (if (exists && key == lastKey) { self._values[len - 1] = value; }, Checkpoints.sol#L198-L215), so several state changes in the period-start block collapse to the last one rather than accumulating.
Impact Details
A staker who times an ordinary exit to the period-start second keeps funds that an approved claim was entitled to, one-for-one. The same two runs, differing only in the redeem's timestamp:
In those runs the staker's position would have been slashed to nothing at the correct timing and keeps half at the boundary, and the 500,000,000 they retain is exactly the 500,000,000 the beneficiary of a valid approved claim does not receive. The proportions depend on the size of the payout relative to the position; the equality between the staker's gain and the beneficiary's loss does not.
This is what the payout function itself is written to prevent. Withdrawals requested during the capture period are deliberately kept inside activePayableAmount because "otherwise exposed assets could escape by withdrawing first" (lines 918-920). Boundary timing defeats that guard from the other side: the assets stay nominally payable, but the cap they are measured against is rewritten downward, so they escape through the Math.min instead of through the withdrawal bucket.
The action costs nothing beyond an ordinary exit the staker wanted to make anyway, requires no role or allowlist, and can be repeated at every period boundary. The live vault's periodConfigurations(0) reports duration = 86400, so boundaries recur daily, and 12 of the last 14 actual boundaries carried a block at exactly that second (measured below).
No adversary is even required for the damage half: any staker's ordinary redeem or withdraw landing on that second produces the same reduction in the payout ceiling, so the defect also fires on honest traffic.
Where an allocation was committed in the same block, the protocol has additionally sold cover against a capacity figure that no longer exists: the commitment stores totalAvailableCapacity of 2000e18 with 1500e18 allocated, and after the redeem the contract rejects both repair attempts, so the period runs with cover outstanding against a basis the contract itself will not re-accept.
Feasibility Limitations check. Privileged actors do appear in the sequence, and every one of them acts correctly. The allocator commits an allocation that was valid at the capacity the contract itself reported; the payout signer pays out a confirmed incident through the documented waterfall. Neither is asked to misbehave, to act incorrectly, or to exceed its documented privileges, and the report does not depend on any risk parameter, buffer setting or other operator-selected value: the boundary is an exact second for every possible epoch and duration, so the result is invariant to how the protocol is configured. What breaks the protocol is an unprivileged action. An ordinary staker rewrites the period's exposure basis after those correct privileged actions have already relied on it, bypassing the intended constraint that a period's starting figure is fixed when the period starts. The contract then contradicts itself, which is the clearest statement that a protocol constraint was bypassed rather than merely configured badly: recommitting at the capacity the contract had just accepted reverts MatchingCapacityOverflow, and recommitting at the period's new maximum reverts TotalAllocationOverflow. Attack investment: the cost of one ordinary withdrawal the staker wanted to make anyway. Financial risk to attacker: none, since the withdrawal is a normal exit. The one real constraint is timing: the redeem must land in a block whose timestamp equals the period start exactly. Measured against the live deployment, that constraint is weak. The vault at 0x4C18Ff3C89632c3Dd62E796c0aFA5c07c4c1B2b3 reports periodConfigurations(0) of epoch = 1764682969, duration = 86400, so boundaries fall on epoch + n * 86400 — one every day. Binary searching Flare mainnet by block timestamp for the last 14 of those actual boundaries (chain id 14, 2026-08-19):
That matches the chain-wide figure: over 2,400 consecutive headers the mean block time is 1.118 s, 94.9 % of consecutive blocks are exactly one second apart, no two blocks share a timestamp, and 89.49 % of wall-clock seconds carry a block (two further windows 24 h apart: 88.72 % and 89.17 %). The two misses above are both explained by an 11-12 second gap straddling that boundary.
So the opportunity exists at roughly six of every seven boundaries and recurs daily. This bounds the opportunity rather than the hit rate of a single attempt: a block producer decides inclusion, so a targeting attacker submits near the boundary with a competitive fee and retries the next day. The accidental path needs no targeting at all — any staker's ordinary exit landing in that block is sufficient. The commit-ordering variant additionally requires the commitment to land in the same block, ahead of the redeem. Chain rollbacks and pre-impact monitoring: not cited; the checkpoint write and the later payout are ordinary committed transactions with no guardian inspecting them.
Recommendation
Make the period-start snapshot exclusive of the boundary key so it cannot be written after the period has begun. Reading totalAssetsAt(periodStart - 1), or adding an upperLookupStrict variant that selects the last checkpoint with a key strictly less than the search key, gives both payout and _computeAvailableCapacity a value that is immutable once the period starts. Alternatively, have the vault write an explicit period-start checkpoint on the first interaction of each period and read that, so the exposure basis is a recorded value rather than a lookup that later writes can land on.
References
inclusive lookup: https://github.com/immunefi-team/audit-comp-firelight/blob/42f9ea5e43d88b35197b901acc2a8c24b314fa62/contracts/core/lib/Checkpoints.sol#L217-L224
same-key replacement amplifier: https://github.com/immunefi-team/audit-comp-firelight/blob/42f9ea5e43d88b35197b901acc2a8c24b314fa62/contracts/core/lib/Checkpoints.sol#L198-L215
payout cap consumer: https://github.com/immunefi-team/audit-comp-firelight/blob/42f9ea5e43d88b35197b901acc2a8c24b314fa62/contracts/core/FirelightVault.sol#L892-L966
capacity basis consumer: https://github.com/immunefi-team/audit-comp-firelight/blob/42f9ea5e43d88b35197b901acc2a8c24b314fa62/contracts/core/CoverOrderAllocator.sol#L696-L725
checkpoint written on redeem/withdraw: https://github.com/immunefi-team/audit-comp-firelight/blob/42f9ea5e43d88b35197b901acc2a8c24b314fa62/contracts/core/FirelightVault.sol#L1044-L1056
Proof of Concept
Proof of Concept
Local Hardhat only; no mainnet or public-testnet transaction was sent. The run wires the real CoverOrderAllocator to the real FirelightVault so the capacity basis and the payout cap read the same on-chain value. The first test places commitAllocation and the boundary redeem in one block at the exact period-start second and asserts that placement mechanically (same blockNumber, commit index strictly lower, block timestamp equal to the boundary). The second measures the redeemer's realised outcome in both branches and asserts the gain equals the beneficiary's loss. The third is the +1 second negative control.
Observed output:
The two runs differ only in the redeem's timestamp. The MatchingCapacityOverflow revert is the contract's own verdict on the commitment it accepted moments earlier in the same block, and TotalAllocationOverflow shows the committed allocation no longer fits even at the period's new maximum. The negative control rules out every other difference: with the redeem one second later the basis, the payout and the recommit all behave normally.
Was this helpful?