89588 sc high same block checkpoint overwrite in firelightvault payout lets any depositor escape assessed loss exposure leaving the protocol unable to honour cover it already sold
Submitted on Aug 20th 2026 at 01:33:18 UTC by @Pwning for Audit Comp | Firelight
Report ID: #89588
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
Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield
Temporary freezing of funds for at least 24 hours
Description
Brief/Intro
FirelightVault.payout caps every incident payout at min(assetsAtCapturePeriod, activePayableAmount), where assetsAtCapturePeriod is totalAssetsAt(capturePeriodStart) — the head of a Checkpoints.Trace256. Because Trace256.push overwrites on an equal key instead of appending, a permissionless withdraw mined in the block whose block.timestamp equals the period start rewrites the exact snapshot that the allocator already consumed when it sized the period's sellable cover, and that the payout will later read as its cap. The protocol therefore sells cover against capital it then treats as absent: in the measured run a fully-assessed claim of 900,000,000,000 asset units is paid only 200,000,000,000, the cover buyer is short 700,000,000,000 (700,000 USDC), and 560,000,000,000 (560,000 USDC) of that shortfall is retained by the withdrawing depositor who was contractually exposed to it. No privileged role acts and no role misbehaves — the attacker is an ordinary LP calling a permissionless function.
Vulnerability Details
The two operands, and why one defeats the other
FirelightVault.payout computes the payable amount at FirelightVault.sol:927:
activePayableAmount is a deliberate anti-escape invariant. It adds pending withdrawals back into the payable base, and the vault's own comment at FirelightVault.sol:918-920 states the purpose verbatim:
"…withdrawals requested before payout during that following period are assigned to capturePeriod + 2 and should also remain payable; otherwise exposed assets could escape by withdrawing first."
That invariant is correctly implemented and it works. The sibling operand voids it.
Why assetsAtCapturePeriod is mutable
totalAssetsAt reads a checkpoint trace. Checkpoints.sol:207-208 overwrites rather than appends when the pushed key equals the latest key:
The key is block.timestamp. Every vault balance mutation routes through _logTrace at FirelightVault.sol:1044-1056:
So a withdraw landing in a block whose block.timestamp == periodStart replaces totalAssetsAt(periodStart) rather than adding a new point after it.
The same key is also what sizes the cover
CoverOrderAllocator.sol:711, inside _computeAvailableCapacity, reads the identical key:
Its own doc comment at CoverOrderAllocator.sol:690 shows the intended reading — a value fixed for the period:
"Values staked assets at
currentPeriodStart(), so it is only meaningful for the current period (enforced by callers)."
Two contracts read one storage key at two different times and both assume it is immutable for the period. It is not.
The contradiction inside the vault itself
The vault classifies a single transaction two ways at once. _requestWithdraw buckets the withdrawal into withdrawAssets[currentPeriod() + 1], treating it as belonging to the new period — which is exactly what keeps it inside activePayableAmount and therefore payable. Simultaneously, totalAssetsAt(periodStart) folds that same withdrawal into the snapshot taken before that period began, which is what caps the payment.
The withdrawal is counted as still exposed by one operand and as already gone by the other, and Math.min lets the second one win.
Measured, both operands, immediately before payout
The PoC reads both operands directly from the live contracts one call before payout executes:
totalAssets()
200000000000
200000000000
withdrawAssets[capturePeriod+1]
800000000000
800000000000
activePayableAmount (operand A)
1000000000000
1000000000000
assetsAtCapturePeriod (operand B)
1000000000000
200000000000
resulting paidAmount
900000000000
200000000000
activePayableAmount is identical in both arms, and so are the vault's liquid totalAssets() and the withdrawal bucket. The claim is therefore not truncated because the assets are absent — they are present and payable, and the anti-escape mechanism correctly says so. It is truncated solely because the sibling operand was rewritten. This is what makes the finding a constraint bypass rather than a capacity shortfall, and it is why the vault's stated intent for that operand — "the active assets committed at the start of the covered period" (:909-910) — is false at the moment it is read. The assets committed at the start of that period were 1,000,000,000,000.
Control validity
The two arms differ in exactly one variable. Measured and emitted by both:
withdrawAssets[targetPeriod+1]
800000000000
800000000000
currentPeriod() at withdrawal
2
2
target period
2
2
offset of withdrawal from periodStart
0 s
1 s
Identical bucket, identical period, identical amount. The single difference between a full payout and a 700,000 USDC shortfall is a one-second difference in the withdrawal's block timestamp.
Reachability — three preconditions, each measured
1. A block must exist whose timestamp is exactly periodStart. Stated plainly because the PoC forces it with evm_setNextBlockTimestamp: a write one second either side of periodStart creates a new checkpoint and changes nothing. Measured against Flare mainnet, two independent 300-block samples taken minutes apart:
Roughly 88–90% of any given second on Flare carries a block bearing exactly that timestamp, so the boundary block is the ordinary case, not a lucky one. Every sampled block had a distinct timestamp, so when it exists it is unique.
2. The allocator's commit must land in that same block. The commit reads currentPeriodStart() — a computed timestamp, not a block property — and the project's own MockCoverOrderAllocatorVault.sol:38-40 documents the operating mode:
"Mirror FirelightVault semantics for the off-chain matcher: the current period started the moment it rolled. The off-chain engine pins its matching reads to the first block at/after this timestamp."
If the commit instead landed in a later block, any write at key periodStart would necessarily precede it, so the commit would read the already-reduced value and size cover correctly.
That dependency is itself the defect, not a mitigation of it. Nothing in CoverOrderAllocator or FirelightVault constrains when the commit is submitted. The contracts read a mutable checkpoint as though it were immutable for the period, and whether that assumption holds is decided entirely by off-chain submission timing the contracts neither observe nor enforce. A safety property — that cover is never sold against capital which has already left — must not rest on the punctuality of an off-chain engine. This report therefore does not require the reviewer to accept any claim about how the production matcher behaves today: if it commits in the boundary block the loss is reachable now, and if it does not, the contracts still carry a latent unsafe assumption that any future change in engine latency, block cadence, or a retry path converts into the loss below, with no contract change and no warning.
3. The withdrawal must be ordered after the commit within that block. This is a genuine transaction-ordering requirement and is not glossed. The PoC asserts it rather than assuming it — it checks the withdrawal shares the commit's block number and carries a strictly higher transaction index, with the commit paying 100 gwei priority and the attacker 1 gwei. Being ordered after a known transaction is the easy direction: this is a back-run, not a front-run. The period start is deterministic (epoch + n * duration), so the attacker does not need to observe the commit — they pre-submit a low-priority withdrawal targeted at that block, and a miss costs nothing beyond an ordinary withdrawal.
Impact Details
Selected in-scope impact: Protocol insolvency
The protocol sells 900,000,000,000 asset units of cover, an incident assesses the full amount, and the contract honours 200,000,000,000. It cannot meet an obligation it has already sold, while the capital that backed it leaves to the LPs. Equivalently, direct theft of any user funds, whether at-rest or in-motion: the claimant's payment is funds in motion, 700,000,000,000 of it never arrives, and 560,000,000,000 is retained by the attacker.
The loss, measured
Scenario: a 1,000,000-token vault (6-decimal asset), cover sold at 90% of it, attacker holding 80% of deposits.
capacity committed
900000000000000000000000
900000000000000000000000
0
totalAssetsAt(periodStart) after the block
1000000000000
200000000000
rewritten
claim assessed
900000000000
900000000000
0
claim paid
900000000000
200000000000
−700000000000
attacker's assets recovered
80000000000
640000000000
+560000000000
The attacker's position, stated precisely
The attacker does not end up with more than they deposited. They deposit 800,000,000,000 and recover 640,000,000,000, so in absolute terms they are down 160,000,000,000. What they gain is escape from a loss they were contractually exposed to. Had the claim paid in full — the control's outcome, and the design's intent — their pro-rata share of the assessed loss would have left them with 80,000,000,000:
The 560,000,000,000 figure is corroborated two independent ways: as the cross-arm difference in the attacker's recovered assets (640B − 80B), and as the within-arm escaped-loss identity above. Both are emitted by the PoC, and the control emits 0 for all three.
The loss scales with the attacker's LP share and with the size of the assessed incident.
Why the operator recovery path does not cap this
The exposure cap is per call rather than aggregate — the program's published known issue [3] — so operators can recover the buyer by opening further incidents against the same order. That path was executed and it does work. It is nonetheless not a mitigation that caps severity, for three measured reasons.
1. It is not the code's behaviour; it is discretionary human work the protocol never signals. payout truncates silently via min(). It does not revert, the incident is set CLOSED after the partial payout, and no path reopens it. Nothing in the contracts tells an operator that the snapshot was rewritten or that the buyer is short.
2. It requires the CURATOR to fabricate incidents that did not occur, purely as a payment rail — 4 extra incident cycles at the measured 80% share, up to 17 at 95%, each cycle five transactions needing both CURATOR and ASSESSMENT_APPROVER. Manufacturing loss events that never happened is not a documented privilege of that role, so this is not an operator using the system as designed.
3. The window is hard-bounded and shuts at exactly the moment the attacker can take the money. _isPeriodInPayoutWindow is current == period || current == period + 1, so payout is impossible from capturePeriod + 2 onward. claimWithdraw requires period < currentPeriod() and the withdrawal sits in capturePeriod + 1, so the attacker's first opportunity to claim is currentPeriod() == capturePeriod + 2. Recovery ends and extraction begins in the same period. With the on-chain period duration of 86400 seconds, operators have at most two days, unprompted.
Operator recovery cost against the size of the boundary withdrawal, measured:
50%
500000000000
500000000000
400000000000
1
80%
200000000000
200000000000
700000000000
4
90%
100000000000
100000000000
800000000000
8
95%
50000000000
50000000000
850000000000
17
If that work is not done in time, the cover buyer's shortfall is permanent and the escaped loss stays escaped. No operator has to do anything wrong for this — they simply have to not perform an undocumented remediation that nothing told them was needed.
Fallback framing: if the reviewer credits the recovery path, the honest fallback impact is temporary freezing of funds for at least 24 hours, since the payout window is two periods and the on-chain period duration is 86400 seconds.
Live-chain context
Measured today against Flare mainnet via test/fork/v2_upgrade_mainnet.js (4 passing):
The live FirelightVault proxy's _traceTotalAssets history is dense and populated, so totalAssetsAt returns real values in production and the period duration bounding the recovery window is the deployed one rather than a test constant. IncidentManager and CoverOrderAllocator are not yet deployed on Flare — the expected state of code under a pre-deployment audit. This finding is against the audited branch v1_audit_ready at commit 42f9ea5; the live figures are corroboration that the checkpoint history the exploit manipulates is real, not a contingency for the finding.
Suggested fix
Read the exposure snapshot at periodStart - 1, or push a period-start checkpoint on the first write of each period. Either makes the value immune to a later write on the same timestamp key and restores the invariant activePayableAmount already encodes.
A narrower alternative: drop assetsAtCapturePeriod from the min() when the withdrawal it accounts for is still inside activePayableAmount, since in that case the two operands double-count the same assets in opposite directions.
Note on scope
This is not a duplicate of known issue [3]. That issue concerns aggregate claims exceeding cover sold. This is the opposite direction — cover sold exceeding the claims the contract will pay — and known issue [3] is what makes the operator recovery path possible at all, not what causes the bug.
References
Target: https://github.com/immunefi-team/audit-comp-firelight Branch v1_audit_ready, commit 42f9ea5e43d88b35197b901acc2a8c24b314fa62.
FirelightVault.sol:927— themin()whose anti-escape operand is defeatedFirelightVault.sol:911—assetsAtCapturePeriod = totalAssetsAt(capturePeriodStart)FirelightVault.sol:909-910— the comment stating the operand's intended meaningFirelightVault.sol:918-920— the comment stating the invariant being brokenFirelightVault.sol:1044-1056—_logTrace, keying the checkpoint onblock.timestampFirelightVault.sol:1130-1132—_isPeriodInPayoutWindowFirelightVault.sol:788-790—claimWithdrawperiod gateCoverOrderAllocator.sol:711— sizes the period's cover from the same keyCoverOrderAllocator.sol:690— doc comment asserting the value is fixed for the periodCheckpoints.sol:207-208— overwrites, rather than appends, on an equal keyMockCoverOrderAllocatorVault.sol:38-40— the project's own statement of the commit-at-period-roll operating mode
Proof of Concept
Proof of Concept
Environment
Save the file in Full PoC source below as test/integration/k_ts1_backrun.js. No in-scope contract is modified — verified with git diff --stat HEAD -- contracts/core contracts/oracle, which is empty. The only mocked contracts are the vault's asset token (MockERC20) and the price feed (MockAggregatorV3); the vault, allocator, IncidentManager and CoverNFT are all real. Neither mock lies on the path this finding concerns.
Step-by-step
Both arms execute the identical sequence. The only difference is the boolean sameBlock, which decides which block the whale's withdraw is mined in.
Seed the vault. Two depositors fund a 1,000,000-token vault: the attacker ("whale") deposits 800,000,000,000 (80%) and a passive LP deposits 200,000,000,000 (20%).
Advance to period P-1 and write a cover order. The CURATOR calls
createCoverOrderfor 900,000 canonical USD of cover against marketMorpho/market-A. The order'speriodis asserted to equalpPrev + 1— it targets period P.Read the period boundary.
periodStart = vault.currentPeriodEnd(), which is the first timestamp of period P.Build the boundary block. Disable automine and pin the next block's timestamp to exactly
periodStart:Submit the allocator's commit first.
commitAllocation(targetPeriod, root, coverAmount, coverAmount)at 100 gwei priority fee. Internally this readstotalAssetsAt(currentPeriodStart())= 1,000,000,000,000 and commits that as the period's available capacity.Back-run it with the withdrawal — treatment only.
withdraw(WHALE, whale, whale)at 1 gwei priority fee, so the mempool orders it after the commit._requestWithdrawcalls_logTrace, which pushes a checkpoint keyedblock.timestamp == periodStart. BecauseTrace256.pushoverwrites on an equal key,totalAssetsAt(periodStart)is rewritten from 1,000,000,000,000 down to 200,000,000,000. In the control this same withdrawal is instead mined in the next block (periodStart + 1), where it appends a new checkpoint and leaves the period-start value intact.Mine the block and assert the ordering — not assume it.
Capture control-validity evidence. Read
withdrawAssets[targetPeriod+1],currentPeriod(), and the withdrawal block's timestamp offset fromperiodStart. These come out identical in both arms except the offset (0 s vs 1 s).Settle the cover order.
settleCoverOrder(orderId, allocations, [])succeeds against the capacity committed in step 5, at the full 900,000 canonical USD in both arms. The protocol has now sold cover against capital that, in the treatment, the vault's own snapshot says has left.Open and assess an incident in period P at the full settled cover amount:
createIncident(periodStart, …)→confirmIncident→addAssessmentLosses(amount = settled.allocatedCoverAmount)→submitCurrentAssessment.Read both
min()operands immediately before payout.Approve the assessment, triggering the payout.
approveCurrentAssessment(incidentId)drivesFirelightVault.payout. Measure the delta in the payout receiver's balance.Control: paid 900,000,000,000 — the full assessed claim.
Treatment: paid 200,000,000,000 — short by 700,000,000,000.
Run the operator recovery arm (optional,
K_TS1_WORKAROUND=1). Loop opening further incidents against the same order whileisPeriodInPayoutWindow(targetPeriod)holds, to test honestly whether operators can recover the buyer. They can — in 4 cycles at the 80% share — which is why the report addresses that path explicitly rather than hiding it.Advance two periods and let the attacker exit.
claimWithdraw(targetPeriod + 1).Control: attacker recovers 80,000,000,000 — they bore their full pro-rata share of the loss.
Treatment (no operator recovery): attacker recovers 640,000,000,000 — they escaped 560,000,000,000 of it.
Commands and observed output
Operator recovery arm (both environment variables are required — the loop's default round cap of 12 truncates the 95% row and prints a shortfall that is an artifact of the harness, not a property of the protocol):
Supporting measurement 1 — Flare block cadence (reachability precondition #1)
Two runs minutes apart:
Supporting measurement 2 — live Flare state
Full PoC source — test/integration/k_ts1_backrun.js
Was this helpful?