90222 sc high any staker can halve the protocol s maximum incident payout by landing a withdrawal on the exact period start timestamp
Submitted on Aug 23rd 2026 at 20:00:37 UTC by @drak3p14159 for Audit Comp | Firelight
Report ID: #90222
Report Type: Smart Contract
Report severity: High
Target: https://github.com/immunefi-team/audit-comp-firelight/blob/v1_audit_ready/contracts/core/FirelightVault.sol
Impacts:
Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield
Protocol insolvency
Description
Brief/Intro
FirelightVault.payout() caps an incident payment at totalAssetsAt(capturePeriodStart), a value read from a timestamp-keyed checkpoint trace whose lookup returns the last entry with key ≤ the query. Any unprivileged staker who lands a withdrawal in a block whose timestamp is exactly capturePeriodStart writes a new, lower checkpoint at that same key, so the cap reads the post-withdrawal total — while those same withdrawn assets remain fully exposed to the incident and are explicitly added back into the payable set by the very next lines. In production this lets any staker unilaterally suppress the protocol's maximum payout for the period: the A/B-controlled PoC supplied in the Proof of Concept field measures an approved claim underpaid by 50% (2,000 → 1,000) while the attacker retains 500 in slashable assets that the control run shows would otherwise have been taken, and the suppression scales with the attacker's share of the vault.
Vulnerability Details
The payout is bounded by the vault's assets at the start of the covered period:
// contracts/core/FirelightVault.sol:909-927
// Cap by the active assets committed at the start of the covered period.
// This is an exposure cap, not a segregated asset bucket.
@> uint256 assetsAtCapturePeriod = totalAssetsAt(capturePeriodStart);
uint256 currentActiveAssets = totalAssets();
uint256 capturePeriodWithdrawals = withdrawAssets[capturePeriod + 1];
...
uint256 activePayableAmount = currentActiveAssets + capturePeriodWithdrawals + nextPeriodWithdrawals;
@> paidAmount = Math.min(amount, Math.min(assetsAtCapturePeriod, activePayableAmount));That bound resolves through a checkpoint lookup that accepts any entry at or before the queried key:
And every withdrawal pushes a new checkpoint at the current block timestamp, so the key the cap reads is writable by an ordinary user action:
The two reads are inconsistent by construction — and that inconsistency, not the choice of snapshot convention, is the defect. The cap is taken after a boundary withdrawal has reduced it, while activePayableAmount adds the very same assets back through withdrawAssets[capturePeriod + 1] — the bucket the design deliberately keeps slashable precisely so that "exposed assets could [not] escape by withdrawing first" (the code's own comment at :918-920). The protocol therefore treats those assets as exposed for the purpose of what it may pay out of, and as absent for the purpose of how much it may pay at all.
No privileged role is involved at any step. The attacker needs only an ordinary staked position and a withdrawal transaction landing in one specific block.
Reachability of the exact-timestamp landing. The attack needs a withdrawal to execute in a block whose timestamp equals capturePeriodStart exactly. That is a repeated low-cost attempt, not a coincidence:
capturePeriodStartis deterministic and publicly readable on-chain (currentPeriodStart()/currentPeriodEnd()), so the attacker knows the target second well in advance.Periods are long — 604,800 seconds in the configuration the PoC exercises — so a missed attempt simply waits for the next boundary. There is no limit on attempts.
The attempt is an ordinary
withdrawthe staker is entitled to make anyway, so missing forfeits nothing and changes no position.It can be automated by routing the withdrawal through a contract that reverts unless
block.timestamp == currentPeriodStart(). A miss then costs only the gas of an early revert and leaves the staked position untouched.
The PoC uses setNextBlockTimestamp to place the block deterministically, which is the ordinary way to reproduce a timing condition in a test suite; it is not an assumption that the attacker holds any special privilege. Under Immunefi's Feasibility Limitation Standards, attack difficulty is not by itself grounds for downgrade absent the attacker's risk extremely outweighing the reward — and here a failed attempt risks nothing beyond gas.
Impact Details
A staker halves the protocol's maximum payout for the period and retains slashable value.
Measured by the PoC, with a control isolating the single variable (withdrawal timing):
payout cap
2,000
1,000
paid to the claimant
2,000
1,000
attacker retains
0
500
honest staker retains
0
500
The approved claim is underpaid by 50% while the vault holds the assets, and the attacker keeps 500 that the control shows would otherwise have been slashed. Scaling: an attacker holding a larger fraction of the vault suppresses the cap proportionally; if every active staker withdraws at the boundary the cap approaches zero and the vault pays nothing at all on a valid claim.
Who bears the loss. The table shows the honest staker also retaining 500 in the attack run, so it is worth stating plainly who is harmed. It is not another staker: it is the cover beneficiary, whose approved and assessed claim is paid at half its value while the vault demonstrably holds the assets. The attacker's gain and the claimant's loss are separate measurements — 500 retained and 1,000 underpaid — and each is shown against the control.
The defect is in the cap, not in any external reserve. totalAssetsAt(capturePeriodStart) returns a value that a boundary write has already lowered, before any downstream component sees it. That follows from the two reads shown above — upperLookupRecent resolves key ≤ timestamp, and the withdrawal pushes its checkpoint at exactly that key — so it holds on every execution, independent of the state of any reserve the protocol maintains outside the vault. The first-loss buffer is a downstream top-up toward the claimant: it does not correct the ceiling, and it does not return the assets the attacker retains in withdrawAssets[capturePeriod + 1] to the payable set. Those assets are slashed in the control run and retained in the attack run, and no external payment path reaches them — which is the Direct theft of any user funds mapping, and it does not depend on any reserve being empty.
This distinction matters against the out-of-scope list, which excludes "the first-loss buffer being an external, unescrowed wallet; and reports based only on the first-loss-buffer token depegging without a protocol bug causing or mishandling that depeg." This report is neither of those. It does not rest on the buffer's custody model, and it does not concern a depeg. It identifies a defect in the vault's own checkpoint accounting, inside FirelightVault.payout, reachable by an unprivileged user with no privileged role involved at any step. The exclusion covers the buffer's existence as an external wallet; it does not convert that wallet into a mitigation for an independent defect elsewhere in the system. Reading it that way would also invert it — the exclusion records that the buffer is unescrowed and outside the protocol's control, whereas closing this report on the buffer would require assuming it is funded and available at the moment of the incident.
This defeats the exposure cap on which the protocol's solvency model rests, and it is reachable by an unprivileged user — the programme states that bypassing an intended protocol constraint is in scope.
Impacts selected — both from the programme's in-scope list, either sufficient on its own. Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield: an unprivileged attacker manipulates the payout so that the claimant incurs a measured loss during the transfer while the attacker profits in retained slashable assets — the 1,000 and 500 figures above, both A/B controlled. Protocol insolvency: because the cap is suppressible by any staker, cover written against period-start capacity exceeds what the protocol can be made to pay on a valid claim. Both are Critical under Severity Classification System v2.3.
The runnable PoC that produces these figures, the ablation confirming the boundary read is their cause, and the reason a local test against the project's own fixtures is the appropriate form here (rather than a mainnet fork) are all supplied in the Proof of Concept field of this submission.
Distinctness from the adjacent capacity defect — verified by cross-ablation
There is a separate, nearby weakness in CoverOrderAllocator._computeAvailableCapacity concerning a missing prior-period reserve, and a triager may reasonably wonder whether this report is a symptom of it. It is not, and that was verified by execution rather than asserted: applying the capacity-reserve fix and re-running this PoC unchanged leaves both measured consequences intact.
the capacity-reserve fix in CoverOrderAllocator
claimant still underpaid 1000, attacker still retains 500
this defect survives it
Different contract, different read, different fix — and a single remediation cannot close both. If this report is considered alongside any capacity-side finding, that is the operative test of whether the two describe one defect or two.
Recommended Fix
(a) One immutable opening-exposure snapshot, shared by the allocator and the payout — preferred. Materialise the period's opening exposure once, and have both _computeAvailableCapacity and payout read that same stored value. A historical figure that a commitment has already been priced against must not remain overwriteable by later activity at the same key.
(b) Smaller mitigation: use the strict pre-boundary value consistently in BOTH components. Read the exposure at capturePeriodStart - 1 so a checkpoint written at the period start — which belongs to the new period, not the old one — cannot lower it. ⚠ Apply this in _computeAvailableCapacity as well as in payout: the allocator reads totalAssetsAt(currentPeriodStart()) and is exposed to the same boundary write. Fixing only the payout leaves the capacity path wrong.
(c) Defer the withdrawal checkpoint to the period the request belongs to. A request made at time T is booked to bucket period(T)+1; pushing its asset-reducing checkpoint at T lets it retroactively affect a period it is still exposed to.
⛔ A fix that was considered and REJECTED, recorded because it is the intuitive one: adding the still-slashable withdrawal buckets back into the cap (totalAssetsAt(capturePeriodStart) + withdrawAssets[cp+1] + withdrawAssets[cp+2]). This does make the measured shortfall disappear, but it is not safe in production: ordinary withdrawals are already represented in the opening snapshot, so adding whole buckets double-counts exposure and can create room to charge post-start deposits. Do not adopt it.
References
All links pinned to the in-scope tag v1_audit_ready (HEAD 42f9ea5).
The payout cap —
FirelightVault.payout(): https://github.com/immunefi-team/audit-comp-firelight/blob/v1_audit_ready/contracts/core/FirelightVault.sol#L909-L927The boundary-tolerant read —
FirelightVault.totalAssetsAt(): https://github.com/immunefi-team/audit-comp-firelight/blob/v1_audit_ready/contracts/core/FirelightVault.sol#L456-L458Checkpoint written at the current timestamp —
redeem(): https://github.com/immunefi-team/audit-comp-firelight/blob/v1_audit_ready/contracts/core/FirelightVault.sol#L739-L741Same write on the other path —
withdraw(): https://github.com/immunefi-team/audit-comp-firelight/blob/v1_audit_ready/contracts/core/FirelightVault.sol#L773-L775Lookup semantics (
key <= timestamp) —Checkpoints.upperLookupRecent: https://github.com/immunefi-team/audit-comp-firelight/blob/v1_audit_ready/contracts/core/lib/Checkpoints.solSecond site requiring fix (b) —
CoverOrderAllocator._computeAvailableCapacity: https://github.com/immunefi-team/audit-comp-firelight/blob/v1_audit_ready/contracts/core/CoverOrderAllocator.solPoC —
_audit_poc_boundary_cap_evasion.js, run against the project's own fixtures (test/setup/fixtures.js); step-by-step explanation, full source and run instructions are in the Proof of Concept field of this submission.Severity mapping — Immunefi Vulnerability Severity Classification System v2.3, Smart Contracts → Critical: Direct theft of any user funds… and Protocol insolvency. https://immunefi.com/immunefi-vulnerability-severity-classification-system-v2-3/
Link to Proof of Concept
https://gist.github.com/drak3p14159/2a1fdc059fe771c88db463ad4bb6c699
Proof of Concept
Step-by-step explanation of the attack and its impact
Preconditions. A vault with a one-week period. Two stakers each deposit 1,000, so the vault holds 2,000 at the start of the covered period. The attacker is one of them — an ordinary staker, no privileged role.
The attacker reads
currentPeriodEnd(), which is public. This is the exact second at which the nextcapturePeriodStartfalls, known in advance.The attacker submits an ordinary
withdraw(1000)and lands it in the block whose timestamp is exactly that boundary second.That withdrawal calls
_logTrace, which pushes a checkpoint(key = capturePeriodStart, totalAssets = 1000)— a new, lower value written at the very key the payout cap will read.An incident covering that period is declared and a claim of 2,000 is approved.
payout()computes its ceiling astotalAssetsAt(capturePeriodStart). That resolves throughupperLookupRecent, which returns the last checkpoint with key ≤ the query — and the attacker's checkpoint sits exactly on the key. It returns the post-withdrawal 1,000, not the 2,000 the vault actually held when the period opened.paidAmount = Math.min(amount, Math.min(assetsAtCapturePeriod, activePayableAmount))therefore binds at 1,000. The claimant is paid 1,000 against an approved 2,000.Meanwhile the attacker's withdrawn 1,000 sits in
withdrawAssets[capturePeriod + 1], which the same function explicitly adds back into the payable set — the protocol still considers those assets exposed. Because the ceiling was suppressed, only part of them is taken: the attacker retains 500.
The control that isolates it. The test then runs the identical scenario a second time, changing exactly one thing: the withdrawal lands 60 seconds later instead of on the boundary. The checkpoint then falls after the key, the cap reads the full 2,000, the claimant is paid 2,000, and the attacker retains 0. Nothing else differs between the two runs.
Impact. The claimant is underpaid by 1,000 — 50% of an assessed claim — while the vault demonstrably holds the assets, and the attacker keeps 500 that the control proves would otherwise have been slashed. Both figures scale with the attacker's share of the vault; if every active staker withdraws on the boundary the ceiling approaches zero and a valid claim pays nothing.
Why this is a local test against the project's own suite, and not a mainnet fork
Immunefi's PoC guidelines require a runnable PoC and permit the project's existing test suite where "forking the mainnet state is not feasible". Forking is not feasible for this defect: the payout path requires Phase 2 contracts that are not yet deployed on Flare, so there is no on-chain state to fork. This PoC therefore runs against the in-scope source at commit 42f9ea5 (tag v1_audit_ready) using the project's own unmodified test fixtures (test/setup/fixtures.js), deploys the real FirelightVault, and measures real token balance changes. No mainnet or public testnet is touched, no live deployment is interacted with at any point, and no contract under contracts/ is modified.
How to run
The test — complete source
It runs the identical scenario twice and moves only the withdrawal timestamp, so the difference between the two runs is attributable to nothing else.
Output
The claimant is underpaid by 1000 (50% of an approved 2000 claim) and the attacker retains 500 in assets that the control run shows would otherwise have been slashed.
Cause verified by ablation, not merely asserted
Applying recommended fix (b) — reading the exposure strictly before the boundary — and re-running the unchanged test makes both measured consequences vanish:
The test then fails, because it asserts the attack succeeds. The measured effect is therefore attributable to the boundary read and to nothing else.
One note for exactness: the line the test logs as cap is the raw totalAssetsAt(start) and still prints 1000 after the fix — it is an adjacent proxy for the effective cap, not the cap itself. The payout and retention figures are the operative measurements. The contract was restored after the ablation; git diff on contracts/ is empty at 42f9ea5.
Was this helpful?