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

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

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?