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

88489 sc high stale iswithdrawclaimed flag left by a rescue lets a later same period re withdrawal burn a user s shares into a slot no code path can honor permanent freezing of funds

Submitted on Aug 13th 2026 at 12:49:55 UTC by @Rubyglask for Audit Comp | Firelight

  • Report ID: #88489

  • 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

Severity

High. Impact category: Permanent freezing of funds — an enumerated in-scope impact for this program (listed there at Critical). The freeze is permanent and affects principal (not merely unclaimed yield), and it is reachable with trusted operators acting only within their documented authority plus the affected user's own ordinary withdrawal. Assessed at High rather than Critical because the frozen amount is bounded to the affected user's own re-withdrawal for a single period, and the trigger requires a specific blocklist → rescue → un-blocklist sequence together with a same-period re-withdrawal — a constrained scope rather than a systemic freeze.

Scope check

Not out of scope. This is a code-level accounting defect: the rescue path sets isWithdrawClaimed[p][account] and never clears it, and _requestWithdraw then burns shares into that flagged slot. It is not an operator-trust or governance issue — every operator action stays within its normal authority, and the loss is caused by the user's own ordinary withdrawal. That places it under the program's honest-operator provision (honest operator actions plus ordinary user actions and ordering).

Summary

FirelightVault processes withdrawals in two steps. A request burns the owner's shares and credits a claim in the next period's withdrawal batch; after a delay, claimWithdraw pays it out. A per-account flag isWithdrawClaimed[period][account] marks a slot as collected so it cannot be claimed twice.

rescueWithdrawFromBlocklisted moves a blocklisted account's pending withdrawal to another address and sets that same flag, and never clears it. If the account is later un-blocklisted and requests a new withdrawal into the same still-open batch, _requestWithdraw burns the shares and credits the slot without checking the flag. From that point both exit paths (claimWithdraw and a further rescue) revert on the stale flag. The re-withdrawn funds are paid in but can never be paid out, and the vault stops counting them in totalAssets().

The defect is independent of operator intent: _requestWithdraw should never burn shares into a slot that every exit is guaranteed to reject, regardless of how the slot came to be flagged.

Root cause

The rescue sets the flag and never resets it:

A fresh withdrawal credits the slot and burns the shares with no check on the flag:

Both exits revert on the flag: claimWithdraw at :794 and re-rescue at :864, each if (isWithdrawClaimed[...]) revert AlreadyClaimedPeriod(...).

Only the rescue path can poison a live slot

claimWithdraw requires period < currentPeriod() (:791) while _requestWithdraw targets currentPeriod() + 1 (:1008). Period numbers are monotonic and never reused, so the claim path can never have flagged a slot that a new withdrawal is landing in. rescueWithdrawFromBlocklisted accepts an arbitrary periods[] and can flag a still-pending period, so it is the only way to reach the collision. The flag has two writers (:806, :870) and no reset.

Impact

The re-participating user permanently loses the re-withdrawn amount: shares burned, assets locked. The loss is bounded by that user's own re-withdrawn amount for the period; it is their own funds, not other users'. No party gains: the stranded tokens are excluded from totalAssets() (= super.totalAssets() − pendingWithdrawAssets, :427-429) and are unredeemable by anyone.

There is no recovery path in the deployed code — every exit for the poisoned slot is closed:

  • claimWithdraw reverts on the flag (:794);

  • rescueWithdrawFromBlocklisted reverts on the flag (:864);

  • the vault's only other rescue, rescueSharesFromBlocklisted (:818), acts solely on the live share balance (balanceOf → _transfer) and never touches withdrawSharesOf, isWithdrawClaimed, or pendingWithdrawAssets, so it is structurally incapable of releasing the frozen withdrawal.

isWithdrawClaimed is written in exactly two places (:806, :870) and cleared nowhere in the contract, and no admin or governance function resets it or moves pendingWithdrawAssets back out of the slot. Upgrading the implementation could add such a path, but standard bounty practice does not treat upgradeability as a mitigation.

Reachability and scope

The full sequence — withdraw, blocklist, rescue, un-blocklist, re-withdraw — must complete within one withdrawal period. This is structural: the flag is keyed [period][account], and a new withdrawal re-enters the poisoned slot only while currentPeriod() == p-1. Each operator action is within its role and non-malicious; addToBlocklist and removeFromBlocklist share one BLOCKLIST_ROLE, and there is no guard preventing removeFromBlocklist after a rescue.

An operator un-blocklists an address after a protective rescue for reasons unrelated to this bug — for example, a mis-identification is corrected — and the loss is then caused by the user's own ordinary withdrawal. Nothing in the contract makes a rescue permanent or forbids un-blocklisting after one.

Guard _requestWithdraw so it can never burn shares into an already-claimed slot:

It now reverts instead of burning into a dead slot. Alternatives: clear the flag when crediting a fresh position, or give rescueWithdrawFromBlocklisted its own bookkeeping instead of overloading isWithdrawClaimed.

Broken invariant

A non-blocklisted account that requested a withdrawal in period p can always claim it after p. The stale rescue flag breaks this.

Notes

The finding is not among the program's listed known issues (which concern first-loss-buffer depeg and oracle behavior, unrelated to withdrawal share-accounting). The distinguishing element is the isWithdrawClaimed overload between the claim path (:806) and the rescue path (:870).

Proof of Concept

contracts/test/zz9_rescue_freeze.js — real FirelightVault, real 6-decimal fXRP, fee 0. Hardhat. Run: cd contracts && npx hardhat test test/zz9_rescue_freeze.js.

Walkthrough: A deposits 30,000 and withdraws 10,000 into pool p. BLOCKLIST_ROLE blocklists A; RESCUER moves A's pending withdrawal to B, setting isWithdrawClaimed[p][A] = true. BLOCKLIST_ROLE un-blocklists A, still in period q = p-1. A re-withdraws 10,000 into pool p, which burns A's shares and credits withdrawalsOf(p, A) = 10,000 while the flag stays true. Pool p matures: B claims A's original 10,000, but A's claimWithdraw(p) reverts AlreadyClaimedPeriod and a further rescue reverts too. The 10,000 is stranded in pendingWithdrawAssets, excluded from totalAssets(), unredeemable. The no-rescue control claims the full 20,000, isolating the flag as the sole cause.

Output (re-run 2026-08-13):

Test (contracts/test/zz9_rescue_freeze.js):

(The committed file additionally logs each step to produce the trace above; both tests pass together.)

Was this helpful?