88653 sc high flag collision between rescuewithdrawfromblocklisted and claimwithdraw leads to permanent freezing of user withdrawals
Submitted on Aug 14th 2026 at 11:09:38 UTC by @georgibuilds for Audit Comp | Firelight
Report ID: #88653
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
Brief/Intro
rescueWithdrawFromBlocklisted records a rescued withdrawal by setting isWithdrawClaimed[period][from] = true—the same storage flag claimWithdraw uses to mean "this account has already been paid." No function ever resets it, and removeFromBlocklist clears only isBlocklisted.
In production, this means that once an account has been rescued for a given period, any withdrawal later credited to that account for that same period is permanently unclaimable by anyone: the shares are already burned, the assets are booked to an entry no address can claim, and the one function that touches the flag is itself blocked by it. Recovery requires a proxy upgrade.
Vulnerability Details
The same storage flag carries two incompatible meanings:
// claimWithdraw - "this account was PAID" (FirelightVault.sol:806)
isWithdrawClaimed[period][sender] = true;
// rescueWithdrawFromBlocklisted - "this entry was MOVED AWAY" (FirelightVault.sol:870)
isWithdrawClaimed[periods[i]][from] = true;
// claimWithdraw - the gate that can no longer be passed (FirelightVault.sol:794)
if (isWithdrawClaimed[period][sender]) revert AlreadyClaimedPeriod(period);removeFromBlocklist (:560) clears only isBlocklisted. There is no setter for isWithdrawClaimed anywhere in the contract.
The flag is also the guard inside the rescue path itself (:864):
So the one admin function that touches this state is blocked by the state it would need to fix.
_requestWithdraw credits withdrawSharesOf[period][receiver] (:1017)—the receiver, not the owner—and always targets currentPeriod() + 1 (:1008). Those two facts define both victim classes and the exposure window.
Case 1: The cleared account loses its own re-request
Alice withdraws in period P, credited to P+1. Compliance flags her; RESCUER moves the entry to the treasury, setting isWithdrawClaimed[P+1][alice] = true.
The screen was a false positive, so removeFromBlocklist(alice) is called. isBlocklisted clears, but the claim flag does not. Period P+1 is still accumulating, so her new redeem credits withdrawSharesOf[P+1][alice] again and the contract accepts it silently.
When P+1 matures, claimWithdraw(P+1) reverts AlreadyClaimedPeriod, permanently.
Case 2: An unrelated third party loses funds
Because the credit lands on the receiver, Bob—never blocklisted and with no connection to the compliance action—calls redeem(shares, alice, bob).
His shares burn and withdrawSharesOf[P+1][alice] is credited. Alice cannot claim because of the stale flag; Bob cannot claim because he is not the credited receiver, so claimWithdraw reverts NoWithdrawalAmount.
Bob's assets are unreachable by anyone, and he had no way to know the condition existed.
Fix
Track "moved away" separately from "paid out". FirelightVaultStorage has a 48-slot __gap, so adding storage is upgrade-safe:
claimWithdraw:794 then checks only isWithdrawClaimed, which now means solely "was paid", and a re-credited entry claims normally.
Impact Details
The affected withdrawal is permanently frozen. The shares are already burned, the assets are booked to an entry no account can claim, and no rescue can re-target it: a second rescueWithdrawFromBlocklisted reverts on the same flag at :864. Only a proxy upgrade can clear the state.
Magnitude is bounded, and I would rather state that than leave it to be discovered. Only funds credited to the flagged period are affected: requests made during the remainder of period P. The exposure window is up to one full period and scales with the configured PeriodConfiguration.duration.
Protocol documentation describes a current ~1-day cadence, but the V2 vault is not yet deployed, so the configured duration should be confirmed rather than assumed. Waiting one period before re-requesting avoids the defect entirely.
Weighed against those limits, the strongest element is Case 2: that victim cannot apply the mitigation, because they cannot see the condition that requires it.
References
contracts/core/FirelightVault.sol:427-428(totalAssets):560(removeFromBlocklist):794and:806(claimWithdraw):864and:870(rescueWithdrawFromBlocklisted):1008,:1017,:1025(_requestWithdraw)
contracts/core/FirelightVaultStorage.sol:87(48-slot__gap)
Proof of Concept
Step-by-step reproduction for both cases, followed by a runnable test.
Save as test/poc_rescue_withdraw_brick.test.js and run:
Expected output—5 passing:
Step by step: Case 1
The asymmetry between the treasury claim and Alice's claim is the defect: same period, same vault, the rescued entry pays out while the freshly credited one cannot.
Step by step: Case 2 (third-party victim)
No recovery path
Re-blocklisting Alice and attempting a second rescue reverts AlreadyClaimedPeriod at FirelightVault.sol:864: the only admin function touching this state is blocked by the state it would fix. Demonstrated by test 2.
Test source
The CONTROL test proves the identical re-request settles normally when no rescue intervenes and pendingWithdrawAssets returns to zero, so the fixture is sound and the rescue is the cause. The BOUND test documents the mitigation rather than hiding it.
Was this helpful?