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

88286 sc high requestwithdraw can reuse a withdrawal bucket already closed by rescue permanently freezing fresh user funds

Submitted on Aug 12th 2026 at 15:44:53 UTC by @CarlosMB for Audit Comp | Firelight

  • Report ID: #88286

  • 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

Summary

rescueWithdrawFromBlocklisted() moves a blocklisted user's pending withdrawal to another address and marks the source (period, account) bucket as claimed:

withdrawSharesOf[periods[i]][to] += _withdrawOf;
withdrawSharesOf[periods[i]][from] = 0;
isWithdrawClaimed[periods[i]][from] = true;

This can happen while that withdrawal period is still in the future.

If the account is legitimately removed from the blocklist before the current period ends, it can deposit fresh assets and request another withdrawal. _requestWithdraw() assigns the new request to the same currentPeriod() + 1 bucket but does not check isWithdrawClaimed[period][receiver]:

uint256 period = currentPeriod() + 1;

withdrawAssets[period] += assets;
withdrawShares[period] += sharesWithdraw;
withdrawSharesOf[period][receiver] += sharesWithdraw;

pendingWithdrawAssets += assets;

The new withdrawal succeeds and burns the user's shares even though the destination bucket is already terminal.

The resulting state is:

From that point:

  • claimWithdraw(P+1) always reverts with AlreadyClaimedPeriod;

  • re-blocklisting the user and calling rescueWithdrawFromBlocklisted() also reverts on the same flag;

  • the newly withdrawn vault shares have already been burned; and

  • there is no cancellation, reassignment, or flag-reset path.

Therefore the fresh withdrawal is permanently inaccessible under the current implementation.

Vulnerability Details

Assume the vault is currently in period P.

  1. Alice creates a normal withdrawal targeting P+1.

  2. Alice is legitimately blocklisted.

  3. RESCUER_ROLE rescues Alice's pending withdrawal for P+1.

  4. The rescue leaves:

  1. The rescuer may also rescue all of Alice's remaining active shares.

  2. Alice is legitimately removed from the blocklist while the vault is still in P.

  3. Alice makes a completely fresh deposit.

  4. Alice calls withdraw(..., Alice, Alice) before P ends.

  5. _requestWithdraw() again selects P+1 and credits new withdrawal shares to Alice despite isWithdrawClaimed[P+1][Alice] == true.

  6. Alice's fresh vault shares are burned.

  7. Once P+1 matures, Alice cannot claim the withdrawal.

  8. Re-blocklisting Alice does not help because the rescue path also rejects the already-marked source bucket.

No privileged role needs to behave maliciously. The privileged calls only perform supported compliance actions; the freeze occurs when the restored user later performs a normal permissionless deposit/withdrawal flow.

Root Cause

isWithdrawClaimed[period][account] is treated as a terminal state by both available settlement paths, but _requestWithdraw() does not enforce that terminal state before writing new withdrawal shares.

There should never be a state where:

but rescue can mark the future P+1 bucket as claimed while the vault is still in P, and _requestWithdraw() can subsequently recreate value in that same bucket.

This invariant is already enforced in rescueWithdrawFromBlocklisted() for the destination account:

but the equivalent check is missing from _requestWithdraw().

Impact

Permanent freezing of user funds.

The amount is not bounded to dust. After being removed from the blocklist, the account can make a fresh deposit of any amount permitted by normal vault limits and then burn those shares into the unusable withdrawal bucket.

An upgrade could theoretically modify storage or contract logic, but there is no recovery path available through the current protocol implementation.

Proof of Concept

Proof of Concept

Create test/critical_rescue_claimed_bucket_reuse.js:

Run:

The PoC passes and demonstrates that fresh user shares are burned into a pending withdrawal for which both claimWithdraw() and rescueWithdrawFromBlocklisted() are permanently disabled.

Reject withdrawal creation when the receiver's target-period bucket is already marked claimed:

This check should execute before updating withdrawal accounting or burning the owner's shares.

Was this helpful?