88585 sc high same block period start withdrawal retroactively changes committed collateral and causes protocol insolvency
Submitted on Aug 13th 2026 at 23:30:42 UTC by @Porcupine17126 for Audit Comp | Firelight
Report ID: #88585
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
FirelightVault uses a timestamp-keyed checkpoint both as mutable ERC-4626 history and as the immutable collateral snapshot for a cover period. A lookup at the period-start timestamp can therefore change later in the same block after the allocator has already relied on it.
If an allocation is committed in the first block of a period, an unprivileged staker can place a withdrawal after that commitment in the same block. Every transaction in the block has the exact period-start timestamp. The commitment first reads the last checkpoint at or before the boundary and receives the pre-withdrawal 10,000-unit value. It does not itself write a vault checkpoint. The later withdrawal calls _logTrace() with zero active assets at the boundary key, so every subsequent lookup at that same period start returns zero. If a checkpoint already exists at that key, Trace256.push() replaces it; in the demonstrated sequence, the withdrawal inserts the first exact-boundary checkpoint after the commitment's read. In either case, a later same-timestamp state change retroactively changes the historical lookup result on which the existing commitment relied.
The commitment remains valid and its orders can still settle, charge premium, and mint cover NFTs. A later claim, however, reads the changed period-start value as its vault exposure cap. Its vault-funded leg can pay zero even though all of that collateral is still physically held in the vault's explicitly exposed withdrawal pool.
The PoC demonstrates the complete production payout path with a funded and approved first-loss buffer: 1,000 USDC FLB + 10,000 vault assets back 11,000 units of cover.
A staker deposits 10,000 vault assets.
The external FLB is funded with 1,000 USDC and approves the real
IncidentManager.A cover buyer creates an 11,000-unit order for the next period.
In the first block of that period, the trusted allocator commits 11,000 of capacity.
In the same block, after the commitment, the unprivileged staker redeems all shares.
The order settles, premium is charged, and its CoverNFT is minted.
The vault still holds all 10,000 assets in
pendingWithdrawAssets, buttotalAssetsAt(periodStart)is now zero.The real incident flow creates and confirms the incident, assesses the 11,000 loss, submits it, and approves it.
The FLB leg legitimately pays its full 1,000 USDC, but the remaining 10,000-unit vault leg pays zero.
The incident is permanently closed with only 1,000 of 11,000 paid and
vaultPaidAmount == 0.After the withdrawal delay, the staker claims all 10,000 vault assets.
No privileged key is compromised. The allocator performs its intended action first; the attacker only uses the public redeem() function and transaction ordering in an exact-boundary block. The opportunity is conditional rather than deterministic: it exists whenever the trusted commitment lands in a block whose timestamp equals the exact period start.
Root cause
redeem() records post-withdrawal assets at the current block timestamp:
_logTrace() pushes that value using Time.timestamp():
The custom Trace256.push() implementation appends an exact-boundary checkpoint when none exists and replaces the last checkpoint when key == lastKey. The demonstrated commitment is a read, not a checkpoint write: it receives 10,000 from the last checkpoint at or before the new boundary. The later withdrawal inserts zero at that exact boundary, making all future lookups for the value already consumed by the commitment return zero. If another state change had already written the boundary key, push() would replace that value instead. The security failure is the retroactive mutation of the lookup result, not a requirement that the PoC literally replace an existing array entry.
Both underwriting and payouts depend on that mutable key. CoverOrderAllocator._computeAvailableCapacity() reads:
and FirelightVault.payout() later caps the claim with:
After the boundary withdrawal, activePayableAmount correctly includes the 10,000-unit withdrawal pool, but the retroactively changed assetsAtCapturePeriod == 0 forces paidAmount == 0.
Impact
The system sells and charges for cover backed by a capacity commitment whose vault leg becomes unpayable after a public vault action. The correctly funded and approved FLB does not prevent the issue: it is consumed first, then the attacker retains the entire vault withdrawal while the approved claim is permanently closed with the vault remainder unpaid. The attack can affect up to the vault backing committed in that period, producing liabilities without payable collateral and therefore protocol insolvency.
The attack is opportunistically repeatable at period boundaries whenever the allocator commitment lands in a block whose timestamp equals the exact period start. The attacker does not control the timestamp or the allocator, but can monitor the pending commitment and order a public redeem() after it. The PoC uses ordinary same-block transaction ordering; it does not alter the timestamp between transactions.
Recommended remediation
Do not use a mutable timestamp checkpoint as an immutable underwriting-period snapshot. Pin the period-start backing in dedicated per-period storage before the first state change of the period, and use that same immutable value for both capacity commitment and claims. In particular, a deposit, withdrawal, reward checkpoint, or payout mined at the period-start timestamp must not be able to alter the already-observed start-of-period value.
A regression test should commit capacity first and then execute redeem() in the same period-start block. The period-start backing used by payout() must remain 10,000 and the exposed withdrawal pool must remain slashable.
Novelty / duplicate distinction
This is distinct from the previously reported stale-capacity-after-payout issue. That report concerns a prior-period incident spending collateral while the following period reuses an older snapshot. This finding requires no earlier incident or payout. Its root cause is a same-timestamp state change retroactively altering the exact period-start lookup after a valid current-period commitment.
The fixes and regression tests are different. The earlier report requires remaining period backing to be reduced after a cross-period payout. This finding requires a period-start value already consumed by a commitment to become immutable against later writes with the same timestamp.
Proof of Concept
Proof of Concept
Create test/research/periodBoundaryEndToEnd.test.js with the complete source below, then run:
The first test demonstrates the exploit through the real allocator, funded/approved FLB, CoverNFT settlement, and IncidentManager payout. The second test is the boundary + 1 second control and proves the failure is specific to an exact period-start write.
Observed focused result:
Control assertions:
withdrawal timestamp = boundary + 1
totalAssetsAt(boundary) = 10,000
pendingWithdrawAssets = 10,000
vaultPaidAmount = 10,000
Full repository regression:
Was this helpful?