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

89904 sc medium rescuewithdrawfromblocklisted marks a still future withdrawal period as claimed permanently locking any withdrawal the rescued account later makes into that period

Submitted on Aug 21st 2026 at 18:23:45 UTC by @SavantChat for Audit Comp | Firelight

  • Report ID: #89904

  • 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

rescueWithdrawFromBlocklisted moves a blocklisted account's pending withdrawal shares to a rescue address and then stamps isWithdrawClaimed[period][from] = true.

That flag is a terminal marker — it means "this account has taken its payout for this period". But the rescued period is still in the future: it keeps accepting new withdrawal requests, including from the same account once the blocklist is lifted. Nothing ever clears the flag; isWithdrawClaimed is only ever written to true, at two sites, and there is no reset path of any kind.

So after an ordinary, correct sequence — rescue, then un-blocklist — the account's next withdrawal lands in a period where it already counts as paid. claimWithdraw reverts AlreadyClaimedPeriod before it ever computes the entitlement, and a second rescue reverts on the same check. The newly reserved assets sit in pendingWithdrawAssets permanently, excluded from totalAssets() and reachable by nobody.

No role misbehaves here. The rescuer performs exactly the operation the function exists for, the blocklister lifts a block exactly as intended, and the user makes an ordinary withdrawal request. The contract corrupts its own state along the way, and the loss falls on the user.

Vulnerability Details

The premature terminal marker

contracts/core/FirelightVault.sol:845-876:

Zeroing withdrawSharesOf[period][from] is sufficient to express "nothing left here for this account". The extra flag adds nothing at rescue time and becomes wrong the moment the same account acquires shares in that period again.

Nothing clears it

isWithdrawClaimed has exactly two writers, both setting true:

  • claimWithdraw (:806), after a successful payout — legitimately terminal, because the period is already past (:791 requires period < currentPeriod()).

  • rescueWithdrawFromBlocklisted (:870) — on a period that has not started.

There is no delete, no setter, no admin override. Once set, it is set forever.

Both exits close

The account now satisfies withdrawSharesOf[period][account] > 0 and isWithdrawClaimed[period][account] == true.

  • claimWithdraw (:794) checks the flag before computing anything:

  • A repeat rescue (:864) checks the same flag on from and reverts identically.

rescueSharesFromBlocklisted (:818) does not help either — it transfers vault shares, not withdrawal-bucket entitlements.

The window

