89238 sc medium unblocking after rescue can permanently freeze same period withdrawals
Submitted on Aug 18th 2026 at 02:24:17 UTC by @chiranz for Audit Comp | Firelight
Report ID: #89238
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:
Permanent freezing of funds
Description
Brief/Intro
FirelightVault.rescueWithdrawFromBlocklisted sets isWithdrawClaimed[period][from] = true in addition to zeroing the rescued user's withdraw shares. No code path anywhere in the repository ever sets that flag back to false. If the address is subsequently un-blocklisted via removeFromBlocklist — the intended outcome of a false positive or a lifted sanction — and the user calls withdraw() again while still in the same period, the new request is credited to the same poisoned (period, user) slot. Their vault shares are burned, but the resulting assets can never be claimed by the user nor recovered by the rescuer. They are permanently frozen in the vault.
Vulnerability Details
Withdrawals are batched per period. A withdraw() during period N burns the caller's vault shares immediately and credits withdrawSharesOf[N+1][receiver]; after period N+1 elapses the user calls claimWithdraw(N+1) to receive assets. Two storage slots track this per (period, account): withdrawSharesOf (amount owed) and isWithdrawClaimed (a bool).
The rescue function writes both (FirelightVault.sol:868-870):
withdrawSharesOf[periods[i]][to] += _withdrawOf; // :868
withdrawSharesOf[periods[i]][from] = 0; // :869
isWithdrawClaimed[periods[i]][from] = true; // :870 <-- the defectLine :869 alone is already sufficient to prevent from double-claiming, because claimWithdraw reverts with NoWithdrawalAmount at :803 when the computed assets are zero. Line :870 is therefore redundant for its apparent purpose, but it permanently poisons the (period, from) slot.
The flag is never cleared. Repository-wide, isWithdrawClaimed has exactly two writers — FirelightVault.sol:806 (inside claimWithdraw) and FirelightVault.sol:870 (inside the rescue) — and both assign true. There is no clearer, no admin reset, and no hook. removeFromBlocklist (:560-563) only mutates isBlocklisted.
_requestWithdraw (:994-1028) never reads the flag. It unconditionally credits the slot and burns the shares:
Both recovery paths are then closed by the same guard:
claimWithdrawchecks the claimed bit at:794, before it reads the share balance at:797, so it reverts withAlreadyClaimedPeriodregardless of the non-zero balance.rescueWithdrawFromBlocklistedchecks the identical bit at:864, so re-blocklisting and re-rescuing also reverts withAlreadyClaimedPeriod.
The assets remain accounted in pendingWithdrawAssets, so they are unusable by the user and simultaneously excluded from totalAssets() for everyone else.
Timing
The dangerous window is the remainder of period N, after a rescue was performed for bucket N+1 during period N. A withdrawal in that window lands in the poisoned bucket and is destroyed. Waiting until period N+1 targets bucket N+2 and is unaffected. The user receives no on-chain signal: withdraw() succeeds, emits a normal WithdrawRequest, and burns the shares. The loss only surfaces a period later when claimWithdraw reverts, at which point it is irreversible.
Impact Details
Permanent freezing of funds. The affected user's vault shares are burned and the corresponding assets are locked in the vault with no function capable of releasing them — not by the user, not by the rescuer, not by any admin role. The amount is bounded by whatever the un-blocklisted user withdraws to themselves during the remainder of that one period.
Note that no privileged role behaves maliciously or exceeds its documented permissions at any point. Block to rescue to unblock is the protocol's intended compliance flow, executed correctly. The freeze is a state-machine defect that materialises afterwards, against a user in good standing, through an ordinary withdraw() call.
Recommended fix
Remove the redundant write:
Zeroing the shares on :869 already blocks the double-claim the flag was presumably intended to prevent. If the flag must be retained for accounting reasons, then claimWithdraw should evaluate the share balance before the claimed bit, and the rescue should clear the flag whenever it zeroes the shares.
References
All line numbers refer to branch v1_audit_ready of immunefi-team/audit-comp-firelight.
Proof of Concept
Runnable PoC
What the PoC demonstrates
Rescue latches isWithdrawClaimed[N+1][alice] = true
confirmed
removeFromBlocklist does not clear it
confirmed
Post-clearance withdraw(600) burns Alice's shares and credits the poisoned slot
confirmed
claimWithdraw(N+1) reverts AlreadyClaimedPeriod
confirmed
Re-blocklist + re-rescue reverts AlreadyClaimedPeriod
confirmed
600 assets remain in the vault, unclaimable by any party
confirmed
Was this helpful?