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

88330 sc medium post slash zero assets state inverts the erc4626 virtual offset a 1 wei deposit mints half of all shares and captures 50 of all future vault yield

Submitted on Aug 12th 2026 at 19:10:53 UTC by @pawelschubert for Audit Comp | Firelight

  • Report ID: #88330

  • 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:

    • Theft of unclaimed yield

Description

Bug Description

FirelightVault uses the OpenZeppelin ERC4626 virtual-offset defense in its conversion math:

// FirelightVault.sol:1058-1065
function _convertToSharesTotals(
    uint256 assets,
    uint256 totSupply,
    uint256 totAssets,
    Math.Rounding rounding
) private view returns (uint256) {
    return assets.mulDiv(totSupply + 10 ** _decimalsOffset(), totAssets + 1, rounding);
}

The virtual offset assumes the invariant totalSupply() == 0 whenever totalAssets() == 0. OpenZeppelin's defense holds under that assumption; when it is broken (supply > 0, assets == 0), the defense inverts and becomes the vulnerability:

A deposit of D wei mints D * (totalSupply + 1) shares, i.e. a fraction D / (D + 1) of post-deposit supply. 1 wei captures ~50% of all shares; ~200 wei captures ~99%.

FirelightVault.payout() breaks the invariant by design: it slashes vault assets for incident claims without burning any shares:

A full-slash payout therefore leaves the vault in the state totalAssets() == 0 && totalSupply() > 0 (all pre-slash shares remain outstanding, now worthless). This state is reachable through the protocol's intended operation:

  • the per-payout exposure cap is totalAssetsAt(capturePeriodStart) (FirelightVault.sol:911) - the full period-start balance;

  • covered losses can legitimately reach or exceed the vault balance because cover capacity is leveraged up to 5x minCAR by design.

After the incident resolves, deposits reopen (the incident-gating machinery is explicitly designed to resume deposits), and the very next deposit - by anyone, no privileges needed - mints a supply-majority share position for dust.

The Macro L-1 fix does not cover this. L-1 was the opposite direction (a dust deposit minting zero shares while assets are pulled); the fix added a shares == 0 revert in deposit() (FirelightVault.sol:667-669). That check does nothing for the hyper-inflation direction, which only became reachable with Phase 2's payout() slash.

Step-by-step attack path (PoC-verified numbers, 6-decimal asset)

  1. Victim deposits 20,000 tokens → totalSupply = 20,000,000,000 shares, 1:1.

  2. An incident captured next period is approved for a loss ≥ TVL (legitimate per the exposure cap and 5x leverage) → payout() drains everything: totalAssets() = 0; the victim's 20e9 shares remain.

  3. Incident resolves; deposits reopen. Attacker calls deposit(1, attacker) and receives 20,000,000,001 shares = victim's shares + 1 (>50% of supply).

  4. The VaultRewardDistributor forwards 10,000 tokens of cover premiums → totalAssets() = 10,000.000001.

  5. Attacker requests withdrawal and claims after the standard delay: receives 4,999.99999 tokens for a 1-wei investment, and permanently owns ~50% of every future reward cycle. Depositing ~200 wei instead captures ~99.5%.

The attacker's exploitation step (deposit(1)) is fully unprivileged and permissionless; the precondition (a near-total slash) is a state the protocol is explicitly designed to reach and continue operating after.

Impact

Theft of unclaimed yield (Medium). An attacker captures ~50% (or ~99% for trivially more dust) of all future yield forwarded to the vault - cover premiums and incentives distributed to stakers - for ~1 wei, immediately after any full-slash incident. The loss falls on legitimate stakers whose post-slash recapitalization and all subsequent rewards are siphoned. Additionally, the share price is permanently distorted (supply-majority holder at zero cost basis), so every later depositor is diluted - "contract fails to deliver promised returns".

Why this is not a known issue:

  • Not Macro L-1: opposite rounding direction, different precondition (totalAssets == 0 && totalSupply > 0, only reachable via the Phase-2 payout slash), different impact (theft vs. lost dust). The L-1 fix provably does not touch this path.

  • Not the accepted "new deposits capture pending reward distribution" risk: that accepted dilution assumes deposits buy shares at a fair price (pay X, capture ∝ X). Here the price function itself is broken - the capture fraction is D/(D+1) of all supply, unbounded relative to the deposit.

  • The exploitation step requires no privileged role.

Recommendation

When totalAssets() == 0 && totalSupply() > 0, either:

  • revert deposit/mint (require a governance re-seed / re-initialization of the vault after a full slash), or

  • mint new deposits 1:1 (the surviving pre-slash shares are already worthless; new money must not receive the supply for free), or

  • burn/anticipate the surviving shares as part of payout() when the slash reaches ~100%.

https://gist.github.com/schvbertt/c215f1d51fadaa760458b54f8a551584

Proof of Concept

Runnable Hardhat test against the real FirelightVault deployed via the repo's own fixture.

Setup:

Expected output (1 passing):

Was this helpful?