90067 sc high rescuewithdrawfromblocklisted latches iswithdrawclaimed on a withdrawal bucket that is still accepting credits permanently freezing every later withdrawal credited into it
Submitted on Aug 22nd 2026 at 20:19:30 UTC by @tinnohofficial for Audit Comp | Firelight
Report ID: #90067
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
isWithdrawClaimed[period][account] encodes "this account has already been paid out for this period". claimWithdraw maintains that meaning safely, because it can only ever run against a bucket that is already closed to further credits. rescueWithdrawFromBlocklisted writes the same flag with no such restriction, and the bucket a blocklisted user's pending withdrawal most naturally sits in is currentPeriod() + 1 — the one still being filled.
Once the flag is latched there, any withdrawal credited to that account in that bucket for the rest of the period has its shares burned on the way in and can never be claimed by anyone. No code path in the contract can clear the flag, reassign the credited shares, or return the assets.
Vulnerability Details
Two writers of the same flag, two different invariants
claimWithdraw refuses to touch a bucket that has not closed (FirelightVault.sol:788-811):
Because _requestWithdraw always credits currentPeriod() + 1 (FirelightVault.sol:1008), a bucket stops receiving credits the moment its own period begins, and only becomes claimable the period after that. claimWithdraw therefore only ever latches a bucket that is already sealed, and the flag's meaning holds.
rescueWithdrawFromBlocklisted writes the same flag with no period constraint at all (FirelightVault.sol:845-876):
periods is caller-supplied and unconstrained.
Exactly one bucket is dangerous, and it is the one a rescue normally targets
Rescuing a bucket that has already closed is harmless: no further credit can ever land there, so the flag is inert. I verified this rather than assuming it — a victim holding unclaimed balances in two closed buckets can have both rescued and still withdraw and claim normally afterwards (PoC case A).
The hazard is confined to currentPeriod() + 1. That is also the only bucket a user blocklisted shortly after requesting a withdrawal can have a balance in, so it is the bucket a rescuer will routinely be asked to operate on, not an exotic input. Buckets beyond currentPeriod() + 1 can never hold a balance, because _requestWithdraw has no way to write them, so _withdrawOf == 0 and the rescue reverts NoWithdrawalAmount.
What breaks after the latch
For the remainder of the current period, any credit into withdrawSharesOf[currentPeriod()+1][from] is destroyed on arrival:
_requestWithdrawcredits the shares and adds the assets topendingWithdrawAssets(FirelightVault.sol:1015-1019),it burns the caller's vault shares (
FirelightVault.sol:1025,_update(owner, address(0), shares)),and
claimWithdrawthen revertsAlreadyClaimedPeriodforever on the check atFirelightVault.sol:794.
No recovery path exists
isWithdrawClaimed is written in exactly two places in the entire repository — FirelightVault.sol:806 and :870 — and both write only true. There is no setter, no admin override, and no branch that writes false.
removeFromBlocklistreturns the account to service but cannot undo the latch; its documented contract is simply to remove the address from the blocklist.Re-blocklisting and rescuing again does not help: the rescue path hits its own
isWithdrawClaimed[periods[i]][from]guard atFirelightVault.sol:864and reverts. Verified in the PoC.Nothing reassigns
withdrawSharesOfor mints replacement shares, and the burn in_requestWithdrawis already final.
Recovery would require a proxy upgrade carrying a bespoke migration written specifically to rewrite isWithdrawClaimed or withdrawSharesOf.
The two reachable sequences
Both require the blocklisting to be reversed inside the same period as the rescue, because withdraw and redeem carry notBlocklisted(owner) and notBlocklisted(receiver) modifiers. I confirmed that a redeem naming a still-blocklisted receiver reverts BlocklistedAddress, so the reversal is a hard prerequisite rather than an incidental step (PoC case D). I state this up front because it is the report's main precondition.
Sequence 1 — the victim's own funds. A user is blocklisted shortly after requesting a withdrawal; the rescuer recovers the pending amount from the open bucket; the blocklisting is determined to have been a mistake and is reversed; still inside the same period, the user withdraws the remainder of their position. Those assets are gone.
Sequence 2 — an unrelated third party's funds. After the rescue and the reversal, any user calling the ordinary ERC-4626 redeem(shares, receiver, owner) with receiver set to the rescued account has their own shares burned and the resulting assets stranded. The victim never acts, and the third party was never blocklisted. Redeeming to another address is a first-class feature the vault supports: WithdrawRequest indexes receiver separately from owner, and _requestWithdraw credits withdrawSharesOf[period][receiver].
Distinction from the known OpenZeppelin finding
The OpenZeppelin audit of this vault (21 July 2025) described the same destructive mechanism under M-02 "Period Duration Cannot Be Changed":
"their shares would be burned during the call to
_requestWithdraw, but the subsequent call toclaimWithdrawwould revert due to theisWithdrawClaimedcheck."
That report reached the mechanism through period renumbering after a periodDuration change, and it was closed as resolved by the period-configuration rework. The underlying hazard — isWithdrawClaimed being latchable on a bucket that can still be credited — was never addressed, and the rescue path is a second, still-live route to it that OpenZeppelin did not review: their scope was Checkpoints.sol, FirelightVault.sol and FirelightVaultStorage.sol at a commit whose privileged-role inventory lists MINTER_ROLE and BURNER_ROLE and does not include RESCUER_ROLE at all.
The project has also already patched the mirror-image collision on the to side (if (isWithdrawClaimed[periods[i]][to]) revert AlreadyClaimedPeriod, commit a2def47, "Fix rescuing withdrawal to a user that already claimed them"), which shows the flag/rescue interaction was recognised as dangerous. The from side and the still-open-bucket case were simply not covered.
On the privileged-role rules
The program excludes reports that need a privileged role to act within its permissions, "unless the report demonstrates that the role can exceed its intended permissions or bypass an intended protocol constraint". Both limbs apply.
RESCUER_ROLE's documented authority is to move a blocklisted account's pending withdrawal — the NatSpec is "Rescues pending withdrawals from a blocklisted address". Nothing grants it the power to permanently destroy assets deposited later by an un-blocklisted account, still less assets belonging to an unrelated third party who was never blocklisted. That is the role reaching past its intended effect.
The protocol constraint isWithdrawClaimed[p][a] == true implies a was paid for period p is broken: the flag is set with no payment, on a bucket that is still accepting credits.
Note also that the rescuer performs no malicious or incorrect act. Zeroing withdrawSharesOf[periods[i]][from] is already sufficient to stop from claiming the rescued amount, because claimWithdraw computes the payout from withdrawSharesOf and reverts NoWithdrawalAmount on zero. The isWithdrawClaimed write is redundant for its stated purpose and is the sole cause of the freeze.
Impact Details
Direct, permanent, unrecoverable loss of principal. In the demonstrated sequence a victim with a 1,000,000-unit position loses 600,000 units — 60% of their deposit. The shares were burned by _requestWithdraw, the flag cannot be cleared, and the assets sit in the vault owned by nobody.
Bounds, stated honestly. The damage is scoped to one bucket. If the victim waits until the next period before withdrawing again, the credit lands in a fresh bucket and claims normally with zero loss (PoC case B). The exposure window is therefore the remainder of the period in which the rescue occurred. Within that window the amount at risk is unbounded: the victim's entire remaining position, plus anything a third party credits to them.
Secondary effect, also stated honestly. The stranded amount remains inside pendingWithdrawAssets permanently, because that counter is only decremented by a successful claimWithdraw or by payout. Since totalAssets() = balanceOf(vault) - pendingWithdrawAssets, the vault permanently holds real tokens that are excluded from its own accounting and belong to no one, which also understates the collateral basis every capacity and payout calculation reads. I measured this: after the freeze the vault's token balance exceeds totalAssets() + claimable by exactly the stranded amount. It does not depress the surviving LPs' share price — a control LP's position measured exactly unchanged before and after (PoC case C) — so this is orphaned value rather than a second loss, and I do not claim otherwise.
This matches Permanent freezing of funds: user principal that the vault holds, that the accounting acknowledges, and that no actor in the system can ever release.
Recommended Fix
The minimal and complete fix is to stop writing the flag at all:
Zeroing withdrawSharesOf already prevents from from claiming the rescued amount, so the flag write adds nothing and causes the freeze.
If the write is to be kept for auditability, restrict it to buckets that can no longer be credited:
Independently, consider adding a guarded recovery path — a RESCUER_ROLE or admin function that clears isWithdrawClaimed[period][account] when withdrawSharesOf[period][account] > 0 and no transfer has occurred — because as the contract stands today a single mistaken rescue is unrecoverable by design.
References
contracts/core/FirelightVault.sol:845-876—rescueWithdrawFromBlocklisted, unconstrainedperiodscontracts/core/FirelightVault.sol:864-865— theAlreadyClaimedPeriodguards that also block re-rescuecontracts/core/FirelightVault.sol:870— the latchcontracts/core/FirelightVault.sol:806— the only other write to the flag, alsotruecontracts/core/FirelightVault.sol:788-811—claimWithdraw; theperiod >= currentPeriod()guard at:791and the revert at:794contracts/core/FirelightVault.sol:994-1028—_requestWithdraw; bucket assignment at:1008, share burn at:1025contracts/core/FirelightVaultStorage.sol:71—isWithdrawClaimeddeclarationCommit
a2def47— "Fix rescuing withdrawal to a user that already claimed them" (theto-side patch)OpenZeppelin, Sentora Firelight Vault Audit, 21 July 2025, M-02 — same mechanism via a different trigger, closed as resolved
Proof of Concept
Proof of Concept
Step-by-step
Sequence 1 — victim's own funds.
Victim deposits 1,000,000 and calls
redeem(400_000, …)during period 0. The request is credited to bucket 1 (currentPeriod() + 1).Compliance blocklists the victim.
RESCUER_ROLEcallsrescueWithdrawFromBlocklisted(victim, rescuer, [1]). Bucket 1 is the open bucket.isWithdrawClaimed[1][victim]becomestrue.The blocklisting is reversed with
removeFromBlocklist(victim).Still in period 0, the victim redeems their remaining 600,000. It is credited to bucket 1 again.
withdrawalsOf(1, victim)reports 600,000 owed.Two periods later the victim calls
claimWithdraw(1). It revertsAlreadyClaimedPeriod(1). Their shares are already burned, so there is nothing left to redeem.Re-blocklisting the victim and rescuing again also reverts on the
isWithdrawClaimed[periods[i]][from]guard — there is no recovery path.
Sequence 2 — third party's funds. After steps 1–4, any other user calls redeem(500_000, victim, self). Their shares are burned, 500,000 is credited to the victim in bucket 1, and nobody can ever claim it.
Boundary checks (these establish what the bug is not):
Rescuing already-closed buckets is inert. A victim holding unclaimed balances in buckets 1 and 2 while in period 2 can have both rescued, then withdraw and claim normally from bucket 3 — no future credit can reach a closed bucket.
Waiting one period before re-withdrawing avoids the loss entirely; the credit lands in the next bucket and claims in full.
The stranded value is orphaned, not redistributed: a control LP's position is exactly unchanged.
The third-party sequence cannot run while the victim is still blocklisted;
redeemrevertsBlocklistedAddress.
Run
Save the two files under ### PoC Code as test/poc_H01_rescue_latches_claim_flag.js and test/verify_h01_bounds.js, then run:
Observed Output
Tested against v1_audit_ready (42f9ea5):
PoC Code
test/poc_H01_rescue_latches_claim_flag.js:
test/verify_h01_bounds.js:
Was this helpful?