88254 sc high rescuewithdrawfromblocklisted permanently poisons the period account claim flag causing unrecoverable freezing of any later withdrawal the account makes into the same still
Submitted on Aug 12th 2026 at 12:36:17 UTC by @Oxb4b for Audit Comp | Firelight
Report ID: #88254
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 uses a single boolean, isWithdrawClaimed[period][account], both to mark a withdrawal bucket as claimed by claimWithdraw and to mark a rescued balance as "consumed" by rescueWithdrawFromBlocklisted. The flag is never cleared once set. Because rescueWithdrawFromBlocklisted is designed to act on the currently open bucket (currentPeriod() + 1), and blocklisting is fully reversible, a legitimate compliance workflow — blocklist → rescue → un-blocklist, all within one period — leaves the flag permanently true for the account on that period. Any withdrawal the account subsequently requests in the same still-open period is credited to that poisoned bucket and becomes permanently unclaimable, with no recovery path on-chain, including via a second rescue. The underlying assets remain physically in the vault but are permanently excluded from totalAssets() through pendingWithdrawAssets, harming the victim and diluting backing for every other staker.
Vulnerability Details
Affected contract/functions: FirelightVault.sol — rescueWithdrawFromBlocklisted(), _requestWithdraw(), claimWithdraw(), removeFromBlocklist().
Root cause:
isWithdrawClaimed[period][account] has exactly two writers, and no function ever resets it to false:
claimWithdraw(period)setsisWithdrawClaimed[period][sender] = trueafter paying out (line 806).rescueWithdrawFromBlocklisted(from, to, periods)setsisWithdrawClaimed[periods[i]][from] = true(line 870) after movingwithdrawSharesOf[p][from]towithdrawSharesOf[p][to]and zeroing the source.
Both writers treat the flag as if it permanently describes the (period, account) pair. In reality it is meant to describe a specific balance at a specific moment — whether that balance has been consumed. The two concepts diverge because:
_requestWithdrawalways credits the currently open bucket,period = currentPeriod() + 1(line 1008). This bucket is not yet claimable (claimWithdrawrequiresperiod < currentPeriod(), line 791), so it can still receive new deposits for the remainder of the current period.addToBlocklist/removeFromBlocklist(lines 550–563) are fully reversible and carry no time lock, so an account can return to good standing while the just-rescued bucket is still open._requestWithdrawnever readsisWithdrawClaimedbefore creditingwithdrawSharesOf[period][receiver]— it has no way of knowing the bucket was already flagged for that account.
Once the flag is true for (p, account), there is no path to reverse it:
claimWithdrawreverts immediately on the flag check (line 794), regardless of the account's actual non-zero balance inwithdrawSharesOf[p][account].rescueWithdrawFromBlocklistedguards on the same flag (if (isWithdrawClaimed[periods[i]][from]) revert AlreadyClaimedPeriod(periods[i]);, line 864), so re-blocklisting the victim to attempt a second rescue also reverts.No admin sweep or alternate write path to
withdrawSharesOforisWithdrawClaimedexists.
The loss is compounded on the accounting side: the second withdrawal burns the victim's shares (_update(owner, address(0), shares), line 1025) and increments pendingWithdrawAssets += assets (line 1019), but that increment is never reversed since the claim can never succeed. Since totalAssets() = super.totalAssets() - pendingWithdrawAssets (line 428), the corresponding tokens stay in the vault's ERC-20 balance but are permanently excluded from share value for every remaining staker, not just the victim.
Impact and Likelihood Details
Impact: Critical — Permanent freezing of funds (per scope.md's in-scope Critical category).
Individual user: shares are burned with no corresponding claimable asset entry; the withdrawal is unrecoverable through any documented function.
Protocol-wide:
pendingWithdrawAssetsis permanently inflated by the frozen amount, sototalAssets()under-reports real backing forever. This dilutes share value/redemption rate for all other stakers and corrupts any downstream logic relying ontotalAssets()(e.g. capital-adequacy or exposure calculations).No on-chain remediation exists; recovery requires a contract upgrade.
Likelihood: Medium
Requires a blocklist → rescue → un-blocklist sequence to complete within a single period, followed by a new withdrawal from the same account before the period rolls over.
This is not a contrived attacker sequence — it is the natural shape of a routine compliance workflow. Sanctions/KYC screening hits are blocklisted, rescued to protect the position, then cleared once verified as a false positive, and
removeFromBlocklistexists specifically to support that reversal. The contract gives operators no indication that the account is now poisoned for the remainder of that period.The exposure window is up to one full period (e.g. 24h at
SMALLEST_PERIOD_DURATION), which is a realistic amount of time for a compliance review-and-clear cycle to complete, and for the affected user to attempt a normal withdrawal in the interim.
In-scope justification: All privileged actions in the attack path (BLOCKLIST_ROLE's addToBlocklist/removeFromBlocklist, RESCUER_ROLE's rescueWithdrawFromBlocklisted) are used entirely within their documented authority. The loss is triggered by an unprivileged victim performing the single most ordinary action available — a normal withdraw() call. Per scope.md, reports requiring privileged roles to act within granted permissions are out of scope unless the report demonstrates the role can exceed its intended authority or bypass an intended protocol constraint. This report demonstrates the latter: the intended constraint that a burned withdrawal request always becomes claimable after its delay is bypassed as a side effect of correct rescue usage. This is a core state-machine defect, not a governance/trusted-role-misuse report.
References
FirelightVault.sol::rescueWithdrawFromBlocklisted()— lines 845–876, flag write at line 870, guards at lines 864–865.FirelightVault.sol::_requestWithdraw()— bucket credit at lines 1008–1019.FirelightVault.sol::claimWithdraw()— claim-flag guard at line 794, period guard at line 791.FirelightVault.sol::removeFromBlocklist()/addToBlocklist()— lines 550–563.FirelightVault.sol::totalAssets()— line 428 (super.totalAssets() - pendingWithdrawAssets).scope.md— "Critical: Permanent freezing of funds"; privileged-actor scope carve-out ("...unless the report demonstrates that the role can exceed its intended permissions or bypass an intended protocol constraint").Proof of Concept below (companion regression test appended to
rescue.jssuite).
Proof of Concept
Proof of Concept (clear scenarios)
Scenario walkthrough, with C = currentPeriod() and the open bucket p = C + 1:
Setup. Alice deposits into the vault and calls
withdraw(FIRST_WITHDRAW_AMOUNT, alice, alice)while inside periodC. This creditswithdrawSharesOf[p][alice] = FIRST_WITHDRAW_AMOUNT-equivalent, setswithdrawAssets[p] += FIRST_WITHDRAW_AMOUNT,pendingWithdrawAssets += FIRST_WITHDRAW_AMOUNT, and burns her shares.isWithdrawClaimed[p][alice]isfalse.Compliance action.
BLOCKLIST_ROLEcallsaddToBlocklist(alice)— a legitimate, documented compliance action.Rescue.
RESCUER_ROLEcallsrescueWithdrawFromBlocklisted(alice, treasury, [p]). Result:withdrawSharesOf[p][alice] = 0,withdrawSharesOf[p][treasury] += FIRST_WITHDRAW_AMOUNT-equivalent, andisWithdrawClaimed[p][alice] = true.Resolution. The screening hit is cleared.
BLOCKLIST_ROLEcallsremoveFromBlocklist(alice). All actions so far are within documented role authority.Ordinary trigger. Still inside period
C, Alice deposits again and callswithdraw(SECOND_WITHDRAW_AMOUNT, alice, alice)._requestWithdrawcomputesperiod = currentPeriod() + 1 = p— the same bucket. It creditswithdrawSharesOf[p][alice] = SECOND_WITHDRAW_AMOUNT-equivalent, adds towithdrawAssets[p]andpendingWithdrawAssets, and burns Alice's shares. No check consultsisWithdrawClaimed.Loss realized. Once period
pbecomes claimable (currentPeriod() > p), Alice callsclaimWithdraw(p). The function revertsAlreadyClaimedPeriod(p)becauseisWithdrawClaimed[p][alice]is stilltruefrom step 3, despite her non-zero, never-claimed balance from step 5.Recovery proven impossible. Operators re-blocklist Alice and attempt
rescueWithdrawFromBlocklisted(alice, treasury, [p])to move the stranded balance out. This also reverts withAlreadyClaimedPeriod(p), since the rescue guard reads the same permanently-set flag.withdrawSharesOf[p][alice]remains stuck forever, andpendingWithdrawAssetsis never decremented for that amount.
This exact sequence is implemented and verified end-to-end in the accompanying Hardhat test provided below (run against the rescue.js fixtures), which asserts at each step:
isWithdrawClaimed(p, alice) == falseimmediately after the first withdrawal (step 1).withdrawSharesOf(p, alice) == 0andisWithdrawClaimed(p, alice) == trueimmediately after rescue (step 3).withdrawSharesOf(p, alice) == SECOND_WITHDRAW_AMOUNTafter the second withdrawal, with the flag stilltrue(step 5).claimWithdraw(p)reverts withAlreadyClaimedPeriod(p)once claimable, andpendingWithdrawAssetsis unchanged by the failed claim (step 6).A second
rescueWithdrawFromBlocklistedattempt also reverts withAlreadyClaimedPeriod(p), and Alice's balance remains stranded (step 7).
Every step uses only public, documented entry points (deposit, withdraw, addToBlocklist, removeFromBlocklist, rescueWithdrawFromBlocklisted, claimWithdraw) with roles used exactly as intended — no privilege escalation, no reentrancy, no unusual timing beyond staying within a single period.
POC Testcase
add the below testcase to the existing test file test/rescue.js and run
Was this helpful?