The collision requires the new request to land in the same period that was rescued. Withdrawals are booked to currentPeriod() + 1 (:1008), so the sequence — rescue, un-blocklist, new withdrawal — must complete while the period counter has not advanced, i.e. within one period. With the deployed cadence (the project's own configuration uses 2 days and 1 week) that is a window of days, not seconds.

The account also cannot withdraw while blocklisted: withdraw carries notBlocklisted(_msgSender()) and notBlocklisted(owner) (:755-768). The un-blocklist step is therefore load-bearing — but removeFromBlocklist (:560) is a plain BLOCKLIST_ROLE operation and lifting a block after a compliance matter is resolved is the expected lifecycle, not an unusual event.

Impact

Permanent, unrecoverable locking of a user's assets, produced by correct operation of the rescue path.

The PoC walks the full sequence with a 6-decimal vault:

  1. Alice deposits 10,000 and requests a 4,000 withdrawal into targetPeriod.

  2. Alice is blocklisted.

  3. rescueWithdrawFromBlocklisted(alice, bob, [targetPeriod]) moves her 4,000 of shares to Bob and sets isWithdrawClaimed[targetPeriod][alice] = true.

  4. Alice is un-blocklisted.

  5. Alice requests a further 6,000 withdrawal — still targetPeriod, so withdrawSharesOf[targetPeriod][alice] == 6_000e6 under the poisoned flag.

  6. The period passes.

  7. claimWithdraw(targetPeriod) reverts AlreadyClaimedPeriod.

  8. Re-blocklisting and rescuing again reverts with the same error.

  9. Bob still claims his rescued 4,000 normally — isolating the failure to Alice's new entitlement — while pendingWithdrawAssets retains her 6,000 forever.

Consequences:

  • The user's assets are gone from their perspective: no claim, no rescue, no admin remedy.

  • totalAssets() is super.totalAssets() - pendingWithdrawAssets (:427-429), so the stranded amount is permanently subtracted from the vault's accounting while physically remaining in the contract — it stops backing any share yet cannot be withdrawn.

  • The damage scales with the second request, which is unbounded: the locked amount is whatever the user withdraws next, not the rescued amount.

  • The trigger is a benign operational sequence. No role has to act maliciously or outside its remit, which is what makes this likely to happen by accident rather than by attack.

Recommendation

Drop the flag write. Zeroing the share balance already encodes everything the rescue needs:

The pre-checks at :864-866 remain meaningful: they still prevent rescuing a period the account has genuinely already claimed, and _withdrawOf == 0 still rejects an empty rescue.

If the flag is wanted as an audit trail, keep it in a separate mapping that claimWithdraw does not consult, or set it only when the rescued period is already in the past (periods[i] < currentPeriod()), where "claimed" is actually terminal.

As defence in depth, consider making claimWithdraw treat the flag as a guard against double payment rather than as a permanent seal — for example, keying it on the entitlement actually settled — so that a stale marker can never strand assets that were reserved afterwards.

References

  • contracts/core/FirelightVault.sol:845-876 — rescueWithdrawFromBlocklisted

  • contracts/core/FirelightVault.sol:870 — the premature isWithdrawClaimed[period][from] = true

  • contracts/core/FirelightVault.sol:864-865 — the same flag blocks a second rescue

  • contracts/core/FirelightVault.sol:794 — claimWithdraw rejects on the flag before computing the entitlement

  • contracts/core/FirelightVault.sol:806 — the only legitimate setter, on an already-past period

  • contracts/core/FirelightVault.sol:791 — claimWithdraw requires period < currentPeriod()

  • contracts/core/FirelightVault.sol:1008 — requests are booked to currentPeriod() + 1

  • contracts/core/FirelightVault.sol:755-768 — withdraw is notBlocklisted, so the un-blocklist step is required

  • contracts/core/FirelightVault.sol:560 — removeFromBlocklist, an ordinary BLOCKLIST_ROLE action

  • contracts/core/FirelightVault.sol:427-429 — totalAssets() subtracts pendingWithdrawAssets

  • contracts/core/FirelightVault.sol:818 — rescueSharesFromBlocklisted moves vault shares, not bucket entitlements

Relation to previously reported issues

None of the three known issues on the program page (first-loss-buffer valuation, the unescrowed buffer wallet, uncapped aggregate claims) concerns the blocklist or rescue paths.

The 0xMacro audit of 26 June – 13 July 2026 (commit d0d3733) contains no finding on rescueWithdrawFromBlocklisted, on isWithdrawClaimed, or on the blocklist lifecycle; its findings are M-1, M-2, L-1..L-4, G-1 and I-1..I-11.

Proof of Concept

Proof of Concept

Runnable Foundry PoC. Verified against a clean clone of the audited repository at tag v1_audit_ready (commit 42f9ea5) with forge 1.5.1 / solc 0.8.28. It drives the real FirelightVault behind an ERC1967 proxy — nothing is simulated by a mock. Everything needed to reproduce is inlined below.

1. Where each file goes

The repository ships with a hardhat setup only, so the PoC adds a foundry profile on top of it. Place the four files below at these exact paths, relative to the repository root:

1.1 foundry.toml (repository root)

via_ir = true is mandatory — the plain pipeline hits Stack too deep in CoverOrderAllocator.

1.2 remappings.txt (repository root)

2. The PoC — test/foundry/F10_RescueClaimLock.t.sol

3. Shared harness — test/foundry/utils/Deployers.sol

Deploys FirelightVault behind an ERC1967 proxy and grants the roles the test needs — here BLOCKLIST_ROLE and RESCUER_ROLE — with a 1-day period configuration. It uses only mocks already present in the repository under contracts/test/.

4. How to run

5. Expected output

6. Key assertions

The rescue leaves the account with zero shares but a terminal flag on a period that has not started:

After an ordinary un-blocklist, a fresh request lands in the same period and inherits the flag:

Both exits are closed, permanently:

Bob's rescued entitlement still settles normally, which isolates the failure to Alice's new reservation rather than to the rescue as a whole:

Was this helpful?