89854 sc high same block withdrawal rewrites committed period start assets and makes valid cover payouts zero
Submitted on Aug 21st 2026 at 14:23:06 UTC by @Shutaru for Audit Comp | Firelight
Report ID: #89854
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:
Protocol insolvency
Description
Severity and impact
Suggested severity: Critical — protocol insolvency.
A vault depositor can place a normal withdrawal after commitAllocation in the first block of a cover period. When both transactions have the exact period-start timestamp, the withdrawal replaces the totalAssets checkpoint that the allocator already used to commit cover capacity. A later valid incident then pays zero, even though the withdrawal remains pending and the depositor can claim all of it after the cooldown.
The depositor does not need a privileged role. The allocator, curator, and assessment approver follow their intended paths. The race requires the allocation commitment and withdrawal to land in that order in the exact period-boundary block.
Root cause
FirelightVault records totalAssets with block.timestamp as the checkpoint key:
Checkpoints.Trace256.push does not append a second checkpoint when the key matches the latest key. It replaces the stored value:
The allocator calculates period capacity from the checkpoint at currentPeriodStart():
Payout later reads the same historical timestamp and treats it as an exposure cap:
The value is therefore consumed as immutable history during allocation but remains mutable for the rest of that block.
Affected locations:
contracts/core/lib/Checkpoints.sol:198-214contracts/core/FirelightVault.sol:755-775contracts/core/FirelightVault.sol:911-930contracts/core/FirelightVault.sol:1044-1055contracts/core/CoverOrderAllocator.sol:696-719
End-to-end exploit
The PoC uses the real FirelightVault, CoverOrderAllocator, CoverNFT, and IncidentManager. Only the underlying token and price feed are local test dependencies.
The depositor deposits 100 units into the vault.
Automining is disabled and the next block timestamp is set to the next period start.
The allocator submits a valid commitment for 100 units of cover capacity.
The unprivileged depositor submits a withdrawal for all 100 units with lower transaction priority. Both transactions are mined in the same block; the PoC asserts that the commitment precedes the withdrawal.
The commitment stores
totalAvailableCapacity = 100andtotalDeclaredAllocated = 100.The later withdrawal replaces the checkpoint at the period-start timestamp.
totalAssetsAt(periodStart)changes to zero, whilewithdrawAssets[nextPeriod + 1]contains the full 100 units.A cover order is settled and a real
CoverNFTis issued.The curator creates and confirms an incident, adds the covered loss, and submits the assessment.
The assessment approver follows the normal approval path. The vault emits
PayoutExecuted(receiver, 100, 0, periodStart), the incident recordsvaultPaidAmount = 0, and its status becomesCLOSED. A second approval attempt reverts withInvalidIncidentStatus, so the unpaid amount cannot be retried through the assessment flow.After the cooldown, the depositor claims the full 100 units.
The negative control moves the withdrawal out of the boundary timestamp. In that case totalAssetsAt(periodStart) remains 100 and the vault pays the full 100-unit claim. This isolates equal-timestamp checkpoint replacement as the cause.
Practical feasibility
The period boundary is deterministic and public. A depositor can watch for the allocator's commitment and backrun it in the same boundary block. This is not a Hardhat-only timestamp condition: a read-only check of the deployed Phase 1 vault and recent Flare block history found blocks whose timestamps exactly matched 28 of the previous 30 scheduled daily period starts. Many of those boundary blocks contained multiple transactions.
The observation was made without changing chain state. The scoped proxy is:
Recommended fix
Do not use a timestamp checkpoint as mutable history after allocation has consumed it. Capture the vault asset value once for the period or commitment, store it immutably, and bind both allocation capacity and payout to that same value.
If the intended value is the state before any transaction in the new period, derive it from the last checkpoint strictly before the boundary and store it once. If deposits at the boundary should count, have the allocator create a one-time snapshot when it commits. In either design, a later transaction with the same timestamp must not change a snapshot already used to underwrite cover.
Add a regression test that commits allocation and then processes a withdrawal in the same boundary block. The payout cap must remain equal to the value used by the commitment.
Scope and evidence notes
No privileged role is assumed malicious.
No mainnet transaction was sent.
The PoC does not claim that the depositor steals the payout. It proves that committed slashable backing can escape while a valid covered claim receives zero.
The exact timing precondition is stated rather than hidden: the commitment and withdrawal must execute in order in a block timestamped at the period boundary.
Proof of Concept
Proof of Concept
This runnable Hardhat test uses the real FirelightVault, CoverOrderAllocator, CoverNFT, and IncidentManager. Only the underlying ERC-20 and price-feed dependencies are local test doubles.
Run from the frozen competition repository:
Expected result:
The full suite passes with this file included: 450 passing.
The positive case asserts all of the following:
A 100-unit allocation commitment executes before the withdrawal in the same period-boundary block.
The commitment records 100 units of capacity and declared allocation.
The later unprivileged withdrawal changes
totalAssetsAt(periodStart)from 100 to zero while the same 100 units remain queued inwithdrawAssets.A real cover order settles and mints a
CoverNFT.A confirmed incident and approved assessment request a 100-unit payout.
The vault emits
PayoutExecuted(receiver, 100, 0, periodStart).The incident becomes
CLOSEDwithvaultPaidAmount = 0; another approval attempt reverts withInvalidIncidentStatus.The payout receiver receives zero, while the withdrawing depositor later claims all 100 units.
The negative control moves only the withdrawal timestamp past the period boundary. The historical snapshot remains 100 and the same payout transfers all 100 units.
Test file
Was this helpful?