89051 sc high rescue marker collision in firelightvault permanently strands a cleared account s later withdrawal
Submitted on Aug 16th 2026 at 19:16:53 UTC by @Dead_Aizen for Audit Comp | Firelight
Report ID: #89051
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:
Temporary freezing of funds for at least 24 hours
Summary
rescueWithdrawFromBlocklisted sets isWithdrawClaimed[period][from] = true as an "already rescued" marker. That is the same flag claimWithdraw reads to prevent double claims. The two meanings are conflated on one storage slot.
The flag is a one-way latch. It is written in exactly two places in the codebase and both write true; nothing writes false. The ledger it guards keeps accepting writes: _requestWithdraw credits withdrawSharesOf[period][receiver] unconditionally, without consulting the flag.
An account rescued for period p and subsequently un-blocklisted can therefore request another withdrawal that also lands in period p. The shares are burned, the assets enter pendingWithdrawAssets, withdrawalsOf(p, account) reports the full amount owed — and claimWithdraw(p) reverts AlreadyClaimedPeriod.
The assets are not transferred to another party. They are unreachable by every party: not the account, not the other participants in the same withdrawal bucket, and not the remaining share holders.
The rescue does not need this flag. Line 869 already zeroes withdrawSharesOf[periods[i]][from], which alone prevents the rescued balance from being claimed or rescued twice. Line 870 adds no protection and is the sole cause.
Severity / impact: High — Temporary freezing of funds for at least 24 hours
Commit: 42f9ea5e43d88b35197b901acc2a8c24b314fa62 (v1_audit_ready)
File: contracts/core/FirelightVault.sol
The freeze is demonstrated below to be permanent, with every recovery path enumerated and shown to revert. This impact is selected conservatively as the lowest category the evidence supports.
Root cause
FirelightVault.sol:868-870, inside rescueWithdrawFromBlocklisted:
FirelightVault.sol:794, inside claimWithdraw, reads the same slot with the other meaning:
FirelightVault.sol:1008 and 1017, inside _requestWithdraw, credit the guarded ledger without consulting it:
The complete reference set for the flag across the in-scope tree:
There is no write of false anywhere in the codebase. Two writers, both setting; no clearing path.
Invariants broken
Shares burned for a withdrawal must remain claimable by someone. _requestWithdraw burns via _update(owner, address(0), shares) and moves the assets into pendingWithdrawAssets, from which claimWithdraw is the only exit. After the collision those assets are claimable by nobody.
Un-blocklisting restores normal use. The repository's own test suite states this expectation:
Restoration is incomplete. A cleared account remains permanently unable to claim for the colliding period, and permanently unable to receive a rescue for it.
The view surface and the state machine disagree. withdrawalsOf(p, account) returns the full amount owed for a claim the contract will always reject, so off-chain accounting and user-facing balances report a debt that will never be honoured.
Sequence
Every step is a legitimate action by the role entitled to perform it. No role acts maliciously or incorrectly, and none exceeds its authority.
Steps 1 to 5 occur inside period p-1, a window of one full period. SMALLEST_PERIOD_DURATION is one day; the repository fixtures use seven.
No re-deposit is required. Because the first request was partial, the user simply still holds shares.
Per the Macro audit's on-chain verification, BLOCKLIST_ROLE is held by a 1-of-1 Safe (0x189993f3B53284c7e5C02b058EFC6618F78D0cEA) and RESCUER_ROLE by a separate 1-of-1 Safe (0xC115757266b902d4b1284eA24DBbE557C15E963E). The sequence requires no multisig quorum.
Proof of concept
A single self-contained file, test/integration/firelight-rescue-flag-poc.js. It depends only on the repository's own helper (test/setup/fixtures.js) and needs no other new file. No mock contract was authored for this report.
Contract provenance is asserted by the first test case rather than claimed in prose. It reads the proxy's ERC-1967 implementation address, fetches the on-chain runtime bytecode, and asserts byte-for-byte equality with artifacts/contracts/core/FirelightVault.sol/FirelightVault.json. It also asserts the vault asset is the project's own FAsset (fXRP), the asset type the live V1 vault holds, and that all twelve functions the PoC calls are declared on the in-scope artifact. A mocked, modified or substituted contract fails that case and the run stops.
Executing in the defective path: FirelightVault (in scope, unmodified) and FAsset / FAssetProxy from contracts/fasset/.
One mock is instantiated by the fixture: contracts/test/MockContract.sol, which the repository's own deployFAsset helper installs as the FAsset assetManager so the test can mint fXRP. The vault never calls it, it holds no protocol state, and it appears in no code path in this finding.
The fourth and fifth cases are written to disprove the finding rather than confirm it.
Asserted, in order: the proxy implementation's runtime bytecode is byte-identical to the compiled in-scope FirelightVault artifact, the vault asset is fXRP, and every function the PoC calls is declared on that artifact; isWithdrawClaimed(p, victim) is true and withdrawSharesOf(p, victim) is 0 immediately after the rescue; the second request burns the victim's entire remaining share balance to 0 and raises pendingWithdrawAssets by exactly 500000000; withdrawalsOf(p, victim) equals that amount three periods later; claimWithdraw(p) reverts AlreadyClaimedPeriod and the victim's token balance is unchanged; both other participants in bucket p receive exactly their withdrawalsOf amounts, so none of the stranded funds reach them; pendingWithdrawAssets still holds the stranded amount after every other claim settles and totalAssets() is unchanged across them; an inbound rescue from a different blocklisted account into the victim for period p, the victim's own claim, a re-rescue to a fresh address, and a self-rescue all revert; all 84 external functions are enumerated from the ABI and none can reset the flag; and, as a control, the identical sequence with the second request landing in period p+1 claims successfully.
No mitigation exists
isWithdrawClaimed has no setter. The only two writes both set true (FirelightVault.sol:806, FirelightVault.sol:870).
The rescue path cannot repair its own damage. rescueWithdrawFromBlocklisted reverts on isWithdrawClaimed[periods[i]][from] at line 864, so the account cannot be rescued again, and on isWithdrawClaimed[periods[i]][to] at line 865, so it cannot receive an inbound rescue for that period either. The account is unusable in both directions for the affected period. The PoC exercises each path and asserts the revert.
Re-blocklisting and un-blocklisting have no effect on the flag.
The only remaining route is a proxy upgrade adding a reset function. Per the program's terms, "Emergency actions may be used to fix or mitigate an issue, but they are not intended as an automatic reason to downgrade an otherwise valid report."
Impact
The affected account permanently loses the assets backing its second request for that period. The shares are already burned, so the position cannot be reconstructed.
The loss is bounded to that account and that period. Other periods behave normally, and other participants in the same bucket are unaffected, because the stranded shares remain counted in both withdrawShares[p] and withdrawAssets[p], leaving their pro-rata unchanged.
The stranded assets permanently distort protocol accounting. They remain in pendingWithdrawAssets for the life of the vault, and since totalAssets() is super.totalAssets() - pendingWithdrawAssets, the vault's reported assets are permanently understated by that amount. The share price, the deposit-limit check, and every period-start snapshot consumed by CoverOrderAllocator all read from that understated figure.
The user receives no signal. redeem succeeds normally and withdrawalsOf continues to report the amount as owed. The failure surfaces only on the claim attempt, at least two periods later, by which point the shares are long burned.
The live V1 vault holds approximately 60.16 million FXRP of staker capital, and the audited V2 code is the intended upgrade for it.
Remediation
Delete line 870. withdrawSharesOf[periods[i]][from] is already zeroed on line 869, which is sufficient to prevent the rescued balance from being claimed or rescued twice. The claimed flag adds no protection the zeroing does not already provide.
If an explicit record of the rescue is wanted, track it on its own slot:
and set that in rescueWithdrawFromBlocklisted instead of isWithdrawClaimed.
Either fix also removes the inbound-rescue restriction at line 865, which currently blocks an unrelated legitimate rescue into a previously rescued account.
Proof of Concept
Was this helpful?