88570 sc high period boundary checkpoint overwrite creates unbacked cover and zeroes valid payouts
Submitted on Aug 13th 2026 at 21:29:06 UTC by @v_c0d35 for Audit Comp | Firelight
Report ID: #88570
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
Period-boundary checkpoint overwrite creates unbacked cover and zeroes valid payouts
Brief/Intro
At assessed commit 42f9ea5e43d88b35197b901acc2a8c24b314fa62, an unprivileged staker can deposit immediately before an honest period-start capacity commitment and redeem immediately afterward under the same block timestamp, retroactively replacing the collateral checkpoint with zero while the allocator retains and settles the committed cover and the vault subsequently pays zero for a valid claim.
Vulnerability Details
FirelightVault.currentPeriodStart() calculates the current boundary from the configured epoch and duration (FirelightVault.sol lines 342–345). Production execution requires a produced block whose timestamp equals that exact boundary, the honest allocator's commitment in that block, and transaction ordering of the attacker's deposit, the honest commitment, and the attacker's redemption. This exact-timestamp boundary block and three-transaction placement are tight exploitation constraints; once they are satisfied, every call follows an accepted contract path.
The attacker first calls the public deposit. After the ordinary pause, blocklist, incident, amount, and deposit-limit checks, _depositFunds increases active assets and records the resulting value through _logTrace (FirelightVault.sol lines 646–673, FirelightVault.sol lines 976–992):
In the submitted PoC, this writes 10,000 six-decimal asset units at the period-start timestamp. The historical accessor returns the latest checkpoint at or before the requested timestamp, so totalAssetsAt(periodStart) now reports those 10,000 units (FirelightVault.sol lines 450–458).
The honest allocator then commits its allocation. commitAllocation validates the matching capacity against _computeAvailableCapacity and stores it in commit.totalAvailableCapacity (CoverOrderAllocator.sol lines 332–356, CoverOrderAllocator.sol lines 394–403). Capacity is calculated from the vault's assets at currentPeriodStart(), the asset price, first-loss buffer, effective leverage, minimum capital adequacy ratio, and divergence tolerance (CoverOrderAllocator.sol lines 689–724):
The PoC uses a $1 price, equal effectiveLeverage and minCAR values for one-to-one capacity, zero first-loss-buffer balance, zero divergence tolerance, and a 100% market concentration limit. The 10,000-unit checkpoint therefore permits and stores 10,000 canonical units of capacity.
The attacker next redeems every share without advancing the timestamp. redeem calculates the assets, moves them into delayed-withdrawal accounting, and logs zero remaining active assets (FirelightVault.sol lines 717–743). _requestWithdraw increases withdrawAssets and pendingWithdrawAssets, so the 10,000 tokens remain held by the vault while awaiting the attacker's later claim (FirelightVault.sol lines 994–1027). _logTrace pushes that zero active-asset value under the unchanged block timestamp (FirelightVault.sol lines 1044–1056).
The decisive behavior is Checkpoints.Trace256.push: when the new key equals the latest key, it overwrites the value in place rather than appending a distinct record (Checkpoints.sol lines 193–215):
The redemption consequently changes totalAssetsAt(periodStart) from 10,000 to zero after that same value has already justified the commitment. The commitment remains 10,000 because the allocator copied the validated capacity into its own storage.
Settlement loads this stored commitment, verifies the allocation proof, derives the concentration cap from commit.totalAvailableCapacity, marks a fully allocated order MATCHED, and increments commit.totalSettledCover without rereading the vault snapshot (CoverOrderAllocator.sol lines 457–524). It then transfers the allocated premium from the buyer to the premium collector (CoverOrderAllocator.sol lines 529–535). The order is therefore fully settled against capacity whose source snapshot now reads zero.
For a claim captured in the affected period, FirelightVault.payout independently reads the overwritten period-start snapshot. Although its activePayableAmount includes withdrawals requested in the capture period, paidAmount is the minimum of the requested amount, assetsAtCapturePeriod, and activePayableAmount (FirelightVault.sol lines 895–930):
Because assetsAtCapturePeriod is zero, a valid 10,000-unit request transfers zero even while the vault holds the attacker's 10,000 pending-withdrawal units. After the delay, claimWithdraw reduces pendingWithdrawAssets and transfers the full withdrawal to the attacker (FirelightVault.sol lines 788–810).
The submitted PoC deploys the public contracts locally, performs this exact sequence, and asserts each intermediate state: 10,000 assets at the boundary before commitment, 10,000 stored canonical capacity, zero historical and active assets after redemption, 10,000 pending-withdrawal assets, a fully MATCHED order, zero payout, and full withdrawal recovery by the attacker. A fresh public checkout run with Forge 1.5.1-stable and Solc 0.8.28 passed its single test: 1 passed, 0 failed.
Impact Details
In the demonstrated configuration, the attacker temporarily supplies 10,000 units of a six-decimal vault asset valued by the mock oracle at $1 per unit. The allocator records and settles nominal $10,000 cover, the buyer pays the corresponding settlement premium, and a valid 10,000-unit vault payout transfers $0 to the configured on-chain payout receiver. The attacker later receives the full 10,000-unit pending withdrawal. The direct on-chain shortfall is therefore 10,000 vault-asset units, while the buyer also paid the premium for that settled allocation; the attacker's demonstrated economic commitment is the temporary use of the injected principal through the withdrawal delay.
For an injected amount X, let Xc be its floor-converted 18-decimal value, P the oracle USD price, d the oracle decimals, L the effective leverage, C the minimum capital adequacy ratio, and T the divergence tolerance in basis points. With zero first-loss-buffer balance, the source formula yields approximately:
The actual committed and settled amount remains bounded by capacity validation, the allocator's declared allocation, market concentration, order size, and configuration limits. For a valid payout request, the realized on-chain shortfall is bounded by the matched cover and requested amount; when the overwritten capture-period cap is zero, the vault transfers none of the otherwise payable request to the configured receiver.
The loss mechanism scales with the amount of temporary capital that can pass the vault's deposit limit and the capacity permitted by the active oracle and allocator configuration. It directly affects funds expected to satisfy covered claims because the same mutable period-start checkpoint first authorizes settlement and later acts as the payout exposure cap.
References
Proof of Concept
The test places three calls at one exact period-start timestamp:
The attacker deposits 10,000 six-decimal vault-asset units.
The honest allocator commits 10,000 canonical units of capacity using that period-start checkpoint.
The attacker redeems all shares, causing the checkpoint at the same timestamp to be replaced with zero.
The allocator can still settle the 10,000-unit cover as MATCHED, but the vault subsequently caps a payout for that capture period at zero. After the withdrawal delay, the attacker claims the full 10,000 units.
Prerequisites
Install:
Git.
Node.js and npm. The verified run used Node.js 24.13.0 and npm 11.6.2.
Foundry, including
forge. The verified run used Forge 1.5.1-stable; Forge downloads Solc 0.8.28 when it builds this test.Internet access for the public Git repositories, npm packages, and Solidity compiler.
If Foundry is not already installed, use its standard installer and then open a new shell:
No RPC URL, funded key, environment file, or deployed-contract address is needed.
1. Obtain the assessed public source
Run these commands in a directory where you want the fresh checkout to be created:
The final command must print:
This pins the checkout to the exact public revision used for the reproduction even if the branch later moves.
2. Install the repository dependencies and forge-std
From the repository root, run:
Allow the repository's npm postinstall step to run. It applies the checked-in OpenZeppelin patch. The second command places the public forge-std test library at lib/forge-std and pins the same revision used for the verified run.
The npm lock file refers to public GitHub and GitLab repositories through SSH URLs. If npm reports an SSH authentication error, configure Git to rewrite those public SSH URLs to HTTPS, rerun npm install, and then continue. No private dependency is required.
3. Add the Foundry configuration
Save the following file as foundry.toml in the repository root, replacing it if it already exists:
The fasset exclusion keeps the reproduction focused on the Firelight core contracts and avoids compiling the separate legacy dependency tree. The PoC still uses the repository's real FirelightVault, CoverOrderAllocator, CoverNFT, checkpoint library, mock ERC-20, and mock price feed.
4. Add the proof of concept
Save the following file as test/CapacitySnapshotReplacement.t.sol:
5. Build and run
From the repository root, run:
forge build should finish with:
The verified fresh-checkout test run contained these exact result lines:
Gas reporting can differ across Foundry versions, but the test name, PASS, and zero-failure summary are the material results.
6. What the passing assertions establish
The test stops immediately if any of these state transitions differs:
vm.warp(boundary)makes the EVM timestamp equal the next period's exact start, and the vault reports period 1 beginning at that timestamp.After the attacker's deposit,
totalAssetsAt(boundary) == 10_000e6, proving the allocator has a nonzero period-start value to consume.The honest allocation commitment succeeds and stores
totalAvailableCapacity == 10_000e18.The attacker redeems without advancing time. Active assets and
totalAssetsAt(boundary)both become zero, whilependingWithdrawAssets == 10_000e6. The tokens have not left the vault; the repeated timestamp has replaced the checkpoint value.Honest settlement still marks the order
MATCHED, allocates and settles10_000e18, and leaves committed capacity greater than the now-zero period-start snapshot.A payout request for
10_000e6at the affected capture timestamp returns zero, and the payout receiver receives zero.After two periods, the attacker claims all
10_000e6, confirming that the temporary capital remained recoverable by the withdrawing staker rather than backing the payout.
The local test grants the trusted allocator and payout permissions to its own setup contract solely to exercise the same production entry points. The attacker's calls use only the ordinary staker operations deposit, redeem, and claimWithdraw; the PoC does not demonstrate that the attacker can obtain a privileged role.
7. Timing and production limitations
The PoC deliberately controls time and execution order. It proves that Firelight's contracts accept the vulnerable state transition when deposit, honest commit, and redeem execute at the identical period-start timestamp.
A production attempt additionally requires:
a produced Flare block whose timestamp is exactly the calculated period start rather than merely later than it;
the honest allocator to commit in that boundary block; and
the attacker's deposit to execute before that commit and the attacker's redemption after it in the same block.
A same block "sandwich attack".
No cleanup is necessary beyond deleting the disposable local checkout and Foundry build output.
Was this helpful?