89811 sc high a completed compliance rescue permanently poisons the source s withdrawal slot and freezes fresh principal after unblocking
Submitted on Aug 21st 2026 at 10:11:53 UTC by @legat for Audit Comp | Firelight
Report ID: #89811
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() moves a blocklisted account's current pending withdrawal to a clean recipient, zeros the source entitlement, and permanently sets isWithdrawClaimed[period][from] = true.
That flag is never cleared when the account is removed from the blocklist. If the restored account makes a fresh deposit and uses the ordinary redeem() or withdraw() flow before the current period changes, _requestWithdraw() credits the independently funded entitlement into the same (period, account) slot without checking or clearing its already-claimed flag. The transaction succeeds, the fresh shares are burned, and the fresh assets enter pendingWithdrawAssets.
After the delay, claimWithdraw() rejects the positive fresh entitlement on the stale flag. Re-blocking the account and invoking the authorized rescue fallback rejects it on the same flag. No scoped method clears the tombstone, cancels the accepted request, restores the burned shares, or transfers the poisoned position. The fresh principal is permanently frozen.
Description
Intended Behavior
Firelight documents unstaking as a two-step process. The staker initiates, then receives the underlying after the defined unstaking window. The documentation states that the protocol adds no additional waiting beyond that window.
The documented blocklist lifecycle is equally specific: blocklisted addresses cannot use the vault, while an authorized role may reassign their assets to a non-blocklisted address, preserving the position. Removing an address from the blocklist restores ordinary vault operations; the repository's own blocklist regression test confirms that transfers work again after removal.
Accordingly:
rescue authority concerns the current blocklisted position;
the source is empty after a complete pending-withdrawal and active-share rescue;
clearing the blocklist restores the account;
an accepted new withdrawal must become payable after the ordinary delay.
Relevant public documentation:
Actual Behavior
The rescue correctly moves and zeros the old source position, but also writes a terminal flag against the reusable (period, source) key:
removeFromBlocklist() clears only the blocklist mapping:
The restored account can deposit normally. maxRedeem() advertises its entire fresh share balance, and previewRedeem() returns the expected asset amount. A full self-redeem then reaches _requestWithdraw():
There is no isWithdrawClaimed[period][receiver] validation. The fresh credit and burned shares therefore coexist with a terminal flag.
When the period becomes claimable, claimWithdraw() checks the flag before calculating or transferring the positive amount:
The rescue fallback also checks the source flag before moving the fresh entitlement:
The old rescued position remains payable to its clean recipient. Only the restored source's later fresh position is inaccessible, isolating the defect to the source tombstone lifecycle.
Root Cause
isWithdrawClaimed is a terminal boolean keyed only by (period, account), but that key can hold multiple sequential entitlement generations.
rescueWithdrawFromBlocklisted() uses the boolean to close the source's old generation even after zeroing it. Later, _requestWithdraw() treats the same zeroed key as reusable and adds a new generation without reconciling the terminal flag. The code therefore violates this required invariant:
The vulnerable combination is:
rescueWithdrawFromBlocklisted()writesisWithdrawClaimed[period][from] = trueafter emptying the source.removeFromBlocklist()does not reconcile per-period withdrawal state._requestWithdraw()credits a positive amount to a terminal slot and irreversibly burns the fresh active shares.Both claim and rescue reject the positive amount before transferring it.
Risk
Impact
This maps directly to the program's Critical impact, Permanent freezing of funds.
The PoC permanently freezes 2,000 FXRP of independently supplied fresh principal and proves:
the historical
1,000 FXRPrescue recipient claims successfully;the restored account's new entitlement remains exactly
2,000 FXRP;the restored account has zero active vault shares and zero liquid FXRP after its accepted full redeem;
direct claim always reverts;
an authorized second rescue always reverts;
ten additional periods do not repair the state; and
pendingWithdrawAssetsremains elevated by exactly2,000 FXRP.
The demonstrated amount is arbitrary. Any amount admitted and redeemed during the remaining portion of the same current period can be burned into the poisoned next-period key. Because pending assets are excluded from active totalAssets(), the poisoned amount also remains reserved in withdrawal accounting rather than returning to the user's active position.
Likelihood
The trigger uses only documented operations and ordinary values:
a real pending withdrawal is rescued from a blocklisted account;
all remaining active shares are rescued, leaving the source completely empty;
the compliance authority removes the empty source from the blocklist before the current period changes;
the restored account deposits fresh FXRP and self-redeems normally.
No malicious recipient, custom callback, unsafe multicall, period reconfiguration, token anomaly, storage edit, stolen credential, or unusual parameter is required. The only timing condition is that rescue, removal, and the new request occur before the current period advances, so the new request targets the same currentPeriod() + 1 key. Firelight documents current one-day periods and intended feature-complete thirty-day periods, making this a normal operational window rather than a constructed timestamp.
The existence of a different-period workaround does not recover an accepted poisoned request. The contract exposes no warning or restriction: it reports the full fresh balance as redeemable, previews the expected output, accepts the self-redeem, and burns the shares.
Privileged-role applicability
The homepage states:
Any bug report that requires a privileged role holder to act within the permissions explicitly granted to that role is out of scope, unless the report demonstrates that the role can exceed its intended permissions or bypass an intended protocol constraint.
This finding falls within that literal exception. The roles do not act maliciously or incorrectly: BLOCKLIST_ROLE blocks a source, RESCUER_ROLE moves its complete current position to a clean recipient, and BLOCKLIST_ROLE later clears the empty source.
The documented authority of RESCUER_ROLE is to move and preserve the current blocklisted position. It is not authority to install a permanent disability on future, independently funded principal after the account is restored. The rescue call silently exceeds its attributed effect and bypasses the intended constraints that only currently blocklisted accounts are disabled, clearing restores ordinary use, accepted withdrawals become payable after the window, and rescue preserves rather than destroys a position. The rescuer does not select or control the later fresh deposit, redeem, or frozen amount.
Attack Path
The user deposits
3,000 FXRPand requests a normal1,000 FXRPwithdrawal for periodq = currentPeriod() + 1.BLOCKLIST_ROLEcorrectly blocklists the account.RESCUER_ROLEcorrectly moves the period-qwithdrawal to clean recipient B. This zeroes A's amount and setsisWithdrawClaimed[q][A] = true.RESCUER_ROLEmoves every remaining active share from A to B. A now has no old entitlement and no active shares.BLOCKLIST_ROLEcorrectly removes A from the blocklist before the current period changes.A supplies an independent fresh
2,000 FXRPdeposit and receives fresh shares.maxRedeem(A)reports every fresh share as redeemable, andpreviewRedeem()reports2,000 FXRP.A self-redeems 100% of the fresh shares.
_requestWithdraw()creditswithdrawSharesOf[q][A], adds2,000 FXRPto pending accounting, and burns every fresh share despite the stale true flag.After the full delay, B claims the historical
1,000 FXRP, proving the pool and period are functional.A's claim for the positive fresh entitlement reverts
AlreadyClaimedPeriod(q).A is re-blocklisted, but an authorized rescue to clean recipient C also reverts
AlreadyClaimedPeriod(q).A is unblocked and ten more periods pass. Claim still reverts; the fresh amount remains pending, while A retains neither shares nor FXRP.
External Preconditions
The source account has an ordinary pending withdrawal and active vault shares.
Compliance operations legitimately block the account and perform a complete rescue.
The account is legitimately cleared before the current period changes.
The restored user owns and approves fresh FXRP for a new deposit.
No role must select the later fresh amount or initiate the later redeem. No role needs to exceed a call-level access check; the excess is the rescue call's undocumented persistent effect on post-restoration funds.
Internal Preconditions
The rescued and fresh requests resolve to the same
currentPeriod() + 1period key.Rescue has left
withdrawSharesOf[q][source] == 0andisWithdrawClaimed[q][source] == true.The account is no longer blocklisted when it deposits and redeems.
The vault is unpaused, no active incident blocks the deposit, and the deposit remains within the configured limit.
These are ordinary protocol states. No storage corruption, invalid token return value, period-duration change, or fabricated balance is used.
Affected Code
Frozen target: audit-comp-firelight @ 42f9ea5e.
FirelightVault.sol#L556-L563removes only the blocklist flag.FirelightVault.sol#L709-L744accepts the fresh redeem and routes it to_requestWithdraw().FirelightVault.sol#L780-L811rejects claim on the stale flag before calculating the positive entitlement.FirelightVault.sol#L839-L876marks the source claimed and later rejects re-rescue on that same flag.FirelightVault.sol#L994-L1028credits a terminal slot, increases pending assets, and burns the fresh shares without checking the flag.FirelightVaultStorage.sol#L61-L74defines the reusable per-period amount and terminal boolean mappings.
Recommended Mitigation
Do not set a permanent claimed flag on the emptied source during rescue. Zeroing the old source amount already prevents the old entitlement from being claimed or rescued twice:
Keep the destination-already-claimed guard introduced by a2def477….
As defense in depth, enforce the invariant before every _requestWithdraw() credit. If a claimed slot has a zero existing account amount, safely reinitialize it for the new generation. If it has a nonzero amount, revert before pending accounting changes or share burning. A generation counter or explicit withdrawal-position state machine would avoid overloading one sticky boolean across sequential positions.
Add regression coverage for this exact lifecycle: complete pending and active-share rescue, unblock, fresh deposit, full redeem and fixed-asset withdraw into the same next-period slot, successful later claim, and successful rescue fallback.
Proof of Concept
Scenario
This report is standalone: the complete deterministic test and guarded runner are embedded below. The test deploys the exact frozen scoped FirelightVault through the repository's official fixture and performs the complete twelve-step path above. The fixture deploys the repository-bundled FAsset implementation behind its real FAssetProxy; MockContract is used solely as the local AssetManager authorization/minting shim and does not replace FXRP transfer, balance, approval, or vault behavior. The helper supplies only the 5,000 FXRP used by the two deposits. Consequently, after the fresh full redeem, the restored account has exactly zero liquid FXRP and zero active vault shares, making the frozen principal balance-aligned and directly measurable.
The test also executes both possible exits after maturity: direct claim and authorized re-rescue. Both fail. The historical recipient's successful claim is the control proving that the period and vault are otherwise operating normally.
Determinism
The exact frozen and embedded-file hashes are:
run-poc.sh refuses to run if the commit, either scoped vault source, bundled FAsset implementation, or bundled FAsset proxy differs. The test edits no storage, subclasses no vault, replaces no vault logic, and contacts no external network. Its claims are exclusively frozen-contract EVM state transitions: role-authorized calls, share burning, mapping writes, maturity-time advancement, claims, and exact reverts. It does not claim any network, consensus, validator, RPC, mempool, or cross-node behavior. Hardhat's in-process chain supplies the complete execution environment, including atomic rollback and deterministic time advancement; Docker, a public fork, and a multi-node cluster are neither required nor probative.
Logs
The token uses six decimals, so 2000000000 atomic units equals 2,000 FXRP.
Source — poc/rescue-source-tombstone-fresh-redeem/firelight-vault-rescue-source-tombstone.e2e.poc.js
Source — poc/rescue-source-tombstone-fresh-redeem/run-poc.sh
Standalone clean reproduction
Requirements: Git, Node.js, and npm. No .env file, external RPC, or live account is required.
Save the two complete code blocks above at these exact repository-relative paths:
Then run:
The wrapper performs every commit and decisive-source hash check, runs only the targeted local Hardhat test, and writes the full console output to poc/rescue-source-tombstone-fresh-redeem/evidence/local-e2e.log.
Was this helpful?