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

88772 sc high stale claimed flag after compliance rescue permanently freezes a later withdrawal credited to the same period

Submitted on Aug 15th 2026 at 00:37:58 UTC by @Maroutis for Audit Comp | Firelight

  • Report ID: #88772

  • 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

rescueWithdrawFromBlocklisted sets isWithdrawClaimed[period][account] = true even though that period can still receive new withdrawal requests. If compliance later unblocks the account, a normal withdrawal burns more shares and credits a new withdrawal to the same period. claimWithdraw and a second rescue both revert with AlreadyClaimedPeriod, leaving the underlying in the vault but excluded from totalAssets().

Vulnerability Details

The rescue moves the existing withdrawal shares and then marks the source (period, account) pair as claimed:

uint256 _withdrawOf = withdrawSharesOf[periods[i]][from];
if (isWithdrawClaimed[periods[i]][from]) revert AlreadyClaimedPeriod(periods[i]);

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

Withdrawal requests made during the current period always target currentPeriod()+1. _requestWithdraw does not check the terminal flag:

The fresh request succeeds and irreversibly burns shares. At maturity, claimWithdraw checks the stale flag before calculating or transferring the new entitlement:

The rescue path contains the same check. There is no admin or user function that clears the flag or moves an entitlement behind a true flag.

This requires no malicious privileged actor. Blocklist, rescue, and later unblock are normal compliance lifecycle actions. Once unblocked, the user is an ordinary caller and the vault publicly accepts the fatal withdrawal.

Impact Details

The later withdrawal amount is permanently frozen. In the PoC, 600 FXRP is left in the vault after the user's 600 live shares are burned. pendingWithdrawAssets remains 600, totalAssets() excludes it, and withdrawalsOf(P,user) still reports 600, yet neither claimant nor rescuer can transfer it. The amount is bounded only by the user's remaining share balance, so this is the in-scope Critical impact “Permanent freezing of funds.”

References

Proof of Concept

From the repository root:

The test performs these steps:

1

Alice deposits and requests a withdrawal

Alice deposits 1,000 FXRP and requests a 400-FXRP withdrawal into period P, retaining 600 shares.

2

Alice is blocklisted

The blocklister blocklists Alice.

3

The pending withdrawal is rescued

The rescuer moves Alice's pending 400 to Bob. The test confirms isWithdrawClaimed(P,Alice) == true.

4

Alice is removed from the blocklist

The blocklister removes Alice from the blocklist.

5

Alice makes a new withdrawal

Alice calls the public withdraw(600, Alice, Alice). It succeeds, burns all 600 shares, increases pendingWithdrawAssets, and credits withdrawSharesOf(P,Alice).

6

Bob claims the rescued withdrawal

After two period transitions, Bob claims the rescued 400 normally.

7

Alice cannot claim her new withdrawal

Alice's claimWithdraw(P) reverts AlreadyClaimedPeriod(P).

8

A second rescue also fails

Re-blocklisting Alice and attempting another rescue also reverts AlreadyClaimedPeriod(P).

9

Frozen funds are confirmed

The final assertions show 600 assets still held, pendingWithdrawAssets == 600, totalAssets() == 0, and withdrawalsOf(P,Alice) == 600.

Full runnable test code

The exact standalone test executed for this submission is reproduced in full below.

Actual execution result

Was this helpful?