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

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:

Line
Use

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.

1

Period P — Alice requests a partial withdrawal

Alice deposits 1,000,000 FXRP and later requests withdrawal of 400,000. The credit lands in the open bucket P+1.

2

Period P — Compliance flags Alice

BLOCKLIST_ROLE calls addToBlocklist(alice).

(Legitimate.)

3

Period P — Rescue moves the pending withdrawal

RESCUER_ROLE calls rescueWithdrawFromBlocklisted(alice, treasury, [P+1]). Alice's credit moves to the treasury and isWithdrawClaimed[P+1][alice] is set to true.

(Legitimate — this is precisely what the function is for.)

4

Period P — Compliance removes the false-positive flag

BLOCKLIST_ROLE calls removeFromBlocklist(alice).

(Legitimate.)

5

Period P — Alice withdraws the remaining position

Alice withdraws the remainder of her position — 600,000 FXRP. Her request is credited to bucket P+1 again, and her shares are burned.

6

Period P+2 — Treasury claims, Alice is permanently rejected

The treasury claims its rescued 400,000 successfully. Alice's claimWithdraw(P+1) reverts AlreadyClaimedPeriod(P+1), and will revert forever.

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_CONFIG duration 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 whatever periodConfigurationDuration is 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:

Quantity
Value

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.

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 — rescueWithdrawFromBlocklisted

  • contracts/core/FirelightVault.sol:870 — the permanent flag write (root cause)

  • contracts/core/FirelightVault.sol:869 — the credit zeroing that already prevents a claim

  • contracts/core/FirelightVault.sol:788-810 — claimWithdraw; gate at :794

  • contracts/core/FirelightVault.sol:428 — totalAssets() excludes pendingWithdrawAssets

  • contracts/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?