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

89590 sc high missing terminal key guard in firelightvault permanently freezes withdrawals after same period rescue and restoration

Submitted on Aug 20th 2026 at 01:47:08 UTC by @Ailenia for Audit Comp | Firelight

  • Report ID: #89590

  • 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

FirelightVault.rescueWithdrawFromBlocklisted() can mark a source address as already claimed for a future period while that period's withdrawal bucket is still accepting requests. If the address is later removed from the blocklist before rollover, withdraw() and redeem() accept a new request into the same terminal (period, receiver) key, burn the owner's shares, and reserve the assets. Every normal claim and rescue path then rejects the positive entitlement with AlreadyClaimedPeriod.

The recovery requires an administrative implementation upgrade or storage migration.

Vulnerability Details

The vulnerable asset is contracts/core/FirelightVault.sol at commit 42f9ea5e43d88b35197b901acc2a8c24b314fa62.

Withdrawal requests use currentPeriod() + 1. _requestWithdraw() appends the receiver's new withdrawal shares and assets to that bucket, increases pendingWithdrawAssets, and burns the owner's live vault shares. It does not check isWithdrawClaimed[period][receiver] before writing:

rescueWithdrawFromBlocklisted() accepts any nonempty withdrawal period, including the still-open currentPeriod() + 1 bucket. It moves the source's shares to a clean address, zeros the source balance, and marks the source key terminal:

removeFromBlocklist() restores ordinary account operations but clears only isBlocklisted[account]. The period-specific terminal flag persists.

If restoration occurs before rollover, a normal self-directed request selects the same numeric period and succeeds despite the stale terminal flag.

Once the period is claimable, claimWithdraw() checks the flag before reading the new positive entitlement and reverts AlreadyClaimedPeriod.

Re-blocklisting cannot recover the assets because rescueWithdrawFromBlocklisted() checks the same flag before moving shares. No normal scoped path clears the flag or moves the terminal source entitlement.

The sequence for the bug to happen is:

  1. A user makes a partial withdrawal request into future period p, leaving part of the position as live vault shares.

  2. Authorized compliance operations blocklist the user and completely preserve the position by rescuing both the pending withdrawal and the remaining live shares to a clean replacement address. Rescuing the pending withdrawal marks (p,user) claimed.

  3. The authorized blocklist role restores the original address before rollover. Removal is an implemented lifecycle action and the repository tests verify that it restores ordinary operations, but the frequency of same-period restoration after rescue is unknown.

  4. The user reconsolidates the rescued live shares into the restored address through an ordinary ERC20 transfer, then makes the usual self-directed redeem() or withdraw() call. This doesn't need new deposit or privileged call. The request succeeds, burns the shares, and records the new entitlement under the terminal key.

  5. After rollover, both direct claim and authorized re-rescue reject that entitlement.

The privileged calls must happen before for this mechanism, but their timing matters. New requests are created under currentPeriod() + 1, while an unclaimed request remains stored after that period stops being future-facing.

So, the collision requires compliance to rescue the still-open future-period request and restore the source before rollover. Waiting until the period advances breaks the chain, which I show with the negative control.

Only claimWithdraw() and rescueWithdrawFromBlocklisted() ever write isWithdrawClaimed, both to true and never to false, and claimWithdraw() requires period < currentPeriod(). Because _addPeriodConfiguration() keeps startingPeriod continuous and strictly increasing across configuration changes, a claimed period can never become writable again, so no ordinary claim can substitute for the rescue.

This sequence does not require an attacker to access a privileged address. Rescue performs its documented preservation function for both forms of the user's position, removal from the blocklist is an implemented role action, and the principal loss is created afterward by ordinary transfer and vault entrypoints accepting an invalid terminal key. The affected staker triggers the terminal request while resuming normal use of the restored address.

Separation from public prior fixes

Sentora M-02 reached a similar terminal symptom when changing a mutable period duration caused period numbers to move backward. The commit 720d67d fixed that route with append-only period configurations and continuous startingPeriod values. My PoC uses one unchanged configuration and never rewinds or aliases a period, so that fix is active but does not prevent this rescue-created terminal key.

The commit a2def47 added isWithdrawClaimed[period][to] validation so that a rescue cannot credit a destination whose key is already terminal. That check is active here and is strong evidence that crediting a terminal withdrawal key violates an intended invariant. It was applied to the role-gated writer and not to the public one.

rescueWithdrawFromBlocklisted() validates the flag at line 865 before crediting at line 868; _requestWithdraw() credits the identical withdrawSharesOf[period][account] entry at line 1017 with no check. My PoC uses both writers against the same (period, address) key: the rescue reverts AlreadyClaimedPeriod and redeem() succeeds. So, my report is the unguarded half of an incomplete fix, not a new design objection.

Impact Details

To me, this causes the critical impact "Permanent freezing of funds". The affected amount is bounded by the restored user's available shares and the vault deposit limit.

My PoC starts from one 3,000-FXRP deposit, safely rescues a 1,000-FXRP pending withdrawal and the remaining 2,000 stXRP position, and then permanently freezes that remaining 2,000 FXRP without any redeposit. The vault physically retains 2,000 FXRP and continues to reserve exactly 2,000 FXRP in pendingWithdrawAssets, while totalAssets() reports zero active backing.

An incident payout drawing on that bucket while its window is open would reduce the reserved figure, but it does not restore any part of the entitlement: the owner's claim remains fully rejected by the terminal flag regardless of the bucket's residual. Other shareholders cannot absorb the reserved balance.

I tried, I think every scoped remedy and they are rejected by the same flag:

  • the owner's claimWithdraw() reverts AlreadyClaimedPeriod.

  • It ends up re-blocklisting and re-rescuing reverts AlreadyClaimedPeriod on the source side to any destination.

  • rescueSharesFromBlocklisted() reverts InsufficientShares because the shares are already burned.

  • Finally, no function in the codebase assigns isWithdrawClaimed = false.

Recovery requires an administrative implementation upgrade or storage migration.

The standard ERC4626 maxRedeem() and maxWithdraw() views continue to advertise the owner's full position as exitable because they do not consider the intended receiver's terminal withdrawal key.

An integration can detect the condition only by separately deriving the next period and querying the public isWithdrawClaimed(period, receiver) getter.

Remediation

Do not set isWithdrawClaimed[period][from] after rescue has zeroed the source withdrawal shares; a repeated rescue already fails on zero amount. Independently, make _requestWithdraw() reject a receiver whose target-period key is terminal. Preventing rescue of the currently open next-period bucket is another safe chain break. Add the exact rescue, restoration, same-period request, claim, and re-rescue sequence as a regression.

References

https://gist.github.com/niafreu/128850c7e944b1c8d549945d5e3b84a3

Proof of Concept

Proof of Concept

The scoped V2 implementation is not deployed at the pinned block, so the test reads the live legacy proxy to authenticate the fork and deploys the exact scoped V2 implementation locally inside it.

Run:

Expected result:

The positive case proves:

  • that complete rescue followed by restoration and ordinary position reconsolidation freezes exactly 2,000 FXRP without a redeposit.

  • that the original rescued entitlement remains claimable

  • that the later source entitlement can be recovered by no scoped path -- direct claim, re-rescue of the withdrawal to multiple destinations

  • and that rescue of the shares all fail

  • and that the vault retains the exact frozen amount as physical and pending assets.

I also added two negative controls to prove that waiting for rollover or selecting a fresh receiver breaks the collision. I made sure that the test works only if the source commit, Flare chain ID, and fork block match, prints the authenticated fork state and final frozen-fund ledger.

PoC code

Was this helpful?