90087 sc high same timestamp checkpoint overwrite in firelightvault lets a permissionless boundary withdrawal underpay a valid same period payout
Submitted on Aug 22nd 2026 at 22:11:06 UTC by @mohamadreza for Audit Comp | Firelight
Report ID: #90087
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:
Contract fails to deliver promised returns, but doesn't lose value
Description
Brief/Intro
FirelightVault.payout() caps every incident payout by the vault's total assets at the start of the covered period, read from a timestamp-keyed checkpoint intended to represent an immutable exposure cap. Because the checkpoint library overwrites the previous value when a new checkpoint uses the same timestamp, a permissionless withdraw() included in the period-boundary block can replace the true period-start snapshot with the post-withdraw balance. In production, this allows an ordinary staker to reduce the exposure cap to zero or to a lower value, causing an approved in-window payout for that period to be under-delivered while the withdrawn assets remain fully claimable by the staker.
Vulnerability Details
payout() sizes the payout using the assets committed at the start of the covered period:
uint256 assetsAtCapturePeriod = totalAssetsAt(capturePeriodStart);The checkpoint trace is written on deposits, withdrawals, and redemptions through _logTrace(), using the current block timestamp as the key:
The underlying Checkpoints.Trace256.push() implementation overwrites the stored value when a new checkpoint uses the same timestamp key:
As a result, _traceTotalAssets is used for two incompatible purposes:
a mutable per-transaction history of current total assets; and
the historical period-start exposure snapshot trusted by
payout().
Same-key replacement is valid for the first purpose but corrupts the second. A deposit or withdrawal included in a block whose timestamp equals the period boundary can silently rewrite the value later used as the payout exposure cap.
Full-escape attack path
A staker deposits
600,000units during period 0. The legitimate period-1 start exposure is therefore600,000.At exactly
periodOneStart, the staker callswithdraw(600,000)._requestWithdraw()assigns the assets towithdrawAssets[currentPeriod() + 1], which iswithdrawAssets[2]._logTrace()writes a post-withdraw active total of0using the same timestamp key, overwriting the original600,000period-start snapshot.totalAssetsAt(periodOneStart)now returns0, whilewithdrawAssets[2]still contains600,000.A valid, approved, in-window payout for a
600,000loss captured in period 1 is executed:
The configured payout recipient receives nothing, while the staker later claims the entire
600,000withdrawal bucket.
The withdrawal is a normal permissionless vault operation. No privileged role, oracle manipulation, invalid incident, or invalid payout amount is required. The execution condition is that the transaction is included in a block whose timestamp equals the predictable period boundary. The attacker cannot force block inclusion, but can target the known boundary block.
Partial and deposit-side variants
Withdrawing only part of the position reduces the period-start cap by the withdrawn amount and causes the valid payout to be under-delivered proportionally.
The inverse direction is also possible: a deposit included at the exact period boundary can overwrite the snapshot with a larger value, exposing fresh capital to a loss that predates that deposit.
These are consequences of the same root cause and share the same remediation; they are not separate findings.
Impact Details
Selected impact: Contract fails to deliver promised returns, but doesn't lose value.
A valid, approved, in-window incident payout for the covered period can be silently under-delivered. In the full-escape case, an approved 600,000 payout is reduced to 0. In the partial case, an approved 600,000 payout is reduced to 300,000.
The value is not stolen or burned: the withdrawn assets remain in the vault's delayed withdrawal bucket and are later claimed by the same staker. However, the contract fails to deliver the approved payout to the configured payout recipient, and the effective coverage for that period is reduced or eliminated.
Demonstrated loss breakdown using a six-decimal vault asset:
Full escape: approved payout of
600,000; actual payout is0.Partial escape: approved payout of
600,000; actual payout is300,000.
The attacker only needs to be an ordinary staker. No privileged role, allowlist entry, oracle manipulation, or invalid incident is required. withdraw() and redeem() are not blocked by the active-incident deposit restriction. The primary condition is a timing/inclusion condition: the withdrawal must be included in a block whose timestamp equals the deterministic period-start second. The live configuration uses one-day periods (confirmed on-chain: currentPeriodConfiguration() returns duration = 86400 on the deployed proxy 0x4C18Ff3C89632c3Dd62E796c0aFA5c07c4c1B2b3), so the boundary is publicly predictable.
References
Link to Proof of Concept
https://gist.github.com/rezar13r/38ce86af560becfa36a59e8ccf0f03a2
Proof of Concept
Proof of Concept
A complete Foundry PoC is available in the following Secret Gist:
https://gist.github.com/rezar13r/38ce86af560becfa36a59e8ccf0f03a2
The PoC file is:
It tests the real FirelightVault implementation behind an ERC1967Proxy in a local EVM environment. No transaction is sent to Firelight mainnet.
Full-escape scenario
Deploy the vault with a one-day period duration and configure a normal payout role and payout recipient.
An ordinary staker deposits
600,000asset units during period 0.Before period 1 starts, the correct historical exposure is:
Move the block timestamp to exactly
periodOneStart.The ordinary staker calls the normal permissionless withdrawal function:
The withdrawal is assigned to the delayed withdrawal bucket:
The withdrawal also calls
_logTrace(), which writes the post-withdraw active total under the same timestamp key. BecauseCheckpoints.Trace256.push()overwrites an existing equal-key checkpoint, the period-start snapshot becomes:A valid, approved, in-window payout for a
600,000loss captured in period 1 is executed:The payout calculation becomes:
The configured payout recipient receives zero, even though the full
600,000remains in the delayed withdrawal bucket.After the withdrawal becomes claimable, the staker calls:
The staker receives the full
600,000. The valid approved payout is therefore completely bypassed.
Partial-withdrawal scenario
Two ordinary stakers deposit
300,000each before period 1 starts.The correct period-start exposure is
600,000.At exactly
periodOneStart, staker A withdraws300,000.The same-timestamp checkpoint overwrite changes the historical exposure snapshot from
600,000to300,000.A valid approved payout for
600,000is executed.Only
300,000is paid because the payout is capped by the corrupted snapshot. The remaining value stays claimable through the withdrawal bucket instead of remaining exposed to the full period-1 loss.
One-second control
The PoC repeats the full-withdrawal scenario with the withdrawal executed at periodOneStart + 1 instead of exactly at the period boundary.
In the control case:
This confirms that the issue is caused specifically by the same-timestamp checkpoint overwrite.
Reproduction command
From the repository root, after checking out commit 42f9ea5e43d88b35197b901acc2a8c24b314fa62 and copying the PoC file into test/foundry/, run:
Expected result:
The attacker is only an ordinary staker using withdraw(). No privileged attacker, oracle manipulation, invalid incident, or mainnet transaction is required.
Was this helpful?