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:
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
:1025and her entitlement is booked inwithdrawSharesOf[P+1][alice], butclaimWithdrawwill reject it forever. She has neither shares nor assets.The assets are not merely unclaimable by Alice — they are unclaimable by anyone.
withdrawSharesOfis not transferable, and the corresponding assets remain counted inpendingWithdrawAssets. BecausetotalAssets() = 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 clearisWithdrawClaimed, 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) == truewhilewithdrawSharesOf(bucket, victim) == 0— the terminal flag is set on a period the victim never claimed in.currentPeriod() == Pafter 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) > 0andisWithdrawClaimed(bucket, victim) == truesimultaneously — the contradictory state at the heart of the bug: a live entitlement in a bucket already marked claimed, because_requestWithdrawat:1017never checks the flag.claimWithdraw(bucket)revertsAlreadyClaimedPeriodonce the window opens — the funds are unreachable.The re-rescue attempt reverts with the same
AlreadyClaimedPerioderror, proving the intended remediation path is self-blocking.The closing assertions show
pendingWithdrawAssetsunchanged and the entitlement still booked — so the assets stay excluded fromtotalAssets()for every other depositor too, permanently.
Was this helpful?