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

89071 sc high withdrawal rescue sets iswithdrawclaimed on an open period bucket making any later withdrawal into that bucket permanently unclaimable

Submitted on Aug 16th 2026 at 23:54:16 UTC by @xeQted for Audit Comp | Firelight

  • Report ID: #89071

  • 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 writes isWithdrawClaimed[period][from] = true at FirelightVault.sol:870 to mark the rescued shares as consumed. claimWithdraw reads that same mapping at FirelightVault.sol:794 and reverts before computing any amount. The mapping has no clearing path anywhere in the contract. Once an account is rescued from period p, any withdrawal it is later credited into bucket p burns vault shares at FirelightVault.sol:1017 and can never be claimed.

rescueWithdrawFromBlocklisted is the only function that can set the flag while the bucket still accepts credits, because the credit window (currentPeriod() + 1 == p, FirelightVault.sol:1008) and the claim window (currentPeriod() > p, FirelightVault.sol:791) are otherwise disjoint.

Affected code

File
Lines
Role

contracts/core/FirelightVault.sol

870

Sets the flag on an open bucket

contracts/core/FirelightVault.sol

794

Reads the flag, reverts before computing assets

contracts/core/FirelightVault.sol

864

Same read blocks the only recovery route

contracts/core/FirelightVault.sol

1017

Credits the bucket after the flag is set

Root cause

rescueWithdrawFromBlocklisted moves a blocklisted account's pending withdrawal shares to another address, then flags the source account:

claimWithdraw treats the same mapping as "this account has been paid for this period":

Line 794 executes before line 796, so a nonzero withdrawSharesOf[period][sender] is never read.

isWithdrawClaimed is the only boolean in FirelightVaultStorage.sol that is set true with no assignment back to false. isBlocklisted is cleared at FirelightVault.sol:561, isPayoutAllowlisted at FirelightVault.sol:580, and hasActiveIncident takes a caller-supplied value at Firelight

A later fix added the to-side check at line 865. No equivalent check exists for the from

Impact

The victim's vault shares are burned at FirelightVault.sol:1017 and the corresponding as[p] and pendingWithdrawAssets for the lifetime of the contract. No function returns them. claimWithdraw reverts at line 794 and rescueWithdrawFromBlocklisted reverts at line 864 on the same flag, so the two functions that move assets out of a withdrawal bucket are both closed. Recovery requires a contract upgrade that writes storage to clear the flag, not a logic patch.

The stranded assets stay subtracted from totalAssets() at FirelightVault.sol:428 permaneot a solvency gap: totalAssetsAt feeds both the payout exposure cap and CoverOrderAllocator._computeAvailableCapacity, so it moves both together.

Sanity check against the Immunefi Vulnerability Severity Classification System v2.3, smart contract category: the impact is permanent and unrecoverable without an upgrade, which is Critical. The trigger requires two role-gated calls, stated below. A triager weighting likelihood rather than

Attack scenario

No attacker participates. Each step is an operation the contract exposes for its stated ctim's own routine withdrawal. All steps occur inside one period, 1 week in the defaultconfiguration.

1

Period p-1: Victim requests a withdrawal

Victim calls withdraw. withdrawSharesOf[p][victim] is credited at line 1017, vault shares are burned, assets are added to withdrawAssets[p] and pendingWithdrawAssets.

Cost: none, normal user action.

2

Victim is blocklisted

Victim is blocklisted via addToBlocklist at line 550.

Precondition: BLOCKLIST_ROLE.

3

Pending withdrawal is rescued

rescueWithdrawFromBlocklisted(victim, X, [p]) moves the shares to X and sets isWithdrt line 870.

Precondition: RESCUER_ROLE.

X is an arbitrary address parameter, so thiscovers migrating a beneficial owner's pending withdrawal to a fresh compliant address.

4

Victim is removed from the blocklist

Victim is cleared via removeFromBlocklist at line 560.

Precondition: BLOCKLIST_ROLE.

the sequence to continue: withdraw carries notBlocklisted(receiver) at line 765, as doesevery other path that can credit a bucket.

5

Victim withdraws again into the same bucket

Still period p-1. Victim calls withdraw again. The call succeeds. Nothing in _requestmed, so no revert or event signals the pending loss.

Cost: none, normal user action.

6

Claim fails permanently

Period p+1. claimWithdraw(p) reverts AlreadyClaimedPeriod(p) at line 794.

The victim discovers the loss one to two periods after the shares were burned.

Period numbers cannot collide, so the same-period constraint in step 5 is required: _addPeriodConfiguration rejects an epoch earlier than nextPeriodEnd() at FirelightVault.sol:1093 and accumulates startingPeriod at line 1096.

Line 869 already covers both jobs line 870 was doing. A second rescue of the same (period, from) reads _withdrawOf == 0 and reverts NoWithdrawalAmount at line 866. A claimWithdraw by from computes assets from withdrawSharesOf, now zero, and reverts NoWithdrawalAmount at line 803.

Retain line 864. claimWithdraw sets isWithdrawClaimed at line 806 without zeroing withdrt has legitimately claimed period p still holds a nonzero withdrawSharesOf[p][account]. Line 864 is what stops a rescue from moving already-paid shares to to for a second payout.

If a consumed-marker is needed for off-chain accounting, add a separate isWithdrawRescued[period][account] that claimWithdraw does not read.

Relationship to prior audit findings

The OpenZeppelin/Sentora review of 2025-07-21, finding M-02 "Period Duration Cannot Be Changed", reaches this same revert at line 794, but from a different cause: a periodDuration change that made two withdrawal requests land on the same period number. It was closed as resolved, and the frations are scheduled.

Proof of Concept

Runs against the repository's stock Hardhat suite. No source modification. The test uses the repo's own deployVault fixture from test/setup/fixtures.js, which is what grants RESCUER_ROLE and BLOCKLIST_ROLE.

Save as test/poc_rescue_flag_poison.js:

Run:

Output:

1500000000 is 1,500 fXRP at 6 decimals. Steps 6 and 7 assert revertedWithCustomError(..., 'AlreadyClaimedPeriod').withArgs(1), so both claimWithdraw(1) and rescueWithdrawFromBlocklisted(victim, X, [1]) are proven closed against a balance the vault still reports as owed via withdrawalsOf(1, victim).

Environment: Node v24.15.0, Hardhat 2.22.19, solc 0.8.28 and 0.8.29, evm target london, Linux x86_64.

Was this helpful?