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
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.
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.
Recommended fix
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?