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

88725 sc high rescued withdrawal bucket reuse permanently freezes newly withdrawn funds

#88725 [SC-High] Rescued withdrawal bucket reuse permanently freezes newly withdrawn funds

Submitted on Aug 14th 2026 at 17:45:54 UTC by @v_c0d35 for Audit Comp | Firelight

  • Report ID: #88725

  • 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

In the public v1_audit_ready source at commit 42f9ea5e43d88b35197b901acc2a8c24b314fa62, FirelightVault permanently marks Alice's period bucket claimed during rescue but later accepts and accounts for a new withdrawal into that same bucket. Once Alice is permitted to use the vault again, every withdrawal the vault accepts and burns shares for must remain claimable or rescuable; instead, Alice's mature claim and a later authorized rescue both reject the terminal marker, permanently freezing the newly accepted withdrawal.

Vulnerability Details

The critical invariant is that a terminal claimed flag must never coexist with a newly accepted entitlement under the same (period, receiver) key. Blocklisting an account, rescuing its pending withdrawal to a beneficiary, and later removing the account from the blocklist are supported compliance operations. When those operations complete before the current period rolls over, currentPeriod() + 1 deterministically reuses the same future withdrawal bucket.

The reproducible sequence follows the contract's ordinary role boundaries:

1

Alice requests a withdrawal

Alice deposits 10,000 six-decimal fXRP and requests a withdrawal to herself for future period P.

2

Compliance blocklists Alice

Compliance blocklists Alice.

3

An authorized rescuer moves Alice's pending withdrawal

The authorized rescuer moves Alice's pending withdrawal for P to non-blocklisted Bob. The rescue sets isWithdrawClaimed[P][Alice] = true.

4

Compliance removes Alice from the blocklist

Compliance removes Alice from the blocklist before the current period changes.

5

Alice requests a new withdrawal

Alice deposits another 10,000 fXRP and requests a new withdrawal to herself. Since currentPeriod() + 1 still equals P, the vault appends the new entitlement to Alice's already-claimed bucket and burns her shares.

6

Claims and rescue both fail

After P matures, Bob successfully claims the original rescued withdrawal. Alice's claim reverts with AlreadyClaimedPeriod(P).

Compliance blocklists Alice again, but an authorized re-rescue also reverts with AlreadyClaimedPeriod(P).

The sequence requires compliance to remove Alice from the blocklist before the current period rolls over, so currentPeriod() + 1 still selects the period marked claimed during rescue. The attached E2E reproduction performs those supported operations in that order and verifies both failed release paths.

First, rescueWithdrawFromBlocklisted transfers all withdrawal shares for the selected period to Bob, zeroes Alice's shares, and marks Alice's bucket claimed:

removeFromBlocklist restores Alice's ordinary access by changing only isBlocklisted[Alice]. It neither clears nor rotates Alice's withdrawal-bucket state. The repository's original blocklist test also verifies that an account can resume ordinary vault use after removal from the blocklist.

Alice's next call to withdraw passes the blocklist checks. The internal _requestWithdraw then selects currentPeriod() + 1, appends a fresh entitlement, raises the pending liability, and burns Alice's shares without checking isWithdrawClaimed[period][receiver]:

Because the period has not changed since rescue, this append targets the exact (P, Alice) key whose terminal flag is already true.

Once P matures, claimWithdraw checks that flag before calculating Alice's amount or transferring assets:

The rescue function performs the same terminal-flag check at lines 862–866 before moving withdrawal shares. Re-blocklisting Alice therefore cannot recover the second entitlement through another rescue.

The attached immunefi-poc.md supplies a complete local-EVM reproduction, including the exact checkout, dependency revision, complete Forge configuration, complete Solidity test, execution command, expected result, and assertion-by-assertion interpretation. The verified command completes with one passing test and zero failures.

The minimal remediation is to reject a withdrawal request before any accounting mutation or share burn when isWithdrawClaimed[currentPeriod() + 1][receiver] is already true. A generation-specific entitlement identifier would also solve the lifetime mismatch while permitting deliberate reuse of the same receiver-period pair.

Impact Details

The E2E test freezes exactly 10,000 fXRP represented with six decimals (10_000e6) after Alice's second deposit and withdrawal. After Bob claims the rescued first withdrawal, the final state requires all of the following:

  • the vault still holds 10_000e6 fXRP;

  • pendingWithdrawAssets() still equals 10_000e6;

  • totalAssets() equals zero because the contract classifies the retained tokens entirely as Alice's pending withdrawal liability;

  • Alice holds zero fXRP from the second withdrawal;

  • Alice's mature claimWithdraw(P) reverts with AlreadyClaimedPeriod(P); and

  • a role-authorized rescue to a fresh recovery beneficiary also reverts with AlreadyClaimedPeriod(P).

