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

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.

  1. A user requests a withdrawal. _requestWithdraw credits bucket currentPeriod() + 1 (FirelightVault.sol:1008, :1017) and adds the assets to pendingWithdrawAssets (:1019).

  2. BLOCKLIST_ROLE places the account on hold — addToBlocklist, FirelightVault.sol:550. A compliance hold is the stated purpose of the blocklist.

  3. RESCUER_ROLE calls rescueWithdrawFromBlocklisted — FirelightVault.sol:845, documented at :840 as "Rescues pending withdrawals from a blocklisted address". The queued shares move to a safe address; the blocklisted account receives nothing; :870 marks it as having claimed.

  4. 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.

  5. The account still holds its vault shares — the rescue moves queued withdrawal shares, not the ERC20 balance; moving the balance is a different function, rescueSharesFromBlocklisted at :818 — and it makes an ordinary new withdrawal request. It lands in the same bucket, and withdrawalsOf(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 targets currentPeriod() + 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 carries duration = 86400 (read live at Flare block 68,000,000 from 0x4C18Ff3C89632c3Dd62E796c0aFA5c07c4c1B2b3, 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.

  6. When the bucket matures, claimWithdraw reverts AlreadyClaimedPeriod (: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 to PAYOUT_ROLE and 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] = true on :870 — zeroing withdrawSharesOf[periods[i]][from] on :869 already prevents the same balance from being rescued twice, and :866 already rejects a zero balance; or

  • keep a separate isWithdrawRescued[period][account] marker for the rescue and leave isWithdrawClaimed meaning strictly what its NatSpec says.

Either change keeps the rescue idempotent while leaving the account able to claim withdrawals it requests afterwards.

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?