90426 sc high rescuewithdrawfromblocklisted permanently freezes later same period withdrawals for a reinstated account
#90426 [SC-High] rescueWithdrawFromBlocklisted permanently freezes later same-period withdrawals for a reinstated account
Submitted on Aug 24th 2026 at 23:10:18 UTC by @moin for Audit Comp | Firelight
Report ID: #90426
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
Firelight Critical Vulnerability Report
Brief / Intro
FirelightVault.rescueWithdrawFromBlocklisted uses the user-facing withdrawal claim flag as a rescue bookkeeping flag. When a blocklisted user's pending withdrawal is rescued, the function sets isWithdrawClaimed[period][from] = true even though from did not claim the funds. If the account is later removed from the blocklist and creates another withdrawal assigned to the same period, _requestWithdraw credits new withdrawal shares behind that stale flag. Once the withdrawal is mature, claimWithdraw(period) checks the flag first and reverts forever with AlreadyClaimedPeriod.
The result is a permanent freeze of the later withdrawal. The user's shares are already burned, the withdrawal view still reports assets owed, the assets remain counted in pendingWithdrawAssets, and both the normal claim path and the role-authorized rescue path are blocked by the same stale marker.
Affected State
FirelightVaultStorage keeps the withdrawal entitlement and claim marker as separate mappings:
The storage comment is important: isWithdrawClaimed[p][account] is documented as meaning the account has claimed. The rescue path writes that flag for an account that has not claimed.
Root Cause
claimWithdraw checks isWithdrawClaimed before calculating the assets owed:
The rescue path moves the existing pending withdrawal to the recipient, zeroes the source balance, and then marks the source as claimed:
The flag is terminal. A grep over the scoped vault source finds writes only at:
There is no delete isWithdrawClaimed[...] and no assignment to false.
The later withdrawal request path does not read the marker. It always assigns a new request to currentPeriod() + 1, burns the user's shares, and credits withdrawSharesOf[period][receiver]:
This creates the invalid state:
Once this state exists, claimWithdraw(p) can never reach the asset calculation or transfer.
Precise Reachability
The vulnerable period does not need to repeat. The reachable path occurs when the rescue and the later withdrawal both happen before the current period rolls over.
The current period is
P.Alice requests an initial partial withdrawal.
_requestWithdrawbooks it top = P + 1, leaving Alice with remaining shares.Alice is blocklisted.
RESCUER_ROLEcallsrescueWithdrawFromBlocklisted(alice, bob, [p]).
rescueWithdrawFromBlocklisted has no maturity or past-period-only restriction, so it can rescue and poison the future P + 1 bucket while P is still current.
The rescue moves Alice's original pending withdrawal to Bob and sets
isWithdrawClaimed[p][alice] = true.Alice is removed from the blocklist before period
Pends.Still in period
P, Alice redeems her remaining shares._requestWithdrawagain usescurrentPeriod() + 1, so the fresh withdrawal is also booked top.When
pbecomes claimable,claimWithdraw(p)reverts withAlreadyClaimedPeriod.
This is why the bug is reachable without waiting for a period number to recur.
Why This Is In Scope Despite Privileged Roles
This report does not claim that blocklisting or rescuing a blocklisted account is itself malicious. Those are documented compliance operations.
The in-scope defect is the side effect that exceeds the rescue authority:
The documented rescue intent is to relocate existing value from a blocklisted address to a non-blocklisted recipient.
The rescue does relocate the existing pending withdrawal correctly.
The bug is that the rescue leaves a permanent marker that later disables a different withdrawal created after the account has been reinstated.
At the moment the later funds are frozen, the harmed account is not blocklisted.
No role has a documented permission to permanently disable future withdrawals of a reinstated account.
This also bypasses the intended withdrawal-accounting constraint that a credited withdrawal balance should either be claimable after maturity or recoverable through rescue while blocklisted.
Firelight's official scope says privileged-role reports are out of scope only when the role acts within explicitly granted permissions, and remains in scope when the role exceeds its intended permissions or bypasses an intended protocol constraint. This finding is the latter: a normal rescue writes state that later freezes funds outside the rescue's documented blast radius.
Validation Checks
Same-period reachability confirmed: The PoC verifies that after rescue and delisting, the second redeem still books to the same target period
pbecausevault.currentPeriod() + 1 == pbefore the second withdrawal request is credited.Claim marker permanence confirmed:
isWithdrawClaimedis writtentrueinclaimWithdrawandrescueWithdrawFromBlocklisted, and the scoped vault never clears it withfalseordelete.Frozen withdrawal balance confirmed: After the second redeem,
withdrawSharesOf[p][alice] > 0andwithdrawalsOf(p, alice)reports600e18owed, proving the later withdrawal is real and not an empty accounting artifact.User claim path blocked: When the withdrawal becomes claimable,
claimWithdraw(p)reverts withAlreadyClaimedPeriodbefore transferring any assets.Role-based recovery path blocked: Re-blocklisting Alice and calling
rescueWithdrawFromBlocklisted(alice, bob, [p])also reverts withAlreadyClaimedPeriod, so the authorized rescue path cannot recover the frozen withdrawal.Share rescue cannot recover it:
rescueSharesFromBlocklistedcannot help because Alice's vault shares were already burned when the later withdrawal request was created.Time does not resolve the freeze: The validation suite advances 12 additional periods and the same
AlreadyClaimedPeriodrevert still blocks the claim.Control case passes without rescue: A no-rescue control with two same-period withdrawal requests succeeds, proving normal same-period withdrawal accumulation is valid and the rescue marker is the cause.
Later-period request after rescue succeeds: A rescued and delisted account can claim a later-period withdrawal normally, proving the bug is specifically caused by booking a fresh withdrawal behind the stale marker in the same period.
Impact
Selected in-scope impact: Critical - Permanent freezing of funds.
The affected withdrawal is permanently inaccessible inside the current contract logic:
the victim's vault shares are burned when the later withdrawal request is created;
withdrawSharesOf[p][victim]remains non-zero;withdrawalsOf(p, victim)reports assets owed;claimWithdraw(p)always reverts before assets are calculated or transferred;the role-authorized recovery path
rescueWithdrawFromBlocklistedalso reverts;the corresponding amount remains permanently reserved in
pendingWithdrawAssetsbecause the blocked claim cannot decrement it.
The PoC demonstrates a concrete 600e18 frozen withdrawal after an initial 400e18 rescue. The maximum practical loss is the asset value represented by fresh withdrawal shares credited to the poisoned (period, account) pair. This can be as large as the user's remaining vault position for that period. There is no attacker profit in the base scenario; the severity comes from permanent principal unavailability, not theft.
Because the entitlement cannot be claimed, its corresponding assets remain reserved and excluded from active totalAssets() indefinitely:
References
Official Firelight Immunefi scope: https://immunefi.com/audit-competition/audit-comp-firelight-1/scope/
contracts/core/FirelightVault.sol:788-806-claimWithdrawcontracts/core/FirelightVault.sol:845-875-rescueWithdrawFromBlocklistedcontracts/core/FirelightVault.sol:994-1028-_requestWithdrawcontracts/core/FirelightVault.sol:1084-1086- minimum period duration enforcementcontracts/core/FirelightVaultStorage.sol:39-40-SMALLEST_PERIOD_DURATION = 1 dayscontracts/core/FirelightVaultStorage.sol:67-71- withdrawal balances and claim marker
Proof of Concept
Proof of Concept
This PoC is local-only Foundry code. It does not interact with mainnet or public testnet contracts and does not require a fork RPC.
Required Setup / Dependencies
Foundry installed (
forgeavailable).Firelight repository checked out at the audited commit.
Repository dependencies installed.
A Foundry test harness that deploys
FirelightVault, grantsBLOCKLIST_ROLEandRESCUER_ROLE, and exposes:assetvaultalicebobblocklisterrescuerdepositAs(address,uint256)warpToNextPeriod()
In this audit workspace, the regression is implemented in a test contract extending the existing FirelightBase harness and importing IFirelightVault.
Steps to Run
Run the minimal regression with:
Expected Result
The test must pass and show that:
the rescued account is marked as claimed for the withdrawal period;
the account is later delisted before the current period rolls;
a fresh withdrawal is credited to the same period behind the stale marker;
claimWithdraw(period)reverts withAlreadyClaimedPeriod;the withdrawal remains owed and reserved in
pendingWithdrawAssets;a second authorized rescue also reverts with
AlreadyClaimedPeriod.
Observed result from the local run:
PoC Code
Was this helpful?