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

88270 sc high rescuewithdrawfromblocklisted permanently marks period from as claimed bricking future withdrawals for that period and stranding the funds in pendingwithdrawassets

Submitted on Aug 12th 2026 at 14:37:32 UTC by @Web3Vinay for Audit Comp | Firelight

  • Report ID: #88270

  • 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

A rescue of a pending (not-yet-claimable) withdrawal will cause permanent, unrecoverable loss of the withdrawn assets for the rescued user, and a permanent understatement of totalAssets() for every other LP, because rescueWithdrawFromBlocklisted sets isWithdrawClaimed[period][from] = true on a period slot that can still be re-filled by a later withdraw/redeem, and claimWithdraw treats that flag as final.

The isWithdrawClaimed flag is used as a slot-consumed marker, but the underlying slot (withdrawSharesOf[period][account]) is not single-use: _requestWithdraw credits withdrawSharesOf[currentPeriod() + 1][receiver] += ... on every request. Any withdrawal request the user books into the same period number after the rescue lands on a slot that is already flagged "claimed" and can never be paid out — not by the user, and not by RESCUER_ROLE either.

Therefore, a legitimate use of rescueWithdrawFromBlocklisted function can poison the user's period slot and permanently brick a later legitimate withdrawal into that same period

Vulnerability Details

FirelightVault.sol:845-876 — rescueWithdrawFromBlocklisted marks from as claimed without any payout ever occurring, and without any restriction on the period being already claimable:

There is no check that periods[i] < currentPeriod(). In practice the rescue target is the pending period, because that is where an outstanding withdrawal lives: _requestWithdraw always books into currentPeriod() + 1 (contracts/core/FirelightVault.sol:1008) and claimWithdraw only unlocks a period once period < currentPeriod() (:791). So the rescued slot is, by construction, still open for new deposits of claim rights.

_requestWithdraw re-fills the very slot that was just flagged:

claimWithdraw then rejects that brand-new, fully paid-for claim right forever, because it consults the account-level flag before it ever looks at the balance:

And the only escape hatch is closed by the rescue function's own guard at :864 — a second rescue of the same (period, from) reverts AlreadyClaimedPeriod, so RESCUER_ROLE cannot move the stuck entry out either. withdrawSharesOf/isWithdrawClaimed have no other writer (_requestWithdraw at :1017 and rescueWithdrawFromBlocklisted at :868-870 are the only two), so the state is terminal.

Second-order damage: the stranded assets were added to pendingWithdrawAssets at :1019 and are only ever released by claimWithdraw's pendingWithdrawAssets -= assets at :805 (or payout() at :960, which is out of window by then). Since the claim can never execute, the counter is permanently inflated, and totalAssets() is permanently deflated for every remaining LP:

Internal Pre-conditions

  1. A user has an open withdrawal request for the pending period P = currentPeriod() + 1 (withdrawSharesOf[P][user] > 0, isWithdrawClaimed[P][user] == false).

  2. BLOCKLIST_ROLE blocklists that user (compliance flag, false positive, key-compromise report, etc.).

  3. RESCUER_ROLE calls rescueWithdrawFromBlocklisted(user, dest, [P]) — the documented, intended use of the function for an outstanding withdrawal.

  4. BLOCKLIST_ROLE later un-blocklists the user (flag cleared / false positive resolved) while the chain is still inside period P - 1, i.e. while currentPeriod() + 1 == P.

  5. The user files any new withdraw/redeem while still in period P - 1.

All four privileged steps are squarely inside documented authority. The trigger in step 5 is a permissionless, ordinary user action — the user does not know their period slot has been poisoned, and there is no view function or event that would tell them (maxClaim-style helper at :465-474 reads withdrawSharesOf only and does not consult isWithdrawClaimed).

External Pre-conditions

None.

Attack Path

Concrete numbers from the PoC (6-decimal asset, 7-day periods):

  1. Bob (unrelated LP) deposits 100,000 fXRP.

  2. Alice deposits 100,000 fXRP and redeems all of it → withdrawSharesOf[P][alice] = 100,000e6, pendingWithdrawAssets = 100,000e6.

  3. BLOCKLIST_ROLE blocklists Alice.

  4. RESCUER_ROLE calls rescueWithdrawFromBlocklisted(alice, aliceNew, [P]) → withdrawSharesOf[P][aliceNew] = 100,000e6, withdrawSharesOf[P][alice] = 0, isWithdrawClaimed[P][alice] = true (:870) — no assets moved.

  5. BLOCKLIST_ROLE un-blocklists Alice (false positive resolved). Still inside period P - 1.

  6. Alice deposits 50,000 fXRP and redeems → withdrawSharesOf[P][alice] = 50,000e6, pendingWithdrawAssets = 150,000e6. The flag from step 4 is still true.

  7. Two period rolls; P becomes claimable. aliceNew claims 100,000 fXRP successfully.

  8. Alice calls claimWithdraw(P) → reverts AlreadyClaimedPeriod(P) (:794). She receives nothing.

  9. Recovery attempt: re-blocklist Alice and call rescueWithdrawFromBlocklisted(alice, aliceNew, [P]) → reverts AlreadyClaimedPeriod(P) at :864. No recovery path exists.

Impact

  • Direct, permanent loss of 50,000 fXRP for Alice in the PoC — the amount is unbounded and equals whatever the user withdraws into the poisoned period.

  • Irreversibly bricked state: neither the user nor RESCUER_ROLE nor any admin function can clear isWithdrawClaimed[P][alice] or move withdrawSharesOf[P][alice]. There is no setter, and the vault is upgradeable only via a full implementation upgrade.

  • Accounting broken for every other LP: pendingWithdrawAssets is permanently inflated by the stranded amount, so totalAssets() (:427-429) is permanently understated by the same amount. In the PoC the vault holds 150,000e6 raw but reports totalAssets() = 100,000e6. This silently:

    • depresses the share price for all remaining holders (_convertToAssetsTotals denominators),

    • consumes depositLimit headroom that will never be freed (maxDeposit at :371-378),

    • shrinks payout()'s currentActiveAssets cap (:912) forever, reducing the cover the vault can honour.

  • Broken invariant, exactly: claimWithdraw's one-shot flag is asserted to be per-claim, but it is in fact a permanent per-slot marker on a slot that is re-fillable — a booked, fully-funded claim right can exist with isWithdrawClaimed == true and zero payout ever made.

Note on the pre-declared exclusions: the privileged actors here all stay inside documented authority. The result — an ordinary user's later funds irreversibly bricked and every other LP's share accounting permanently corrupted — is the "privileged role's action produces a result outside its documented authority (breaks accounting for others, bricks state irreversibly)" carve-out, not centralization risk.

Reference

contracts/core/FirelightVault.sol:845-876 — rescueWithdrawFromBlocklisted contracts/core/FirelightVault.sol:788-811 — claimWithdraw

Proof of Concept

Proof of Concept

The project uses Hardhat, but I used Foundry as an independent testing harness for the PoC.

Run

Test Code

Result

Every assertion in the test asserts the broken behaviour, so a green run is the proof: Alice's 50,000e6 is unclaimable by her, unrescuable by RESCUER_ROLE, and permanently subtracted from totalAssets() (150,000e6 held vs 100,000e6 reported).

Was this helpful?