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

88231 sc high rescuing a pending withdrawal permanently poisons that bucket

Submitted on Aug 12th 2026 at 10:16:26 UTC by @shadowHunter for Audit Comp | Firelight

  • Report ID: #88231

  • 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

Description

rescueWithdrawFromBlocklisted moves a blocklisted account's pending withdrawal to a safe address and marks the source as having claimed:

contracts/core/FirelightVault.sol:845-876

function rescueWithdrawFromBlocklisted(
    address from,
    address to,
    uint256[] calldata periods
)
    external
    onlyRole(RESCUER_ROLE)
    onlyBlocklisted(from)
    notBlocklisted(to)
{
    if (to == address(0)) revert InvalidAddress();

    uint256 len = periods.length;
    if(len == 0 ) revert InvalidArrayLength();

    uint256[] memory rescuedShares = new uint256[](len);
    for (uint256 i = 0; i < len; i++) {
        uint256 _withdrawOf = withdrawSharesOf[periods[i]][from];

        if (isWithdrawClaimed[periods[i]][from]) revert AlreadyClaimedPeriod(periods[i]);   // :864
        if (isWithdrawClaimed[periods[i]][to]) revert AlreadyClaimedPeriod(periods[i]);     // :865
        if (_withdrawOf == 0) revert NoWithdrawalAmount(periods[i]);

        withdrawSharesOf[periods[i]][to] += _withdrawOf;
        withdrawSharesOf[periods[i]][from] = 0;
        isWithdrawClaimed[periods[i]][from] = true;                                          // :870
        ...

There is no restriction that periods[i] has already elapsed. Pending withdrawal buckets are inherently future periods — _requestWithdraw always assigns currentPeriod() + 1:

contracts/core/FirelightVault.sol:1008-1027

So the rescue at :870 sets a terminal "already claimed" marker on a bucket that has not started yet and is still accepting new requests. _requestWithdraw never consults that flag, so a later request lands in the poisoned bucket and burns the user's shares.

The claim path then rejects it forever:

contracts/core/FirelightVault.sol:788-811

isWithdrawClaimed is written in exactly two places — claimWithdraw:806 and the rescue at :870 — and is never cleared. There is no setter, no administrative override, and no delete anywhere in the contract.

Why re-rescuing cannot recover it

The obvious remediation is self-blocking. Re-blocklisting the user and calling the rescue again reverts at :864 because of the flag the first rescue set. rescueSharesFromBlocklisted (:818) is no help either: it moves ERC-20 shares, and the user's shares were already burned at :1025.

Relationship to OpenZeppelin M-02 (this is not that finding)

A published OpenZeppelin audit of an earlier revision reported M-02 "Period Duration Cannot Be Changed", which described the same symptom — shares burned in _requestWithdraw, claimWithdraw then blocked by isWithdrawClaimed. That finding is distinct and was fixed:

OZ M-02
This finding

Who sets the flag

A legitimate claimWithdraw

rescueWithdrawFromBlocklisted at :870

Root cause

Period numbering could regress after a duration change, letting a user re-request into a period they had already claimed

The rescue writes a terminal flag to a not-yet-elapsed bucket that is still open for writes

Status

Fixed — newEpoch >= nextPeriodEnd() at :1093 makes period numbering strictly monotonic

Unfixed

Decisively, this scenario requires no period-number movement at all: the entire setup occurs inside a single period P. The M-02 fix is structurally orthogonal to it.

Further, commit a2def47 ("Fix rescuing withdrawal to a user that already claimed them") added the isWithdrawClaimed[periods[i]][to] guard at :865. The team reviewed this exact function for this exact class and hardened only the to direction, leaving the from side writing a sticky flag to a future bucket.


Steps to reproduce

No attacker. Every actor performs its documented function.

1. Alice requests a withdrawal during period P. The request is booked into bucket P+1, which has not started.

2. Alice is added to the blocklist — for example a screening false positive.

3. RESCUER_ROLE rescues the pending bucket to a safe address. This is the intended use of the function.

4. The screening hit is cleared and Alice is un-blocklisted — still inside period P.

5. Alice withdraws again. currentPeriod() is still P, so _requestWithdraw assigns bucket P+1 again. There is no isWithdrawClaimed check on this path, so the request is accepted and her shares are burned.

6. At period P+2, Alice claims — and cannot.

7. There is no recovery. Re-blocklisting and re-rescuing reverts at :864 on the same flag. The flag has no clearing function anywhere in the contract.


Impact

Permanent freezing of funds, from an ordinary operational sequence.

  • Alice's shares are burned at :1025 and her entitlement is booked in withdrawSharesOf[P+1][alice], but claimWithdraw will reject it forever. She has neither shares nor assets.

  • The assets are not merely unclaimable by Alice — they are unclaimable by anyone. withdrawSharesOf is not transferable, and the corresponding assets remain counted in pendingWithdrawAssets. Because totalAssets() = super.totalAssets() - pendingWithdrawAssets (:427-429), they are permanently excluded from every other depositor's NAV as well. The tokens sit in the vault owned by nobody.

  • Only a proxy upgrade can recover them. No role — including DEFAULT_ADMIN_ROLE — can clear isWithdrawClaimed, and pausing/unpausing has no effect on it.

  • No malice is required. Blocklist → rescue → un-blocklist is exactly the sequence a compliance false positive produces, and Alice re-withdrawing in the same period is normal user behaviour.

  • The outcome is strictly worse than anything the roles can do deliberately: a malicious rescuer can seize funds, but funds seized still exist and are held by a known address. Here they become unowned and unrecoverable by anyone.

The same code shape appears in contracts/legacy/FirelightVaultPredeposits.sol:740-766. That contract is out of scope and no finding is claimed against it; it is mentioned only so the fix is applied in both places.


Recommendation

The simplest correct fix is to delete the flag write, since the balance zeroing already prevents a double claim:

With withdrawSharesOf[period][from] == 0, a subsequent claimWithdraw computes assets == 0 and reverts with NoWithdrawalAmount — the correct terminal state, and one that is not sticky across future requests.

If the flag must be retained, restrict rescues to already-elapsed buckets so a future period can never be pre-marked:

Additionally, consider adding an administrative function to clear isWithdrawClaimed[period][account] when withdrawSharesOf[period][account] != 0, so any account already stranded by a deployed version can be recovered without a proxy upgrade. Apply the same fix to contracts/legacy/FirelightVaultPredeposits.sol.


Proof of Concept

Proof of Concept

Environment

Create hardhat.poc.config.js in the repository root

Save the PoC as test/poc_rescue_bucket_poisoning.js

Run

Output

What the output proves

The test runs against the real FirelightVault behind a transparent proxy, using the repository's own roles. Each assertion pins one link in the chain:

  • bucket > currentPeriod() — the rescued bucket is confirmed to be a future period, which is the precondition the rescue function fails to check.

  • After the rescue, isWithdrawClaimed(bucket, victim) == true while withdrawSharesOf(bucket, victim) == 0 — the terminal flag is set on a period the victim never claimed in.

  • currentPeriod() == P after the un-blocklist — proof that the whole setup happens inside a single period, so no period-number movement is involved and the OZ M-02 fix (monotonic period numbering) is irrelevant here.

  • sharesAfter < sharesBefore — the victim's shares are irreversibly burned by the second withdrawal.

  • withdrawSharesOf(bucket, victim) > 0 and isWithdrawClaimed(bucket, victim) == true simultaneously — the contradictory state at the heart of the bug: a live entitlement in a bucket already marked claimed, because _requestWithdraw at :1017 never checks the flag.

  • claimWithdraw(bucket) reverts AlreadyClaimedPeriod once the window opens — the funds are unreachable.

  • The re-rescue attempt reverts with the same AlreadyClaimedPeriod error, proving the intended remediation path is self-blocking.

  • The closing assertions show pendingWithdrawAssets unchanged and the entitlement still booked — so the assets stay excluded from totalAssets() for every other depositor too, permanently.

Was this helpful?