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:
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_000e6fXRP;pendingWithdrawAssets()still equals10_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 withAlreadyClaimedPeriod(P); anda 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
Assessed source revision:
42f9ea5e43d88b35197b901acc2a8c24b314fa62
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
forgeavailable onPATH
The initial dependency installation requires network access. Running the completed test is local-only.
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.
Interpret the assertions
The test makes each state transition explicit:
Alice's first withdrawal targets future period
P.Compliance blocklists Alice, and the rescuer transfers Alice's entitlement for
Pto Bob. The rescue setsisWithdrawClaimed[P][Alice]totrue.Compliance removes Alice from the blocklist before the current period rolls over.
currentPeriod() + 1therefore deterministically reusesPfor Alice's second withdrawal.isWithdrawClaimed(P, Alice) == truetogether withwithdrawalsOf(P, Alice) == 10_000e6proves that the vault accepted a fresh entitlement into a terminal bucket.pendingWithdrawAssets() == 20_000e6proves that both Bob's rescued entitlement and Alice's new entitlement are accounted as pending.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.
Alice's claim reverts with
AlreadyClaimedPeriod(P). This proves that the stale terminal flag blocks her newly accepted entitlement before any transfer can occur.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.The final assertions require the vault to retain Alice's second 10,000 fXRP, keep exactly 10,000 fXRP in
pendingWithdrawAssets, report zero activetotalAssets, 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?