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
A user has an open withdrawal request for the pending period
P = currentPeriod() + 1(withdrawSharesOf[P][user] > 0,isWithdrawClaimed[P][user] == false).BLOCKLIST_ROLEblocklists that user (compliance flag, false positive, key-compromise report, etc.).RESCUER_ROLEcallsrescueWithdrawFromBlocklisted(user, dest, [P])— the documented, intended use of the function for an outstanding withdrawal.BLOCKLIST_ROLElater un-blocklists the user (flag cleared / false positive resolved) while the chain is still inside periodP - 1, i.e. whilecurrentPeriod() + 1 == P.The user files any new
withdraw/redeemwhile still in periodP - 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):
Bob (unrelated LP) deposits
100,000fXRP.Alice deposits
100,000fXRP and redeems all of it →withdrawSharesOf[P][alice] = 100,000e6,pendingWithdrawAssets = 100,000e6.BLOCKLIST_ROLEblocklists Alice.RESCUER_ROLEcallsrescueWithdrawFromBlocklisted(alice, aliceNew, [P])→withdrawSharesOf[P][aliceNew] = 100,000e6,withdrawSharesOf[P][alice] = 0,isWithdrawClaimed[P][alice] = true(:870) — no assets moved.BLOCKLIST_ROLEun-blocklists Alice (false positive resolved). Still inside periodP - 1.Alice deposits
50,000fXRP and redeems →withdrawSharesOf[P][alice] = 50,000e6,pendingWithdrawAssets = 150,000e6. The flag from step 4 is stilltrue.Two period rolls;
Pbecomes claimable.aliceNewclaims100,000fXRP successfully.Alice calls
claimWithdraw(P)→ revertsAlreadyClaimedPeriod(P)(:794). She receives nothing.Recovery attempt: re-blocklist Alice and call
rescueWithdrawFromBlocklisted(alice, aliceNew, [P])→ revertsAlreadyClaimedPeriod(P)at:864. No recovery path exists.
Impact
Direct, permanent loss of
50,000fXRP 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_ROLEnor any admin function can clearisWithdrawClaimed[P][alice]or movewithdrawSharesOf[P][alice]. There is no setter, and the vault is upgradeable only via a full implementation upgrade.Accounting broken for every other LP:
pendingWithdrawAssetsis permanently inflated by the stranded amount, sototalAssets()(:427-429) is permanently understated by the same amount. In the PoC the vault holds150,000e6raw but reportstotalAssets() = 100,000e6. This silently:depresses the share price for all remaining holders (
_convertToAssetsTotalsdenominators),consumes
depositLimitheadroom that will never be freed (maxDepositat:371-378),shrinks
payout()'scurrentActiveAssetscap (: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 withisWithdrawClaimed == trueand 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?