88741 sc high c 1 firelightvault rescuewithdrawfromblocklisted permanently poisons a still open withdrawal bucket freezing a staker s funds with no recovery path
Submitted on Aug 14th 2026 at 19:28:30 UTC by @web3pavlou for Audit Comp | Firelight
Report ID: #88741
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
Severity selected: Critical — Permanent freezing of funds
Asset: contracts/core/FirelightVault.sol
Requires an attacker: No. Every actor in the sequence performs its documented function correctly.
Summary
rescueWithdrawFromBlocklisted uses isWithdrawClaimed[period][from] = true (FirelightVault.sol:870) as a bookkeeping marker meaning "these shares were moved away." But that mapping is not a marker — it is the hard claim gate in claimWithdraw (:794), it is never reset anywhere in the contract, and periods[i] is unbounded, so the still-open bucket currentPeriod() + 1 can be flagged.
A staker rescued out of the open bucket, then un-blocklisted after a false-positive compliance flag, who makes a fresh withdrawal request in the same period lands back in that poisoned bucket. Their shares are burned, their assets are moved into pendingWithdrawAssets, and claimWithdraw rejects them permanently.
The result is worse than a loss to a counterparty: the funds become unreachable by anybody. In the PoC the vault ends holding 600,000 FXRP while reporting totalAssets() == 0 and totalSupply() == 0. The tokens are present, owned by no one, and claimable by no one.
The protocol documents the opposite guarantee:
"Authorized roles can rescue assets from blocklisted accounts and reassign them to a non-blocklisted address, preserving the position without violating compliance constraints." — Firelight docs, Deployments and Withdrawals
Scope: this is not a privileged-actor report
The program excludes reports that require "a privileged role holder to act within the permissions explicitly granted to that role... unless the report demonstrates that the role can exceed its intended permissions or bypass an intended protocol constraint." The project restated the test in its competition Q&A:
"If a trusted role operates strictly within its designated functions, that is expected behavior and not a vulnerability. However, a bug may be valid if the role can bypass its documented boundaries or violate a system invariant without needing to gain additional roles."
This report satisfies that exception on its own terms, and I want to be precise about why:
Nobody acts maliciously or incorrectly. BLOCKLIST_ROLE flags an account, RESCUER_ROLE moves the pending withdrawal, BLOCKLIST_ROLE un-flags on review. Every call is the intended use of the function. There is no adversary in this report — the roles are the mechanism, not the attacker — and the victim is an ordinary staker whose only action is a second withdrawal request.
The documented invariant is violated. The protocol states that the rescue path reassigns a blocklisted account's assets "preserving the position without violating compliance constraints." The PoC shows the position is not preserved: it is destroyed. That is the invariant violation the exception is written for.
No additional roles are needed. The permanent write at FirelightVault.sol:870 lands inside the rescue's ordinary execution. The rescuer cannot avoid it, cannot detect it, and no role in the system can undo it.
The defect is that correct, intended use of a compliance function permanently destroys a user's position and contradicts the protocol's own stated guarantee. A guard that malfunctions when used properly is a code defect, not a privilege question.
Vulnerability details
The flag is a claim gate, not a marker
isWithdrawClaimed appears in exactly five places in the entire contract:
794
claimWithdraw—the hard gate: if (isWithdrawClaimed[period][sender]) revert AlreadyClaimedPeriod(period);
806
claimWithdraw — set after a genuine claim
864
rescueWithdrawFromBlocklisted — guard on from
865
rescueWithdrawFromBlocklisted — guard on to
870
rescueWithdrawFromBlocklisted — set as a "moved away" marker
There is no reset. removeFromBlocklist does not touch it. No admin function does. Line 870 writes a one-way, permanent value into the mapping that line 794 uses to decide whether a user may ever be paid.
periods[i] is unbounded
Nothing constrains periods[i] to a settled bucket. Since _requestWithdraw always credits currentPeriod() + 1, and a rescue must target a bucket that actually holds a balance (_withdrawOf == 0 reverts), the natural target for an in-flight rescue is the open bucket. Flagging it is not an edge case — it is the ordinary path.
The flag is also unnecessary
Zeroing withdrawSharesOf[periods[i]][from] on line 869 already prevents a later claim: claimWithdraw computes assets from that mapping and reverts NoWithdrawalAmount(period) when it is zero. Line 870 protects nothing. It exists only to poison the slot.
Attack path
No attacker. Each step is a legitimate action by the party authorised to take it.
Timing. Steps 3–5 must fall inside one period for the second request to land in the flagged bucket. Under today's 1-day periods that is a same-day cycle; at the 30-day periods the docs commit to for the feature-complete launch it is a month-wide window — and a compliance false positive resolved within a month is entirely ordinary. The PoC runs on the project's own
DEFAULT_CONFIGduration of one week rather than the deployed one day: the requirement is only that the three steps share a period, so the defect is duration-independent and scales with whateverperiodConfigurationDurationis configured.
Impact
Permanent freezing of funds. Alice loses 600,000 FXRP — 60% of her stake — with no recovery path in the contract. Her shares are burned, so she holds no ERC-4626 claim, and her bucket credit is permanently unclaimable.
Critically, the funds are not merely transferred to the wrong party. The final state from the PoC:
token.balanceOf(vault)
600,000,000,000 (600,000 FXRP)
pendingWithdrawAssets()
600,000,000,000
totalAssets()
0
totalSupply()
0
Because totalAssets() is super.totalAssets() - pendingWithdrawAssets, the stranded amount is excluded from the vault's own accounting. Alice cannot claim it (the flag). No other staker can reach it (there are no shares and no other credits). It is orphaned inside the contract.
A second, lesser consequence of the same line: once to claims a period, isWithdrawClaimed[period][to] is true, and line 865 makes that address permanently unusable as a rescue destination for that period.
Severity: permanence
isWithdrawClaimed has no reset anywhere in the contract, and a second rescue of the same account cannot clear it — line 864 reverts AlreadyClaimedPeriod on from. There is no recovery path in the deployed code.
One pre-emption, stated plainly: FirelightVault sits behind a transparent proxy, so in principle an upgrade could add a reset. I do not think that reduces the severity. The funds are unrecoverable by every function the contract exposes; recovery would require authoring, auditing and shipping new logic through governance, with the affected balance stranded meanwhile. If triage nonetheless treats an upgrade as a recovery path, the impact is Temporary freezing of funds for at least 24 hours (High) rather than Critical — but it is one of the two, not less.
I have also considered and rejected Griefing: griefing implies a deliberate actor accepting a cost to damage someone. Nobody chooses this outcome and nobody gains from it — the funds are destroyed, not captured.
Prior coverage
Macro A-1's TMAAR describes the rescue power ("can blocklist any account and move its shares and pending withdrawals to an address of choice… a targeted confiscation power") but does not identify this defect. It appears in none of the three Known Issues, none of the design notes on the program's scope page, and none of the 46 disclosed reports from the November 2025 competition. The single rescue-adjacent item there was Insight #59288 — "Repeated Array Access in rescueWithdrawFromBlocklisted Loop Causes Unnecessary Gas Consumption." Someone read this exact loop and saw only the array indexing.
Recommended mitigation
Option A — remove the redundant flag (preferred, one line)
Delete line 870:
Zeroing the credit on the preceding line already causes any later claimWithdraw by from to revert NoWithdrawalAmount, so the flag protects nothing. Removing it restores the documented behaviour: a rescued staker who is later un-blocklisted can withdraw normally. The guards on lines 864–865 should remain — they correctly prevent rescuing a bucket that was genuinely claimed.
Option B — bound the rescue to settled buckets
If the flag is retained for auditability, constrain the target so an open bucket can never be poisoned:
This mirrors the constraint claimWithdraw already applies at line 791 and makes the two functions consistent about which buckets are final.
References
contracts/core/FirelightVault.sol:845-876—rescueWithdrawFromBlocklistedcontracts/core/FirelightVault.sol:870— the permanent flag write (root cause)contracts/core/FirelightVault.sol:869— the credit zeroing that already prevents a claimcontracts/core/FirelightVault.sol:788-810—claimWithdraw; gate at:794contracts/core/FirelightVault.sol:428—totalAssets()excludespendingWithdrawAssetscontracts/core/FirelightVault.sol:169—error AlreadyClaimedPeriod(uint256 period)Firelight docs, Deployments and Withdrawals — "preserving the position"
Proof of Concept
Foundry. Uses the shared Base.t.sol fixture, a Solidity port of the project's own test/setup/fixtures.js. This PoC calls only deployVault(); the allocator half of the fixture is unused here and included solely so the file matches the one used across my other submissions.
test/Foundry/Base.t.sol
test/Foundry/RescueClaimFlag.t.sol
Output
The three runs isolate the defect precisely:
Control A — the same split withdrawal with no rescue claims the full 1,000,000. The two-request pattern is not the problem.
Control B — the rescue happens, but Alice waits one period; her request lands in a different bucket and she recovers 600,000 in full. The bucket collision is the problem.
The finding — same rescue, same-period second request: the treasury's rescued portion claims correctly, Alice's reverts forever, and 600,000 FXRP is left in a vault that reports holding nothing.
Was this helpful?