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

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:

  1. rescueWithdrawFromBlocklisted() writes isWithdrawClaimed[period][from] = true after emptying the source.

  2. removeFromBlocklist() does not reconcile per-period withdrawal state.

  3. _requestWithdraw() credits a positive amount to a terminal slot and irreversibly burns the fresh active shares.

  4. 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 FXRP rescue 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

  • pendingWithdrawAssets remains elevated by exactly 2,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

  1. The user deposits 3,000 FXRP and requests a normal 1,000 FXRP withdrawal for period q = currentPeriod() + 1.

  2. BLOCKLIST_ROLE correctly blocklists the account.

  3. RESCUER_ROLE correctly moves the period-q withdrawal to clean recipient B. This zeroes A's amount and sets isWithdrawClaimed[q][A] = true.

  4. RESCUER_ROLE moves every remaining active share from A to B. A now has no old entitlement and no active shares.

  5. BLOCKLIST_ROLE correctly removes A from the blocklist before the current period changes.

  6. A supplies an independent fresh 2,000 FXRP deposit and receives fresh shares.

  7. maxRedeem(A) reports every fresh share as redeemable, and previewRedeem() reports 2,000 FXRP.

  8. A self-redeems 100% of the fresh shares. _requestWithdraw() credits withdrawSharesOf[q][A], adds 2,000 FXRP to pending accounting, and burns every fresh share despite the stale true flag.

  9. After the full delay, B claims the historical 1,000 FXRP, proving the pool and period are functional.

  10. A's claim for the positive fresh entitlement reverts AlreadyClaimedPeriod(q).

  11. A is re-blocklisted, but an authorized rescue to clean recipient C also reverts AlreadyClaimedPeriod(q).

  12. 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() + 1 period key.

  • Rescue has left withdrawSharesOf[q][source] == 0 and isWithdrawClaimed[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.

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?