89549 sc high rescue leaves a sticky claimed flag that permanently freezes a later same period withdrawal
Submitted on Aug 19th 2026 at 20:19:06 UTC by @Kuzomo for Audit Comp | Firelight
Report ID: #89549
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 moves a blocklisted account's pending withdrawal to a safe address and permanently marks the source account as having claimed that period. The flag is never cleared: both assignments to it in FirelightVault set it to true, and no function in the repository clears the mapping. If that account is later removed from the blocklist and makes an ordinary new withdrawal request while the originating period is still current, the request is accepted and credited, but claimWithdraw reverts AlreadyClaimedPeriod and a second rescue reverts on the same flag. The entitlement is unreachable by every path the contract offers. In the run below the account ends with 0 shares, 0 tokens, and 500.000000 FXRP that withdrawalsOf() still reports as owed; pendingWithdrawAssets retains the same amount forever, which also permanently understates totalAssets() for every other staker. No role misbehaves at any point: blocklist, rescue and un-blocklist are the documented compliance workflow performed correctly, and the step that traps the funds is an ordinary redeem by the now-cleared account.
Vulnerability Details
isWithdrawClaimed has exactly two writers in FirelightVault and both set it to true. No function in any in-scope contract clears the mapping, and there is no setter or admin override:
FirelightVaultStorage.sol:71 mapping(uint256 period => mapping(address account => bool value)) public isWithdrawClaimed;
FirelightVault.sol:794 if (isWithdrawClaimed[period][sender]) revert AlreadyClaimedPeriod(period);
FirelightVault.sol:806 isWithdrawClaimed[period][sender] = true;
FirelightVault.sol:864 if (isWithdrawClaimed[periods[i]][from]) revert AlreadyClaimedPeriod(periods[i]);
FirelightVault.sol:865 if (isWithdrawClaimed[periods[i]][to]) revert AlreadyClaimedPeriod(periods[i]);
FirelightVault.sol:870 isWithdrawClaimed[periods[i]][from] = true;The rescue empties the source account and then marks it claimed:
https://github.com/immunefi-team/audit-comp-firelight/blob/42f9ea5e43d88b35197b901acc2a8c24b314fa62/contracts/core/FirelightVault.sol#L868-L870
true here means "this account has been settled for this period", but the account was emptied, not paid. The write path never consults the flag. _requestWithdraw targets currentPeriod() + 1, so a second request made before the originating period rolls lands in the same period and is credited normally:
https://github.com/immunefi-team/audit-comp-firelight/blob/42f9ea5e43d88b35197b901acc2a8c24b314fa62/contracts/core/FirelightVault.sol#L1008-L1019
Both release paths then reject it. claimWithdraw reverts at line 794 before it ever reaches the conversion, and a second rescueWithdrawFromBlocklisted reverts at line 864 on the identical flag. rescueSharesFromBlocklisted (line 818) moves share balances, not withdrawal entitlements, and the account's balanceOf is already zero, so it cannot help either. Recovery requires a contract upgrade.
withdrawalsOf performs the same conversion as claimWithdraw but does not consult the flag, so the view surface keeps reporting the amount as owed while both release paths revert: https://github.com/immunefi-team/audit-comp-firelight/blob/42f9ea5e43d88b35197b901acc2a8c24b314fa62/contracts/core/FirelightVault.sol#L466-L474
Impact Details
Permanent freezing of user funds. A staker who is blocklisted, rescued and subsequently cleared loses the entire value of any new withdrawal request they make before that period ends, with no on-chain recovery. In the run below the amount is 500.000000 FXRP, but the mechanism scales with the position: it strands whatever the second request is worth, not a rounding residue. The loss is silent, because withdrawalsOf() continues to report the funds as claimable, and it is triggered by an ordinary user action that the contract accepts without warning. pendingWithdrawAssets is decremented only inside the now-unreachable claimWithdraw and inside payout, so the stranded amount stays subtracted from totalAssets() (https://github.com/immunefi-team/audit-comp-firelight/blob/42f9ea5e43d88b35197b901acc2a8c24b314fa62/contracts/core/FirelightVault.sol#L427-L429) permanently, understating the vault for every remaining staker. FirelightVault is the V2 implementation for the live predeposit vault at 0x4C18Ff3C89632c3Dd62E796c0aFA5c07c4c1B2b3, which returns totalAssets() = 56,601,078,263,347 (56.6M FXRP at 6 decimals) on a read taken 2026-08-19, so this lands on a vault holding real staker capital at upgrade time.
Feasibility Limitations check. No trusted role misbehaves, exceeds its privilege or is misconfigured anywhere in this sequence. Blocklisting, rescuing and un-blocklisting are fixed documented operations, not configuration values or discretionary parameters, and each is performed correctly here; nothing requires a trusted party to act maliciously or incorrectly, and no privilege is exceeded. The protocol breaks while every party behaves exactly as intended. The action that loses the funds is a permissionless redeem by an account that is not blocklisted at that moment. The precondition is a blocklisting that is later reversed, which is the ordinary lifecycle of a screening false positive. That path is provisioned and live today, not hypothetical: on the same vault hasRole(BLOCKLIST_ROLE, 0x189993f3B53284c7e5C02b058EFC6618F78D0cEA) and hasRole(RESCUER_ROLE, 0xC115757266b902d4b1284eA24DBbE557C15E963E) both return true. Attack investment: none. Chain rollbacks and pre-impact monitoring: not cited; the state transition is committed in the user's own transaction and there is no pause or guardian that inspects it. An informed user could avoid it by waiting for the next period, but nothing on-chain or in the view surface tells them to.
Recommendation
Clear the flag when the rescue empties the source account, since the account has not been paid:
The flag's purpose is to prevent double-claiming a balance, and after the rescue the source balance is zero, so claimWithdraw's own assets == 0 check at line 803 already provides that protection. If the marker is needed for accounting, track rescued periods in a separate mapping that _requestWithdraw resets on a fresh credit, and make withdrawalsOf return zero whenever isWithdrawClaimed is set so the view cannot report unreachable funds.
References
rescue sets the terminal flag: https://github.com/immunefi-team/audit-comp-firelight/blob/42f9ea5e43d88b35197b901acc2a8c24b314fa62/contracts/core/FirelightVault.sol#L845-L876
claimWithdraw rejects on it: https://github.com/immunefi-team/audit-comp-firelight/blob/42f9ea5e43d88b35197b901acc2a8c24b314fa62/contracts/core/FirelightVault.sol#L788-L811
_requestWithdraw credits without consulting it: https://github.com/immunefi-team/audit-comp-firelight/blob/42f9ea5e43d88b35197b901acc2a8c24b314fa62/contracts/core/FirelightVault.sol#L994-L1028
storage declaration, no other writer: https://github.com/immunefi-team/audit-comp-firelight/blob/42f9ea5e43d88b35197b901acc2a8c24b314fa62/contracts/core/FirelightVaultStorage.sol#L71
Proof of Concept
Proof of Concept
Local Hardhat only; no mainnet or public-testnet transaction was sent. Clone the in-scope repository at the audited revision, install, save the file below as test/poc_sticky_claim.js, and run it. It uses only the repository's own fixtures.
Observed output:
The rescued leg is deliberately excluded from the loss: the safe address claims it in full, and the assertion safeGot == safeOwed proves it. The 500.000000 FXRP attributed as lost is only the second request, which the account made after being cleared, and the final assertions confirm the account holds 0 shares and 0 tokens while pendingWithdrawAssets still carries the full amount.
Was this helpful?