89856 sc high rescue path permanently freezes a user s funds by marking a future period as already claimed
#89856 [SC-High] Rescue path permanently freezes a user's funds by marking a future period as already claimed
Submitted on Aug 21st 2026 at 14:33:18 UTC by @pindarev for Audit Comp | Firelight
Report ID: #89856
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:
Protocol insolvency
Permanent freezing of funds
Description
Brief / Intro
rescueWithdrawFromBlocklisted sets isWithdrawClaimed[period][from] = true for a period that has not started yet and is still accepting new withdrawal requests. The flag is permanent — no function in the contract ever clears it — and claimWithdraw checks it before it looks at the user's balance. If the account is later un-blocklisted (the normal end of a compliance hold) and submits a fresh withdrawal that lands in that same period, its shares are burned and the assets can never be claimed by anyone. The stranded assets also stay inside pendingWithdrawAssets forever, permanently understating totalAssets() for every other depositor.
No privileged party acts maliciously or incorrectly anywhere in this flow. The rescuer performs a correct rescue and the blocklister performs a correct un-blocklisting; the loss is caused entirely by the contract writing a flag whose meaning ("this account has been paid for this period") is false.
Vulnerability Details
Root cause
A permanent, per-(period, account) latch is written for a period whose share balance is still mutable, and the latch is checked before the balance it was meant to stand in for.
FirelightVault.rescueWithdrawFromBlocklisted (L845-L873):
FirelightVault.claimWithdraw (L788-L800):
FirelightVault._requestWithdraw (L994) credits into currentPeriod() + 1 and never reads isWithdrawClaimed:
The rescue targets currentPeriod() + 1 — the only period that can hold a pending withdrawal — which is precisely the period that is still accepting new credits for the remainder of the current period. The implicit assumption is "a rescued address will never again hold withdraw shares for this period." Nothing enforces it, and removeFromBlocklist makes violating it routine.
The write is also redundant against its own stated purpose: line 869 already zeroes withdrawSharesOf[periods[i]][from], which alone makes claimWithdraw compute assets == 0 and revert NoWithdrawalAmount, and makes a second rescue revert too. Its only observable effect is the poisoning.
There is no recovery path. Re-blocklisting and re-rescuing hits the identical isWithdrawClaimed guard at the top of the rescue and reverts. No function anywhere writes isWithdrawClaimed = false.
Impact Details
Permanent freezing of user funds. The victim's shares are burned by _update(owner, address(0), shares) at request time, and the corresponding assets can never be claimed by the victim, by the rescuer, or by anyone else. This is unrecoverable without a contract upgrade.
Permanent accounting corruption affecting all other depositors. The stranded assets remain in pendingWithdrawAssets, and totalAssets() is defined as super.totalAssets() - pendingWithdrawAssets. Every future share-price computation, every payout exposure cap (totalAssetsAt), and the cover capacity that CoverOrderAllocator._computeAvailableCapacity reads via totalAssetsAt(currentPeriodStart()) are all permanently understated by the frozen amount. The damage is not contained to the victim.
Trigger is a routine compliance operation, not an attack. Blocklisting a user on a screening hit, rescuing their pending withdrawal, and then clearing the hit when it proves to be a false positive is exactly the workflow these functions exist to support. Any user who withdraws again in the same period after being cleared loses those funds.
References
FirelightVault.rescueWithdrawFromBlocklistedL845-L873 — sets the permanent flag at L870FirelightVault.claimWithdrawL788 — checks the flag at L794, before the balanceFirelightVault._requestWithdrawL994 — re-credits the same period without consulting the flag
Mitigation
Delete the flag write. Zeroing the share balance on the preceding line is already sufficient to prevent both a double-claim and a double-rescue:
If a latch is still wanted for auditability, only set it for periods that are already closed (periods[i] < currentPeriod()), which can no longer receive credits.
Proof of Concept
Proof of Concept
Add the following test as test/FirelightVault/rescueWithdrawPermanentFreeze.js and run it with npx hardhat test test/FirelightVault/rescueWithdrawPermanentFreeze.js
PoC:
Was this helpful?