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

88576 sc high commitallocation can underwrite cover against a period start snapshot that a same block withdrawal rewrites letting lp collateral escape slashing

Submitted on Aug 13th 2026 at 21:39:26 UTC by @CarlosMB for Audit Comp | Firelight

  • Report ID: #88576

  • 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

Summary

CoverOrderAllocator validates current-period cover capacity using:

vault.totalAssetsAt(vault.currentPeriodStart())

The value is treated as the vault collateral backing the period's committed cover.

However, totalAssetsAt(periodStart) is not immutable during the exact period-boundary timestamp. Firelight's custom Trace256.push() overwrites the latest checkpoint when another write uses the same timestamp key, while upperLookupRecent() returns the last checkpoint with key <= timestamp.

As a result, if an honest commitAllocation(P) executes in a block whose timestamp is exactly periodStart(P), an LP can execute withdraw() later in that same block. The withdrawal writes the post-withdraw active asset balance at the same timestamp and retroactively changes:

totalAssetsAt(periodStart(P)): 100 FXRP -> 0 FXRP

after the allocator has already accepted a commitment backed by the original 100 FXRP.

The 100 FXRP does not leave the vault. It is moved to withdrawAssets[P + 1], which FirelightVault.payout() explicitly includes as collateral that remains exposed to incidents captured during P.

A later valid claim nevertheless re-reads the rewritten period-start value and caps the vault payout to zero.

The end-to-end PoC reaches:

The protocol therefore settles real cover while the LP collateral that backed it becomes immune to the corresponding valid slash.


Root cause

1. Capacity is committed against totalAssetsAt(currentPeriodStart())

CoverOrderAllocator._computeAvailableCapacity() uses the historical vault query:

commitAllocation() then validates the submitted matching result against that capacity and persists the commitment:

Thus an underwriting decision is persisted based on the value returned by the period-start historical query.

2. The supposedly historical value is still mutable at the boundary timestamp

FirelightVault.totalAssetsAt() is:

Firelight's custom checkpoint implementation contains:

and the lookup returns the latest checkpoint whose key is lower than or equal to the requested timestamp.

Therefore a write at:

can change the value returned by totalAssetsAt(periodStart(P)) even after another transaction in the same block has already consumed the previous value.

3. Permissionless withdraw() performs exactly such a write

withdraw() requests the delayed withdrawal and then calls _logTrace() using the post-withdraw totals.

_logTrace() writes:

If:

then an LP withdrawal executed after commitAllocation(P) in that block writes the new active asset value at the exact key already used for underwriting.


Why the collateral is still supposed to be slashable

A withdrawal initiated during P is assigned to:

so the assets become:

They remain physically held by the vault.

FirelightVault.payout() explicitly includes this exact bucket for an incident captured in P:

The implementation documents these withdrawals as remaining payable while the payout window is open.

The vulnerability therefore does not rely on the LP legitimately leaving the risk set. The funds remain in the protocol's own current-period slashable accounting.

Proof of Concept

End-to-end exploit

Let:

The PoC uses a 30-day vault period and splits the cover across two independent protocol-concentration groups:

1. A legitimate order is created during P-1

The buyer creates a $110 order for P.

The LP has deposited 100 FXRP.

The external FLB wallet holds 10 USDC and has approved IncidentManager.

No oracle depeg or stale price is used; the vault asset oracle is refreshed to $1 immediately before the boundary.

2. Honest commitment at T

At the first valid period-P timestamp:

the honest allocator executes:

The live capacity validation sees:

and accepts the commitment.

3. LP withdraws later in the same block

The LP executes:

later in the same block.

The withdrawal produces:

and _logTrace() writes the post-withdraw active value using the same timestamp T.

After the block:

The underwriting decision remains, but the historical exposure value it was based on has changed.

4. Cover settles normally

After a non-zero one-hour settlement grace, the allocator settles the committed tree.

The real CoverNFT is minted and the order becomes fully matched:

Settlement does not re-establish the original vault exposure snapshot.

5. A legitimate incident is approved

The PoC uses the real IncidentManager lifecycle:

There is one incident and one assessment round.

The assessment contains exactly the settled losses:

No duplicate or excessive claim is used.

IncidentManager first pulls the properly funded FLB:

and then requests the remaining $100 from FirelightVault.

6. Vault payout is incorrectly capped to zero

payout() calculates:

The state is:

so:

The legitimate $110 assessment therefore receives only the $10 FLB.

The $100 vault-backed portion is left unpaid.

7. LP later recovers all collateral

Because the vault paid zero, withdrawAssets[P + 1] remains untouched.

After the delayed withdrawal becomes claimable:

returns the complete:

to the LP.

Terminal state:


Impact

Protocol insolvency.

The protocol sells and settles coverage using vault collateral that a permissionless LP can subsequently make unavailable to the exact claim that collateral is intended to secure.

The attack is not based on:

  • an unfunded or unapproved First Loss Buffer;

  • a stablecoin depeg;

  • oracle manipulation or a stale oracle;

  • duplicate claims;

  • an assessment exceeding settled cover;

  • a malicious curator, allocator, or assessment approver;

  • a zero settlement grace;

  • or an unusual one-market 100% concentration configuration.

All privileged actors in the PoC follow their intended lifecycle. The attacker only performs ordinary LP actions:


Feasibility precondition

The exploit is opportunistic and requires a block satisfying:

with ordering:

inside that block.

This is the earliest timestamp at which the commitment for P is valid, because commitAllocation() requires commitmentPeriod == currentPeriod().

The PoC does not modify contract storage to manufacture this state. It only controls the local block timestamp and transaction ordering to deterministically reproduce a valid EVM execution.

A non-zero one-hour settlement grace is used. No settlement occurs before the grace expires.


Proof of Concept

Save as:

and run:

Expected terminal assertions:


The period-start exposure used for underwriting and payout must become immutable once period P begins.

A robust design is to materialize the exposure once per period:

and enforce:

Both CoverOrderAllocator capacity validation and FirelightVault.payout() should consume the same immutable value.

Alternatively, the historical lookup semantics must exclude all writes occurring after the boundary state has been established, including writes sharing the exact boundary timestamp.

The required invariant is:

Was this helpful?