90104 sc high rescuing a blocklisted account s withdrawal permanently bricks that account s next withdrawal
Submitted on Aug 23rd 2026 at 02:07:55 UTC by @koshak01 for Audit Comp | Firelight
Report ID: #90104
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:
Permanent freezing of funds
Description
Summary
rescueWithdrawFromBlocklisted sets isWithdrawClaimed[period][from] = true for an account that receives no assets at all. That flag is declared, and read everywhere else, as "this account has already been paid for this period". It is never cleared: no code path in scope writes false to it and no clearing function exists.
Consequently, once an account has had a pending withdrawal rescued, its next legitimate withdrawal request into the same period bucket becomes permanently unclaimable. The assets stay inside the vault, permanently counted in pendingWithdrawAssets, and are therefore permanently subtracted from totalAssets() — lost to the account and to every other depositor alike.
Every actor in the path acts strictly inside its documented powers, and the rescue itself is correct behaviour — moving a blocklisted account's queued withdrawal to a safe address is exactly what the function exists to do. The defect is not that the rescue happens. It is that the rescue leaves behind an irreversible mark which the vault reads as "this account was paid", breaking the invariant that an account which has not claimed can still claim. No role can restore that state and no additional role is needed to reach it.
Root cause: one storage slot carrying two incompatible meanings
The slot is declared as a record of payment received:
and claimWithdraw documents its revert on exactly that basis:
Across the whole scope the flag is written in exactly two places:
and it is read as a hard stop in two places:
grep -rn "isWithdrawClaimed" contracts/core/ returns exactly seven sites: the declaration (FirelightVaultStorage.sol:71), the interface getter (interfaces/IFirelightVault.sol:171), the two reads that revert (:794, :864), the read guarding the receiver (:865), and the two writes (:806, :870). There is no = false, no delete, and no function that clears it anywhere. Zeroing withdrawSharesOf[periods[i]][from] on :869 only prevents the same balance from being rescued twice; it does nothing for shares credited to that account afterwards.
The immediately adjacent line shows the authors were aware of this precise failure mode and guarded only the other side of the transfer:
The mirror case — the sender left with a permanent, false "already paid" mark — is not guarded anywhere.
Vulnerability path
Every step below is a documented operation performed correctly by the actor entitled to it.
A user requests a withdrawal.
_requestWithdrawcredits bucketcurrentPeriod() + 1(FirelightVault.sol:1008,:1017) and adds the assets topendingWithdrawAssets(:1019).BLOCKLIST_ROLEplaces the account on hold —addToBlocklist,FirelightVault.sol:550. A compliance hold is the stated purpose of the blocklist.RESCUER_ROLEcallsrescueWithdrawFromBlocklisted—FirelightVault.sol:845, documented at:840as "Rescues pending withdrawals from a blocklisted address". The queued shares move to a safe address; the blocklisted account receives nothing;:870marks it as having claimed.The hold is lifted —
removeFromBlocklist,FirelightVault.sol:560. This is a separate, deliberately provided function of the same role, used whenever a hold was precautionary or mistaken.The account still holds its vault shares — the rescue moves queued withdrawal shares, not the ERC20 balance; moving the balance is a different function,
rescueSharesFromBlocklistedat:818— and it makes an ordinary new withdrawal request. It lands in the same bucket, andwithdrawalsOf(bucket, account)now reports a positive amount owed. This is the single condition the path needs: the new request must fall in the same period as the rescue, because a request always targetscurrentPeriod() + 1. The window is therefore the remainder of the current period, which on the deployed vault this upgrade targets is one day: its single period configuration carriesduration = 86400(read live at Flare block 68,000,000 from0x4C18Ff3C89632c3Dd62E796c0aFA5c07c4c1B2b3, currently in period 263). A user whose hold has just been lifted and who goes to withdraw walks into that window by the shortest path, especially since the vault itself reports the amount as owed.When the bucket matures,
claimWithdrawrevertsAlreadyClaimedPeriod(:794), and a second rescue reverts on the same flag (:864). No further path exists.
The loss is committed at request time, and the vault says nothing is wrong
The flag is readable — isWithdrawClaimed has a public getter (IFirelightVault.sol:171) — so it could be argued the user should have checked it before step 5. Three things make that argument fail.
First, nothing else in the contract reflects the flag. maxWithdraw (:401-407) and maxRedeem (:415-421) gate only on blocklist and pause, so the ERC4626 surface reports the full balance as withdrawable. withdrawalsOf (:466-473) reads withdrawSharesOf alone and reports the amount as owed. The one accessor that would reveal the problem is the one nothing points at, while three standard accessors say the funds are available.
Second, the request itself succeeds. _requestWithdraw performs no flag check and burns the shares immediately — _update(owner, address(0), shares) (:1025).
Third, there is no way back: the contract has no function to cancel a withdrawal request (grep -n "cancel" contracts/core/FirelightVault.sol returns nothing). The moment withdraw returns successfully the shares are gone and the assets are committed to a bucket that can never be claimed. The failure only becomes visible one period later, at claimWithdraw.
So the user cannot mitigate by "requesting in a later period" — by the time the defect is observable, the shares that would have funded that later request no longer exist.
Impact
The assets of step 5 can never leave the vault:
claimWithdraw— reverts on:794.rescueWithdrawFromBlocklisted— reverts on:864, including after re-blocklisting.The only other outgoing asset transfer in the contract is
payout(:962), restricted toPAYOUT_ROLEand to an incident payout window; it pays a configured receiver and never releases a specific account's queued shares.
Because totalAssets() is super.totalAssets() - pendingWithdrawAssets (:428), the stuck amount is permanently excluded from the vault's active assets. It is lost to its owner, and it permanently depresses totalAssets() for every other depositor, for the underwriting capacity computed from it, and for any future incident payout.
This is a permanent freezing of user funds produced by the contract itself: no role exceeds its powers, no role makes an incorrect decision, and no key is compromised. What is violated is a system invariant the contract states in three places — isWithdrawClaimed records that the account has claimed its withdrawal (FirelightVaultStorage.sol:71), and a claim is refused only when the withdrawal "has already been claimed" (FirelightVault.sol:784, repeated in IFirelightVault.sol:230). Here the claim is refused on precisely that ground when nothing was ever claimed. No additional role is required for the outcome: the rescuer cannot undo it, and the victim is an ordinary depositor holding no role at all.
The deployment this lands on
FirelightVault.sol is the V2 implementation upgrade for the legacy pre-deposit vault deployed on Flare at 0x4C18Ff3C89632c3Dd62E796c0aFA5c07c4c1B2b3. Read live at block 68,000,000, that vault holds 56,613,733.94 FXRP, with 393,423.29 FXRP already sitting in the withdrawal queue, against a 60,168,000 FXRP deposit cap.
Its storage layout is compatible with V2 slot for slot — the two new mappings consume two words of the reserved gap and __gap drops from 50 to 48 — so every isWithdrawClaimed entry and every queued withdrawal carries into V2 unchanged. The defect therefore applies to a live vault and its existing withdrawal queue from the moment the upgrade executes, not to a fresh deployment.
Recommendation
Stop overloading one slot with two meanings. The rescue is not a payment, so it must not write the payment flag for the sender. Either:
drop
isWithdrawClaimed[periods[i]][from] = trueon:870— zeroingwithdrawSharesOf[periods[i]][from]on:869already prevents the same balance from being rescued twice, and:866already rejects a zero balance; orkeep a separate
isWithdrawRescued[period][account]marker for the rescue and leaveisWithdrawClaimedmeaning strictly what its NatSpec says.
Either change keeps the rescue idempotent while leaving the account able to claim withdrawals it requests afterwards.
Link to Proof of Concept
https://gist.github.com/koshak01/0a1329fa6fd2fe4b77cd09d82745569d
Proof of Concept
Runnable against the audit snapshot with the project's own fixtures, unmodified. Save the file below as test/FL-02-poc.js and run:
Output
Was this helpful?