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

90415 sc high rescuewithdrawfromblocklisted marks the period claimed so a later self withdraw cannot be claimed

Submitted on Aug 24th 2026 at 21:48:19 UTC by @Dliteofficial for Audit Comp | Firelight

  • Report ID: #90415

  • 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

Summary

rescueWithdrawFromBlocklisted() sets isWithdrawClaimed[period][from] and moves the pending bucket. _requestWithdraw() never reads that flag. After a routine unblock, a self-withdraw() into the same period burns shares that claimWithdraw() will never release.

Finding description

claimWithdraw() keys eligibility on msg.sender and a per-period claimed flag.

address sender = _msgSender();
if (isWithdrawClaimed[period][sender]) revert AlreadyClaimedPeriod(period);
// ...
isWithdrawClaimed[period][sender] = true;
IERC20(asset()).safeTransfer(sender, assets);

rescueWithdrawFromBlocklisted() moves withdrawSharesOf from the blocklisted account to custody and then marks from claimed. Rescue itself also reverts if from or to is already claimed. The first pending bucket is the one that is meant to move.

A later request still writes a new bucket with no claimed check. _requestWithdraw() always books into currentPeriod() + 1.

If receiver is the same address that rescue just marked claimed, the new shares sit under a key that can never pass claimWithdraw(). That is the default ERC-4626 call withdraw(assets, me, me).

The path is the documented compliance flow. A user deposits and requests a withdrawal to self for period P+1. BLOCKLIST_ROLE lists them. RESCUER_ROLE rescues that pending bucket to custody. The investigation clears and they are unblocked while still in period P. They still hold live shares, because rescue does not take those. They withdraw the remainder to self. The request succeeds and burns the shares. After P+1 ends, their claimWithdraw(P+1) reverts AlreadyClaimedPeriod. Custody claims only the rescued first bucket. The second amount stays in pendingWithdrawAssets and is excluded from totalAssets(). There is no claim path for that self-booking.

The freeze is same-window. If they wait until the next period, the new request targets P+2 and the old flag does not apply. A second withdraw with receiver != from is claimable by that other address. That choice must be made at request time. claimWithdraw() has no receiver argument, so a self-booking already on chain cannot be repaired. That is not the default UI.

This is not the rescuer stealing funds. Rescue of the first bucket works as specified. The missing bound is at request. A period already marked claimed for an address still accepts a new booking for that address.

Impact Explanation

Critical. The second booking is permanently unclaimable for the default self-receiver. Shares are burned. FXRP remains in the vault but is excluded from totalAssets() via pendingWithdrawAssets. Remaining stakers see a depressed vault. In the fixture the user deposits 6000 FXRP, 2000 is rescued and claimed by custody, and the remaining 4000 is stranded. totalAssets() is then 0 while the vault still holds those 4000 FXRP. Emergency re-rescue is not a repair for a self-booking already written under a claimed key.

Likelihood Explanation

High. Blocklist, rescue, and unblock are the intended compliance path, not an extra-role attack. Unblock often lands in the same period as the original request. The vault's public withdraw() books to self. A user who withdraws leftover shares the way ERC-4626 is normally used hits the freeze. BLOCKLIST_ROLE and RESCUER_ROLE stay inside their documented jobs. The unprivileged withdraw() / claimWithdraw() pair is what fails after those roles acted as designed.

Proof of Concept

Proof of Concept

The test is test/rescue.js. It does the following.

  1. User A deposits 6000 FXRP.

  2. A withdraws 2000 to self for period P+1.

  3. A is blocklisted. Rescue moves that 2000 pending bucket to B and sets isWithdrawClaimed[P+1][A].

  4. A is unblocked in the same period. A still holds 4000 live shares.

  5. A withdraws the remaining 4000 to self. The request succeeds and burns the shares.

  6. After P+1 ends, A's claimWithdraw(P+1) reverts AlreadyClaimedPeriod.

  7. B claims only the rescued 2000. pendingWithdrawAssets stays 4000. The vault still holds that FXRP. totalAssets() is 0.

add to test/rescue.js

Was this helpful?