88631 sc medium firelightvault mint mints shares at zero price after an incident payout drains the vault breaking the erc4626 share price invariant and enabling capture of the recovery inflow
Submitted on Aug 14th 2026 at 08:58:07 UTC by @Galliform78689 for Audit Comp | Firelight
Report ID: #88631
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:
Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield
Description
Brief/Intro
Paying out covered losses is the core, designed purpose of FirelightVault: in a severe incident, IncidentManager calls vault.payout() and the vault pays out assets up to the capture-period exposure cap. When such a payout drains the active assets (totalAssets() == 0 while shares remain outstanding), mint() prices shares at ~0 because it has no minimum-value guard — breaking the ERC4626 invariant that shares can only be minted in exchange for a meaningful amount of assets. An attacker then mints a large position for ~1000 wei and captures the next reward inflow, so the funds that should accrue to existing holders are instead withdrawn by the attacker.
Vulnerability Details
1. The invariant that is broken
mint() (FirelightVault.sol:682-704) prices shares via _previewTotals -> _convertToAssetsTotals (FirelightVault.sol:1067-1077):
shares.mulDiv(totalAssets + 1, totalSupply + 10 ** _decimalsOffset(), Math.Rounding.Ceil)_decimalsOffset() is 0 (same-decimals asset). When totalAssets() == 0 and totalSupply() == S > 0, minting M shares costs:
which is sub-wei per share — effectively zero. The protocol defends the deposit side of this boundary: deposit() reverts when the minted shares round to zero (InvalidAmount), and the donation tests treat a zero-price deposit as a rejected dust deposit. mint() has no equivalent minimum-value guard — it only validates shares != 0, pause/incident state, and blocklist. This is an invariant violation: the share price can be reset to ~0 and shares can be created for ~nothing, which the vault's own conversion and deposit paths assume cannot happen.
2. The precondition is a designed, routine protocol operation
totalAssets() is defined as:
payout() (FirelightVault.sol:892) is the vault's incident-payout entry point: for a confirmed incident, IncidentManager._executePayout calls it and the vault pays out covered losses up to the period-start exposure cap, drawing from active assets and still-exposed withdrawals. A payout transfers assets out and only updates the total-assets checkpoint; it never burns shares. A severe incident — an insurance protocol's core scenario — can therefore pay out essentially all active assets, leaving totalAssets() == 0 while totalSupply() > 0. The drained state is not an admin abuse or a fabricated condition; it is the tail outcome of a legitimate, designed flow that the protocol must be able to survive.
3. The concrete attack
Impact Details
Reproduced numbers (test executed against commit
bd98a029…): fresh vault with a 1-unit deposit -> a legitimate full payout drains it (totalAssets() == 0,totalSupply() == 1e6) -> attacker mints1e9shares for 1000 wei -> a 1000-unit reward inflow arrives -> attacker'smaxWithdraw≈ 999 units (about 999,000,998 wei; more than half the reward and > 999,000x the cost). The legitimate holder ends with ≈ 1 unit instead of the ≈ 1001 units they would have received without the attack — the recovery inflow is effectively taken by the attacker.Selected impact: Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield (Critical tier per the program matrix).
Severity note: the underlying defect is a broken ERC4626 share-pricing invariant with a missing minimum-mint guard; exploitation additionally requires the vault to have been drained by a legitimate incident payout. Because that precondition is a real but tail-case protocol event that the attacker cannot trigger themselves, an expected final rating is Medium rather than Critical, and the report should be read with that precondition in mind.
References
Audit commit tested:
bd98a0292dd602e9bbfa94551862c6af6933b5f4
Proof of Concept
Step-by-step
Drain active assets through a legitimate payout
A confirmed incident is paid out through the protocol's normal flow: PAYOUT_ROLE (production caller: IncidentManager) calls payout(receiver, 1 unit, captureTimestamp = period-1 start) -> the vault transfers out its entire active balance: totalAssets() == 0, totalSupply() > 0.
Runnable PoC (Hardhat test)
Place the file at test/ldpf/poc_9a30318b.js in the audited repository and run:
Observed result
The test was executed twice from a clean state against the audited commit; both runs pass. It uses only public entry points, and the drained state is produced by the protocol's own payout() (a legitimate incident flow), not by impersonating roles or overriding state.
Was this helpful?