These conditions prove permanent freezing of Alice's 10,000 fXRP under the current contract paths. Maturity cannot clear the terminal marker, removing Alice from the blocklist changes only her blocklist status, and adding Alice back to the blocklist leaves rescue blocked by the same marker. The contract contains no function that rotates this (period, receiver) key or reopens its claim state for the newly appended entitlement.

The frozen amount scales with the otherwise-valid second withdrawal and remains bounded by Alice's available vault shares and the vault's ordinary deposit and withdrawal constraints. The demonstrated value is 10,000 fXRP. Whenever the supported compliance lifecycle completes before rollover, the second withdrawal deterministically targets the rescued bucket; once accepted there, the entitlement remains permanently inaccessible.

References

Proof of Concept

End-to-end reproduction: reopening a rescued receiver permanently freezes a new withdrawal

What this demonstrates

FirelightVault stores withdrawal entitlements by (period, receiver) and stores a single terminal isWithdrawClaimed flag for the same key. Blocklisting Alice, rescuing her pending withdrawal to Bob, and later removing Alice from the blocklist are supported compliance operations. When this lifecycle completes before the current period rolls over, currentPeriod() + 1 deterministically selects the same future bucket: rescue has left Alice's flag set to true, while the public withdrawal path accepts a new entitlement into that bucket.

Once Alice is permitted to use the vault again, every withdrawal the vault accepts and burns shares for must remain claimable or rescuable; a terminal claimed flag must never coexist with a newly accepted entitlement. Once the period matures, claimWithdraw rejects the terminal flag, and re-blocklisting Alice leaves rescueWithdrawFromBlocklisted rejecting the same flag before moving the new entitlement. The test demonstrates that Alice's second 10,000 fXRP remains held by the vault with no working claim or rescue path.

Prerequisites

Install the following tools before starting:

  • Git

  • Node.js and npm

  • Foundry with forge available on PATH

The initial dependency installation requires network access. Running the completed test is local-only.

1

Obtain the assessed source

Run:

The final command must print:

2

Install dependencies

From the repository root, run:

npm install also applies the repository's OpenZeppelin compatibility patch through its postinstall script.

3

Configure Forge

Save the following file as foundry.toml in the repository root:

Confirm that the assessed contracts compile before adding the test:

The command must finish with Compiler run successful!.

4

Add the reproduction test

Save the following file as test/RescuedWithdrawalBucketReopen.t.sol:

5

Run the proof of concept

From the repository root, run:

The test should pass. Gas and timing values can vary by local Foundry version, but the important output is the named test and the 1 passed; 0 failed result:

This reproduction was locally verified with Node.js 24.13.0, npm 11.6.2, Forge 1.5.1-stable, and the Solidity 0.8.28 compiler selected by Forge. The observed run compiled successfully and reported one passing test with zero failures.

6

Interpret the assertions

The test makes each state transition explicit:

  1. Alice's first withdrawal targets future period P.

  2. Compliance blocklists Alice, and the rescuer transfers Alice's entitlement for P to Bob. The rescue sets isWithdrawClaimed[P][Alice] to true.

  3. Compliance removes Alice from the blocklist before the current period rolls over. currentPeriod() + 1 therefore deterministically reuses P for Alice's second withdrawal.

  4. isWithdrawClaimed(P, Alice) == true together with withdrawalsOf(P, Alice) == 10_000e6 proves that the vault accepted a fresh entitlement into a terminal bucket. pendingWithdrawAssets() == 20_000e6 proves that both Bob's rescued entitlement and Alice's new entitlement are accounted as pending.

  5. Bob successfully claims 10,000 fXRP after the period matures. This is a positive control proving that the period is mature and the ordinary claim path works for the rescued entitlement.

  6. Alice's claim reverts with AlreadyClaimedPeriod(P). This proves that the stale terminal flag blocks her newly accepted entitlement before any transfer can occur.

  7. After compliance blocklists Alice again, the authorized rescuer's recovery attempt also reverts with AlreadyClaimedPeriod(P). This proves that the administrative recovery path cannot release the new entitlement either.

  8. The final assertions require the vault to retain Alice's second 10,000 fXRP, keep exactly 10,000 fXRP in pendingWithdrawAssets, report zero active totalAssets, and transfer nothing to Alice or the recovery beneficiary. Together with both failed release paths, these assertions prove that the vault accepted and burned shares for an entitlement that can be neither claimed nor rescued.

No cleanup is required because Forge discards the local EVM after the test.

Was this helpful?