For the complete documentation index, see llms.txt. This page is also available as Markdown.

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:

control
treatment

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:

treatment
control

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.

control
treatment
delta

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:

attacker's share
snapshot after
first payout
recovered
extra incident cycles

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.

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.

  1. 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%).

  2. Advance to period P-1 and write a cover order. The CURATOR calls createCoverOrder for 900,000 canonical USD of cover against market Morpho/market-A. The order's period is asserted to equal pPrev + 1 — it targets period P.

  3. Read the period boundary. periodStart = vault.currentPeriodEnd(), which is the first timestamp of period P.

  4. Build the boundary block. Disable automine and pin the next block's timestamp to exactly periodStart:

  5. Submit the allocator's commit first. commitAllocation(targetPeriod, root, coverAmount, coverAmount) at 100 gwei priority fee. Internally this reads totalAssetsAt(currentPeriodStart()) = 1,000,000,000,000 and commits that as the period's available capacity.

  6. 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. _requestWithdraw calls _logTrace, which pushes a checkpoint keyed block.timestamp == periodStart. Because Trace256.push overwrites 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.

  7. Mine the block and assert the ordering — not assume it.

  8. Capture control-validity evidence. Read withdrawAssets[targetPeriod+1], currentPeriod(), and the withdrawal block's timestamp offset from periodStart. These come out identical in both arms except the offset (0 s vs 1 s).

  9. 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.

  10. Open and assess an incident in period P at the full settled cover amount: createIncident(periodStart, …) → confirmIncident → addAssessmentLosses(amount = settled.allocatedCoverAmount) → submitCurrentAssessment.

  11. Read both min() operands immediately before payout.

  12. Approve the assessment, triggering the payout. approveCurrentAssessment(incidentId) drives FirelightVault.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.

  13. Run the operator recovery arm (optional, K_TS1_WORKAROUND=1). Loop opening further incidents against the same order while isPeriodInPayoutWindow(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.

  14. 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?