88619 sc high stale iswithdrawclaimed flag set by rescuewithdrawfromblocklisted permanently freezes a later legitimate withdrawal by the same account burning the user s shares with no recove
Submitted on Aug 14th 2026 at 07:26:19 UTC by @enowk for Audit Comp | Firelight
Report ID: #88619
Report Type: Smart Contract
Report severity: High
Target: https://github.com/immunefi-team/audit-comp-firelight/blob/v1_audit_ready/contracts/core/IncidentManager.sol
Impacts:
Permanent freezing of funds
Description
Brief/Intro
FirelightVault.rescueWithdrawFromBlocklisted sets isWithdrawClaimed[period][from] = true as a "this account is no longer entitled" marker, but claimWithdraw reads that same flag as "this account has already been paid". The flag is keyed by (period, account) and is never cleared, while _requestWithdraw neither consults nor resets it. If a rescued account is later removed from the blocklist — a routine, intended compliance action — and makes a new, entirely legitimate withdrawal request that lands in the same period bucket, its shares are burned on request and claimWithdraw(period) reverts AlreadyClaimedPeriod forever. The privileged rescue path cannot recover the funds either, because it reverts on the same flag. On mainnet this permanently freezes the user's assets with their shares already destroyed, and permanently inflates pendingWithdrawAssets, which suppresses totalAssets() — and therefore cover capacity and the payout exposure cap — for the remaining life of the vault.
Vulnerability Details
The flag carries two incompatible meanings
isWithdrawClaimed is declared once in FirelightVaultStorage.sol#L71:
mapping(uint256 period => mapping(address account => bool value)) public isWithdrawClaimed;It has two writers that disagree about what it means:
claimWithdraw— "this account has already been paid for this period."rescueWithdrawFromBlocklisted— "this account's entitlement was moved elsewhere."
claimWithdraw (FirelightVault.sol#L788-L811) treats any set flag as meaning (1):
rescueWithdrawFromBlocklisted (FirelightVault.sol#L861-L873) writes it with meaning (2):
The flag write is redundant, so it buys no safety in exchange for the harm
Both protections the flag might be providing in the rescue path already exist independently:
Double-rescue is already prevented by
if (_withdrawOf == 0) revert NoWithdrawalAmount(...), because the rescue zeroeswithdrawSharesOf[periods[i]][from]on line 869.A post-rescue claim is already prevented by
claimWithdraw's ownif (assets == 0) revert NoWithdrawalAmount(period), since the rescued account'swithdrawSharesOfis now zero.
The flag write on line 870 therefore adds nothing except a permanent, un-clearable poison on the (period, account) pair.
_requestWithdraw never reconciles with the flag
FirelightVault.sol#L994-L1028 accepts the new request unconditionally and burns the shares:
There is no read of isWithdrawClaimed anywhere in _requestWithdraw, and no path anywhere in the contract that ever sets the flag back to false. The user gets no revert and no warning; the transaction succeeds.
Why only the rescue writer can reach this state
This is the crux, and it is what distinguishes this from the previously-disclosed issue discussed below.
A bucket is open for new requests only while period == currentPeriod() + 1 (line 1008). claimWithdraw refuses to run unless period < currentPeriod() (line 791). Because period numbering is monotonic at this commit, the claimWithdraw writer can never set the flag on a bucket that is still open for requests — by the time it can write, the bucket has been closed to new requests for at least one full period. Reaching the freeze through claimWithdraw therefore requires the period counter to move backwards, which is exactly the (now-fixed) precondition of OpenZeppelin M-02.
rescueWithdrawFromBlocklisted has no period bound at all. Its only constraint is withdrawSharesOf[periods[i]][from] != 0, and the one bucket that is guaranteed to be non-empty and simultaneously still open is currentPeriod() + 1. The rescue writer can therefore set the flag on a live bucket, which the claim writer structurally cannot. That is a distinct root cause with a distinct reachability precondition, and it is unaffected by the fix that closed M-02.
Attack / failure sequence
All privileged roles behave exactly as designed throughout. No role acts maliciously or beyond its documented authority.
Attempt recovery
No recovery exists. Re-blocklisting Alice does not help: the rescue's own guard if (isWithdrawClaimed[periods[i]][from]) revert AlreadyClaimedPeriod(...) (line 864) fires on the identical flag. The shares are already burned, and the assets remain counted in withdrawAssets[P] and pendingWithdrawAssets forever.
Step 4 is the only step that needs a plausibility argument, and there are two ordinary operational narratives for it:
Key compromise. Alice reports a compromised key. The vault blocklists the old address and rescues the pending withdrawal to her new address (
tomust be non-blocklisted, so rescuing back to Alice's own blocklisted address is impossible by construction). The old address is then cleared once the incident is closed.False-positive screening. An automated compliance hold is opened and actioned, then cleared on review. Because
tocannot be the blocklisted address, the rescue necessarily routes funds to a third address even when the hold was later found to be unwarranted.
The whole sequence must fit inside one period. The docs state periods are currently 1 day and extend to 30-day cover periods at the feature-complete launch, at which point a compliance hold that is opened, actioned and cleared within a single period is ordinary operations rather than an exotic prerequisite.
Prior art: why this is not OpenZeppelin M-02
The OpenZeppelin Phase 1 audit (2025-07-21) reported M-02 "Period Duration Cannot Be Changed", which reaches a similar-looking end state through a different mechanism. I raise it here proactively rather than leave it for triage.
M-02's body reads:
"If a user had requested a withdrawal in a previous period and, after the
periodDurationchange, the recalculatedcurrentPeriodstill matches the earlier request period, the user may attempt to withdraw again. In such cases, their shares would be burned during the call to_requestWithdraw, but the subsequent call toclaimWithdrawwould revert due to theisWithdrawClaimedcheck."
Three reasons this report is a separate, still-live issue:
OpenZeppelin diagnosed a period-configuration bug, not a flag bug. Their recommendation is "Consider keeping
periodDurationconstant throughout the lifetime of the vault to prevent inconsistencies such as the one discussed above." They proposed no change toisWithdrawClaimed,claimWithdraw, or_requestWithdraw. The flag was the symptom carrier, not the diagnosis.Their fix is in place at this commit and is effective against their trigger. M-02 is marked "Resolved in pull request #6 at commit 1433252".
_addPeriodConfigurationnow requiresnewEpoch >= nextPeriodEnd()plus epoch alignment, so period numbering is monotonic andcurrentPeriodcan never rewind onto an earlier request period. Test 3 of the attached PoC asserts this directly —addPeriodConfiguration(nextPeriodEnd() - 1, ...)revertsInvalidPeriodConfigurationEpoch, and a legal future configuration never lowers the period number. OpenZeppelin's path is dead.The rescue path never touches period configuration at all, and PR #6 did not modify
rescueWithdrawFromBlocklisted. As shown in the reachability argument above, the rescue writer can poison a bucket that is still open for requests, which the claim writer structurally cannot. Test 1 of the PoC runs green against this exact commit.
For completeness, the following were also checked and none covers this issue:
The 8 published Immunefi known issues — none concerns the blocklist or rescue paths.
0xMacro Phase 2 (
d0d3733, 2026-07-29) — mentions the rescue only as a trust assumption ("BLOCKLIST_ROLE with RESCUER_ROLE … targeted confiscation power"), with no finding on it. Findings M-1, M-2, L-1…L-4, G-1 and I-1…I-11 are unrelated.OpenZeppelin M-03 ("Missing Blacklist Check Can Lock Funds") is the nearest neighbour, but concerns a blacklisted receiver at request time; its fix (the
notBlocklisted(receiver)guard onwithdraw/redeem) is present and is a precondition of this report, not a mitigation for it.Coinspect 2025-09-01 — frontend scope only.
The Dec-2025 Immunefi Audit Competition summary reports 6 valid and 40 insight submissions from 329 reports, with zero findings at Medium severity or above (top payout $2,546 of a $15,000 pool). The two published reports are unrelated (
_disableInitializersand aperiodAtTimestamptimestamp bug). A permanent freeze of user funds would not have been resolved as a Low.
Recommended fix
Delete the redundant flag write on line 870:
withdrawSharesOf[period][from] = 0 already makes claimWithdraw revert NoWithdrawalAmount, and the _withdrawOf == 0 guard already prevents a double rescue.
If the flag must be retained for accounting or event reasons, track rescues in a separate mapping (e.g. isWithdrawRescued) that claimWithdraw does not consult, and drop the isWithdrawClaimed[...][from] precondition on line 864 so that privileged recovery remains possible.
Either way, add a regression test asserting that an un-blocklisted account can still claim a withdrawal it requested after a rescue.
Impact Details
Selected in-scope impact: Permanent freezing of funds.
Direct loss, per occurrence. The full asset amount of the affected user's post-rescue withdrawal request is permanently frozen. The loss is realised in two irreversible steps within the vault:
The user's shares are burned by
_update(owner, address(0), shares)at request time, so the claim on the vault is destroyed.The corresponding assets sit in
withdrawSharesOf[P][Alice]and can never be paid out:claimWithdrawis blocked by the stale flag, andrescueWithdrawFromBlocklisted— the protocol's own designated remedy for stuck balances — reverts on the same flag.
In the attached PoC, a user who deposits 1,000 and has a 400 withdrawal rescued then loses the entire 300 of her next request. There is no cap on this: a user withdrawing her full remaining position in the affected period loses that entire position. Nothing in the contract bounds the amount, and the freeze applies per (period, account) pair, so every account the rescue path touches carries the latent poison for the remainder of that period.
Secondary, permanent protocol-level harm. claimWithdraw is the only path that decrements pendingWithdrawAssets for a user's own entitlement (line 805). Because the claim can never execute, the frozen amount stays in pendingWithdrawAssets for the life of the vault. Since
the frozen assets are subtracted from totalAssets() forever while physically remaining in the vault. They become dead capital: they can never be withdrawn by anyone, can never back cover, and are permanently excluded from both the cover-capacity computation in CoverOrderAllocator._computeAvailableCapacity (which reads vault.totalAssetsAt(...)) and the payout exposure cap in payout (which reads totalAssetsAt(capturePeriodStart)). Every occurrence permanently ratchets down the protocol's underwriting capacity. The PoC asserts this residue explicitly.
Why this is "permanent" and not "temporary". No sequence of calls on the deployed contract restores access. The one privileged function designed to unstick balances is blocked by the identical flag it set, and the flag has no clearing path anywhere in the contract. A proxy upgrade could of course introduce one, but that is true of essentially any state corruption behind an upgradeable proxy and is not an in-contract recovery path.
Why this is not griefing. There is no attacker and no profit motive — the rescue destination receives only the funds the rescue legitimately moved, and nobody gains from the freeze. The user's own funds are destroyed by a transaction the user sends herself, and every privileged role acts within its documented authority. The absence of a beneficiary does not reduce the loss; it means the loss is unrecoverable rather than recoverable from an attacker.
Preconditions, stated honestly. The sequence requires BLOCKLIST_ROLE and RESCUER_ROLE to perform their intended compliance operations and then clear the account within the same period, followed by a withdrawal the user initiates herself. It is gated by an operational sequence rather than by attacker capability, and the trusted-operator carve-out does not apply because no role exceeds its intended authority — this is the in-scope contract destroying in-scope user funds while all of its dependencies behave correctly.
Severity note. The program's in-scope impact table lists "Permanent freezing of funds" as Critical with no TVL qualifier, and this report is filed against that entry. For the program's own calibration: the blast radius is one user's withdrawal per occurrence rather than majority TVL, and protocol-owned funds are not put at risk, so a High rating would also be a defensible read.
References
Vulnerable code (commit 42f9ea5e43d88b35197b901acc2a8c24b314fa62, branch v1_audit_ready):
FirelightVault.sol#L870— the flag write inrescueWithdrawFromBlocklisted(root cause)FirelightVault.sol#L864— the rescue guard that blocks recovery on the same flagFirelightVault.sol#L794— theclaimWithdrawcheck that reads the flag with the other meaningFirelightVault.sol#L791—period >= currentPeriod()bound that makes the claim writer unable to reach an open bucketFirelightVault.sol#L994-L1028—_requestWithdraw, which never consults or clears the flagFirelightVault.sol#L1008—period = currentPeriod() + 1, defining the open bucketFirelightVault.sol#L427-L429—totalAssets()subtractingpendingWithdrawAssetsFirelightVault.sol#L763-L765— thenotBlocklistedguards onwithdraw(the OZ M-03 fix, a precondition of step 5)FirelightVaultStorage.sol#L71— theisWithdrawClaimedslot declaration
Prior audits referenced:
OpenZeppelin, Sentora Firelight Vault Audit, 2025-07-21 — M-02 "Period Duration Cannot Be Changed" (resolved in PR #6 at commit
1433252) and M-03 "Missing Blacklist Check Can Lock Funds" (resolved in PR #5 at commit68a9dd2). Published at Firelight's/resources/audits-and-security.0xMacro, Firelight A-1, 2026-07-29 — the BLOCKLIST_ROLE/RESCUER_ROLE combination is documented there as a trust assumption only, with no finding on the rescue path.
Immunefi, Firelight Audit Competition Summary Report, December 2025.
Protocol documentation:
Core Concepts — Periods and Coverage Cycles: periods are currently 1 day and extend to 30-day cover periods at the feature-complete launch.
For Stakers — Deployments and Withdrawals: the unstaking window is the remainder of the current period plus one full period.
Proof of Concept
A runnable Hardhat test is provided below. It executes against the in-scope repository at commit 42f9ea5e43d88b35197b901acc2a8c24b314fa62 (branch v1_audit_ready) using the repository's own test/setup/fixtures.js, with no modifications to any contract.
How to run
Save the file as test/zz-audit-poc-rescue-freeze.js in the audit-comp-firelight repository and run:
Actual output
Test 1 demonstrates the vulnerability. Test 2 is a control proving the freeze is caused by the rescue's flag write rather than by the test setup. Test 3 asserts that OpenZeppelin M-02's trigger is closed at this commit, which is what makes the rescue trigger a distinct and still-live root cause.
Step-by-step explanation
All amounts use the vault asset's 6 decimals. P denotes the withdrawal bucket currentPeriod() + 1.
Rescue the pending withdrawal to Carol
rescueWithdrawFromBlocklisted(alice, carol, [P]) moves Alice's entitlement to Carol. This is the intended compliance remedy and it succeeds.
Assertions: isWithdrawClaimed(P, alice) == true — the flag is now set even though Alice never claimed anything — and withdrawSharesOf(P, alice) == 0.
Alice makes a new withdrawal request of 300
withdraw(300, alice, alice). The notBlocklisted(_msgSender() / owner / receiver) guards all pass because she is cleared. The call succeeds with no revert and no warning.
Assertions: balanceOf(alice) strictly decreased — her shares were burned — and withdrawalsOf(P, alice) > 0, so the vault now owes her assets in bucket P.
Attempt recovery
Re-blocklisting Alice and retrying the rescue does not help: rescueWithdrawFromBlocklisted(alice, carol, [P]) reverts with the same AlreadyClaimedPeriod error, because line 864 guards on the identical flag. The protocol's own designated remedy for stuck balances is blocked by the flag it set itself.
Assertion: withdrawalsOf(P, alice) is unchanged — still owed, still unclaimable.
Confirm protocol-level residue
The frozen assets remain counted in pendingWithdrawAssets forever, because claimWithdraw is the only path that would decrement them. Since totalAssets() subtracts pendingWithdrawAssets, the assets are permanently excluded from cover capacity and the payout exposure cap while still physically sitting in the vault.
Assertion: pendingWithdrawAssets() >= owed.
Result: 300 assets permanently frozen, shares already burned, no recovery path.
Test source
Was this helpful?