90033 sc medium missing share burn in payout allows a 1 wei deposit to clone the entire lp supply and steal half of all subsequent vault yield
Submitted on Aug 22nd 2026 at 15:31:49 UTC by @BenTimor for Audit Comp | Firelight
Report ID: #90033
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
Brief/Intro
FirelightVault.payout() transfers vault assets to the claim receiver but never burns the corresponding LP shares. After a full payout the vault holds zero assets while the full share supply remains outstanding. Because the ERC-4626 share conversion divides by totalAssets + 1, the first deposit after the incident closes — even a single wei — mints totalSupply + 1 shares, instantly cloning the entire leftover LP book. VaultRewardDistributor forwards premiums as plain token donations (no shares minted), so every subsequent donation is split roughly 50/50 between the attacker and the legitimate stakers, and the attacker redeems and claims their half. This is permissionless theft of unclaimed yield: any EOA can backrun setActiveIncident(false) with a 1-wei deposit and harvest half of every premium-forwarding cycle until a large recapitalizing deposit occurs.
Vulnerability Details
1. payout() drains assets but leaves the share supply untouched
payout() (FirelightVault.sol:892) is the vault's claim exit. It computes paidAmount, decrements the withdrawal buckets, pushes a total-assets trace, and transfers the asset — but it never burns or otherwise reduces totalSupply:
// FirelightVault.sol:892-963 (abridged)
function payout(address to, uint256 amount, uint48 captureTimestamp)
external onlyRole(PAYOUT_ROLE) nonReentrant returns (uint256 paidAmount)
{
...
paidAmount = Math.min(amount, Math.min(assetsAtCapturePeriod, activePayableAmount));
if (paidAmount > 0) {
...
_traceTotalAssets.push(Time.timestamp(), currentActiveAssets - paidFromActive);
...
IERC20(asset()).safeTransfer(to, paidAmount); // line 962
}
emit PayoutExecuted(to, amount, paidAmount, captureTimestamp);
}When the approved claim equals the active assets, totalAssets() becomes 0 while totalSupply() remains at the pre-incident level. This state is reachable through entirely legitimate operations — PAYOUT_ROLE only pays an approved claim; no operator malfeasance is required.
2. The share conversion explodes at zero NAV
FirelightVault does not override _decimalsOffset(), so the ERC-4626 default of 0 applies. The conversion is:
With totAssets == 0 and leftover supply S:
A 1-wei deposit therefore mints S + 1 shares. The attacker's share count equals the entire pre-existing book, so they own ~50% of the vault from that moment on.
3. None of the existing deposit guards fire
assets == 0: 1 wei is not zero.Incident gate: blocks deposits only while the incident is active. The moment
INCIDENT_ROLEcallssetActiveIncident(period, false)(line 510), deposits are legal again — in the same period.shares == 0(the guard added for the Macro L-1 zero-share pull): the minted amount isS + 1, which is huge, not zero.
4. The yield source is a donation, not a mint
VaultRewardDistributor does not mint shares; it transfers vault assets directly into the vault (VaultRewardDistributor.sol:13-14, 123-127). These donations increase totalAssets() without increasing totalSupply(), which is exactly what re-prices the attacker's cloned shares into real value.
5. Why the existing tests do not cover this
The team's test/donations.js covers only the empty-vault case (totalSupply == 0), where a deposit floors to zero shares and reverts with InvalidAmount. The leftover-supply-at-zero-NAV state is only created by payout() and is not exercised by that test. The Macro L-1 finding (zero-share pull) is a different mechanism: there the attacker loses their deposit to rounding; here the attacker gains the entire supply clone.
6. MEV / race
The race is the mempool after ActiveIncidentUpdated(period, false). The first 1-wei deposit wins the full clone (S + 1 shares). A second 1-wei deposit, while NAV is still 1, also mints S + 1 shares (since totalAssets == 1), so even the runner-up ends up with ~1/3 of subsequent donations. The hot path is cheap: deposit ≈ 187k gas, redeem ≈ 109k gas, claimWithdraw ≈ 38k gas.
Impact Details
Impact selected: Theft of unclaimed yield (High).
The stolen value is the yield stream that VaultRewardDistributor forwards to the vault after the incident — premiums and incentives that were supposed to recapitalize the remaining stakers' shares. The principal is not stolen: it already left the vault through the legitimate, approved payout(). This matches the program's High row "Theft of unclaimed yield" and deliberately does not claim the Critical row "Direct theft of any user funds ... other than unclaimed yield".
Executed numbers (FXRP, 6 decimals; reproduced in the attached PoC):
Base
1,000,000 FXRP
100,000 FXRP
1 wei
50,000 FXRP
5×10¹⁰
Partial slash (NAV = 1 unit)
1,000,000 FXRP
100,000 FXRP
1 wei
~33,333 FXRP (33.3% of NAV)
same class
V1-scale book
60,000,000 FXRP
250,000 FXRP
1 wei
125,000 FXRP
1.25×10¹¹
4 donation cycles
1,000,000 FXRP
10,000 × 4
1 wei
5k → 10k → 15k → 20,000 FXRP
ongoing
The theft continues every premium-forwarding cycle until someone makes a large recapitalizing deposit. There is no permission or timing requirement beyond the incident having closed; the attacker is any EOA.
References
FirelightVault.sol—payout()(L892),deposit()(L646),_convertToSharesTotals()(L1058),setActiveIncident()(L510): https://github.com/immunefi-team/audit-comp-firelight/blob/v1_audit_ready/contracts/core/FirelightVault.solVaultRewardDistributor.sol— donation flow (L13-14, L123-127): https://github.com/immunefi-team/audit-comp-firelight/blob/v1_audit_ready/contracts/core/VaultRewardDistributor.solTeam's donation test (empty-supply case only): https://github.com/immunefi-team/audit-comp-firelight/blob/v1_audit_ready/test/donations.js
Program page (impact table, known issues): https://immunefi.com/audit-competition/audit-comp-firelight-1/information/
Proof of Concept
Proof of Concept
Step-by-step (all steps are executed by the self-contained Foundry test inlined below; 7/7 passing against commit 42f9ea5e43d88b35197b901acc2a8c24b314fa62):
Deploy
FirelightVaultbehind anERC1967Proxywith a 6-decimal mock FXRP asset; grantPAYOUT_ROLEandINCIDENT_ROLEto test actors; add the claim receiver to the payout allowlist.Alice deposits 1,000,000 FXRP →
totalAssets = 1,000,000e6,totalSupply = 1,000,000e6.Incident opens:
setActiveIncident(0, true).Approved claim pays out:
payout(receiver, 1,000,000e6, epoch)returns the full amount. State:totalAssets = 0,totalSupply = 1,000,000e6(shares not burned).Incident closes:
setActiveIncident(0, false).Attacker deposits 1 wei:
deposit(1, attacker)succeeds and mints1,000,000,000,001shares (S + 1). Attacker now owns ~50% of the supply. (The PoC also proves the gate: the same deposit reverts withCurrentPeriodHasActiveIncidentwhile the incident is still active.)Distributor donates 100,000 FXRP via a plain
transferto the vault →totalAssets = 100,000e6 + 1.Attacker NAV =
(S+1) × totalAssets / (2S+1)= 50,000,000,001 units = 50,000 FXRP — half the donation.Attacker exits:
redeem(all shares)books 50,000,000,001 units; after the withdrawal delay,claimWithdraw(1)transfers the full amount. Net profit: 50,000,000,000 units from a 1-wei spend.Repeatability: the PoC's multi-cycle test shows the same ~50% take on each of 4 consecutive donations (5,000 → 10,000 → 15,000 → 20,000 FXRP cumulative), and the V1-scale test shows 125,000 FXRP taken from a 250,000 FXRP donation on a 60,000,000 FXRP book.
Self-contained runnable PoC (place at forge-test/HIGH01_ZeroNavMint.t.sol in the comp repo; the vault, mock asset, and OpenZeppelin proxy are all in the repo):
Run command:
Result: 7/7 passing (base extraction, partial slash, V1-scale, multi-cycle, MEV race, empty-supply negative control, unpatched guard predicate).
Recommendation
Revert deposit() and mint() when the vault is in the zero-NAV leftover-supply state:
Alternatively (or additionally), burn the shares corresponding to the paid-out assets inside payout(), so the share supply always tracks the assets that remain.
Was this helpful?