89511 sc medium mutable period start checkpoint lets a covernft callback grief payouts for already settled cover
Submitted on Aug 19th 2026 at 17:12:39 UTC by @ChadiSebbar for Audit Comp | Firelight
Report ID: #89511
Report Type: Smart Contract
Report severity: Medium
Target: https://github.com/immunefi-team/audit-comp-firelight/blob/v1_audit_ready/contracts/core/FirelightVault.sol
Impacts:
Griefing (e.g. no profit motive for an attacker, but damage to the users or the protocol)
Description
Summary
An attacker can deposit temporary collateral through a contract that is both a vault shareholder and a cover buyer. If the allocator commits and starts batch settlement at exact start(P), minting the attacker's Cover NFT invokes the buyer's ERC-721 receiver callback. The callback withdraws the temporary collateral after the allocator has stored the higher capacity. This withdrawal writes a lower vault checkpoint at start(P). The later incident payout uses that lower checkpoint even though settlement retained the higher committed cover. In the PoC, 1,000 units of cover remain settled, an honest order has 999 units of approved loss, the vault transfers zero to the configured aggregate payoutReceiver, and the incident closes with vaultPaidAmount == 0. The attacker later claims its full 1,000-unit delayed withdrawal.
The attacker recovers its temporary principal while the payout pipeline closes an incident with a 999-unit vault payout shortfall for honestly settled cover. The PoC does not measure attacker profit or theft.
Affected asset
Primary affected asset:
contracts/core/FirelightVault.sol
Relevant functions and components:
FirelightVault.withdraw,_requestWithdraw,_logTrace,totalAssetsAt, andpayout;CoverOrderAllocator.commitAllocation,_computeAvailableCapacity, and_settleCoverOrder;CoverNFT.safeMintand the standard ERC-721 receiver callback;Checkpoints.Trace256.push;IncidentManager.approveCurrentAssessmentand_executePayout.
The authoritative source is v1_audit_ready@42f9ea5e43d88b35197b901acc2a8c24b314fa62. This submission does not rely on a live deployment or live configuration.
Vulnerability details
Broken invariant
Given that the allocator accepts a commitment for period P using vault collateral at start(P), the settled cover and the later payout cap for P must use the same economic exposure.
The allocator and vault read the same historical value at different moments. The allocator stores capacity from the first read. A callback then writes a lower checkpoint at the same timestamp. The payout performs a second read and uses the lower value.
The allocator stores the higher capacity
commitAllocation validates the submitted matchingCapacity against live capacity and stores it in the period commitment:
_computeAvailableCapacity reads vault.totalAssetsAt(vault.currentPeriodStart()). At exact start(P), before any checkpoint has been written with that key, the historical lookup returns the most recent earlier value. That value includes the attacker's pre-period deposit.
The callback replaces the period-start exposure
Settlement updates the order and commitment before calling CoverNFT.safeMint(order.buyer, orderId). A contract buyer receives onERC721Received before batch settlement returns.
The attacker contract owns its vault shares. Its receiver callback calls the public FirelightVault.withdraw function for those shares. The withdrawal is delayed, but _logTrace immediately writes the reduced active assets using the current timestamp:
Because the timestamp equals start(P), this write becomes the historical value for the beginning of the period. The allocator commitment remains unchanged. Settlement continues against commit.totalAvailableCapacity and does not recompute vault capacity after the callback.
This produces incompatible state for the same period:
Payout trusts the lower value
For an approved incident, FirelightVault.payout calculates:
The vault still physically holds the delayed withdrawal assets. However, the zero assetsAtCapturePeriod value caps paidAmount at zero. The pending assets are therefore not charged for this incident.
IncidentManager.approveCurrentAssessment accepts the zero result, records vaultPaidAmount == 0, and sets the incident status to CLOSED. A second approval attempt reverts because the incident is no longer under evaluation. The frozen contracts do not record the unpaid amount as debt or expose a beneficiary claim function for it.
Attack scenario
Before
start(P), the attacker deposits 1,000 vault-asset units for an attacker-controlled contract.The attacker contract is the buyer of a 1-unit cover order. A separate honest buyer has a 999-unit order.
The matcher includes both orders in a 1,000-unit allocation.
At exact
start(P), the authorized allocator performs the ordinary commitment and batch-settlement flow.The commitment accepts the 1,000-unit capacity derived from the attacker's deposit.
Settlement safely mints the attacker's Cover NFT. The receiver callback withdraws all 1,000 vault-asset units.
The withdrawal writes zero to the exact period-start checkpoint and creates a delayed withdrawal entitlement.
Settlement retains 1,000 units of cover, including 999 units for the honest order.
Honest protocol roles approve a valid 999-unit loss against the honest order.
The vault reads the zero checkpoint and transfers zero to the configured aggregate
payoutReceiver.The incident closes. Retrying the normal approval path reverts.
In
P+2, the attacker claims its full 1,000-unit withdrawal entitlement.
The attacker controls only its deposit, contract buyer, NFT callback, and withdrawal. The allocator, curator, and assessment approver perform ordinary protocol actions.
Impact
The PoC measures:
Capacity committed and settled
1,000 units
Honest order's settled cover
999 units
Approved loss against the honest order
999 units
Final totalAssetsAt(start(P))
0 units
Vault transfer to aggregate payoutReceiver
0 units
Vault payout shortfall for honestly settled cover
999 units
Attacker deposit and later claim
1,000 / 1,000 units
Incident state
CLOSED, vaultPaidAmount == 0
All vault and First-Loss Buffer payments go to the configured aggregate IncidentManager.payoutReceiver. The honest order's stored payoutRecipient is a separate accounting identity. Final distribution to individual beneficiaries is off-chain and was not measured.
The normal manual distribution process cannot distribute vault funds that never reached the aggregate receiver. Firelight could choose exceptional off-chain compensation, but the frozen contracts neither guarantee nor record such remediation.
Limitations and nonclaims
The exploit requires honest commitment and first settlement while the block timestamp equals exact
start(P).Public evidence does not establish how often the honest runner lands both operations at that exact timestamp.
The PoC does not measure final off-chain transfers to individual
payoutRecipientaddresses.The result is a vault payout shortfall, not proof that a beneficiary receives zero from every possible external source.
The report does not claim attacker profit, theft, insolvency, live deployment, guaranteed repetition, or protocol-wide shutdown.
Private known-issue status is unavailable to the submitter.
Known-issue boundary
The closest public families concern collateral drawdown, capacity validation, historical checkpointing, and payout accounting. This mechanism requires a different ordering:
The public material reviewed for this competition does not describe this allocator-to-payout snapshot split or the surviving full withdrawal entitlement. Private known issues remain unknown.
Recommended fix
Materialize one immutable vault-exposure value for period P. Bind the allocation commitment, settlement limits, and incident payout cap to that same value. A later checkpoint written at the same timestamp must not change the exposure already accepted for the period.
Add regression coverage for both same-block orders:
Include a contract buyer that calls withdraw from onERC721Received. The first ordering must not leave settled cover above payout exposure. The second ordering must validate capacity against the reduced collateral.
Source references
Link to Proof of Concept
https://gist.github.com/Dar-Own/a25bf01456d5cf8f0c70d55251c91f60
Proof of Concept
Proof of Concept
This PoC reproduces the period-start checkpoint split against the frozen Firelight competition source. It demonstrates that a CoverNFT receiver callback can withdraw temporary collateral after higher cover capacity is committed. The callback changes the historical payout cap without changing the stored commitment or settled cover.
The test runs entirely on the local Hardhat network. It does not require an RPC, a .env file, live contracts, or privileged attacker credentials.
Frozen source and requirements
Repository:
https://github.com/immunefi-team/audit-comp-firelight.gitCommit:
42f9ea5e43d88b35197b901acc2a8c24b314fa62Node.js: 20 or 22
Package manager: npm
The PoC package contains three files:
Place the executable files at these paths in the frozen repository:
WithdrawOnCoverReceipt.sol is the attacker-controlled contract buyer. It owns its vault shares, authenticates the CoverNFT callback, and calls the production vault's public withdraw function once.
Setup
Clone and install the frozen source:
Create the destination directories:
Then place the downloaded Gist files at these exact paths:
Confirm the frozen commit:
Expected value:
Run the primary exploit
The test should finish with 1 passing and print:
The vault asset uses 6 decimals. Cover and assessed losses use 18 canonical decimals. The receipt displays human units after conversion.
Step-by-step explanation
1. Fund the attacker-controlled contract buyer
The attacker receives 1,000 vault-asset units and deposits them into FirelightVault. The vault shares are minted to WithdrawOnCoverReceipt.sol. The helper therefore owns the shares it later withdraws.
The attacker arms the helper for a one-time withdrawal of all 1,000 units.
2. Create attacker and honest orders
The ordinary curator flow creates two orders:
The attacker needs positive allocated cover so settlement mints a Cover NFT to the helper and invokes its ERC-721 receiver callback. The 999-unit order keeps the measured loss separate from the attacker's own cover.
3. Commit and begin settlement at exact start(P)
The test places the authorized allocator's commitAllocation and batchSettleCoverOrder calls in one block whose timestamp equals exact start(P).
Before the callback, totalAssetsAt(start(P)) returns the earlier 1,000-unit deposit. commitAllocation accepts and stores 1,000 units of capacity.
4. Withdraw from the CoverNFT callback
Settlement records the attacker's allocation and calls CoverNFT.safeMint. The safe mint invokes WithdrawOnCoverReceipt.onERC721Received.
The callback calls:
The withdrawal burns the helper's shares and creates a delayed entitlement for the attacker. It also writes zero active assets at the exact start(P) checkpoint. The helper clears its armed state before making the external vault call, so the callback is one-shot.
5. Verify that settled cover still uses the higher commitment
After the callback, the test verifies:
The honest order's stored payoutRecipient is also checked separately from the configured aggregate IncidentManager.payoutReceiver.
6. Approve an honest loss and execute payout
Honest protocol roles create and confirm an incident. They add a valid 999-unit assessment loss against the separate honest order and submit it for approval.
The assessment approver performs the normal approval action. The First-Loss Buffer balance is zero in this test, so the remaining approved loss is passed to the vault.
The vault calculates:
The test verifies:
It then attempts approveCurrentAssessment(incidentId) a second time. The call reverts with InvalidIncidentStatus(incidentId, CLOSED). This proves that the normal approval path cannot retry the unpaid vault amount after the incident closes.
7. Claim the attacker withdrawal
The test advances to P+2 and calls claimWithdraw for the attacker. The attacker receives the full 1,000-unit delayed entitlement.
The final measured result is:
Primary assertions
The test requires all of the following:
1,000 units of capacity are committed and settled;
the honest order retains 999 units of allocated cover;
the honest order stores its own
payoutRecipient;the aggregate
payoutReceiveris a different address;the First-Loss Buffer balance is zero;
the approved assessment loss is 999 units;
the aggregate receiver's vault-asset balance increases by zero;
the vault payout shortfall is 999 units;
the incident is
CLOSEDwithvaultPaidAmount == 0;retrying approval reverts with
InvalidIncidentStatus;the attacker retains a 1,000-unit withdrawal entitlement;
the attacker later claims all 1,000 units.
Causal controls
The same file contains three controls. No additional files are required.
Exact start versus one second later
This control holds 2,000 units of cover and approved loss constant. Withdrawing at exact start(P) produces a 1,000-unit payout shortfall. Withdrawing at start(P)+1 preserves the 2,000-unit period-start checkpoint and produces no shortfall.
Full-principal-recovery control
This control holds 1,000 units of cover constant. At exact start(P), the period-start checkpoint and payout are zero while the attacker later claims 1,000 units. At start(P)+1, the vault pays the full 1,000 units and the attacker has no remaining claim.
Same-timestamp transaction-order control
This control compares commit → withdraw with withdraw → commit at the same timestamp. The high commitment succeeds only when it is stored before the checkpoint falls.
Run the complete file with:
The complete file should finish with 4 passing.
What the PoC proves
The PoC proves that the frozen production contracts can reach this state under the tested exact-boundary ordering:
It also proves that the zero-paid incident closes, the normal approval path cannot retry it, and the attacker's full delayed withdrawal remains claimable in the primary scenario.
The PoC measures the transfer to the configured aggregate payoutReceiver. It does not measure final off-chain distribution to individual beneficiary addresses. It does not prove that exceptional manual compensation is impossible.
Scheduling limitation
The exploit requires honest commitAllocation and first settlement while the block timestamp equals exact start(P). Public evidence supports the protocol performing matching at period boundaries and the chain permitting that timestamp. Public evidence does not establish how often the honest runner lands both operations at that exact timestamp.
Was this helpful?