90497 sc high stale withdrawal claim flag permanently freezes a new same period withdrawal
Submitted on Aug 25th 2026 at 09:59:35 UTC by @RustyLock for Audit Comp | Firelight
Report ID: #90497
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
A blocklisted user whose pending withdrawal is rescued and then unblocked can create a new withdrawal for the same period, but the vault permanently refuses both claim and rescue operations for the new withdrawal due to a stale terminal flag. The user's principal-derived funds remain frozen in the pending-withdrawal bucket with no in-contract recovery path.
Vulnerability Details
FirelightVault implements compliance controls via BLOCKLIST_ROLE and RESCUER_ROLE. When a blocklisted account has a pending withdrawal, rescueWithdrawFromBlocklisted transfers the withdrawal shares to an unblocked recipient and marks the slot as claimed:
// FirelightVault.sol:869-870
withdrawSharesOf[periods[i]][from] = 0;
isWithdrawClaimed[periods[i]][from] = true;The removeFromBlocklist function clears only the blocklist status:
It does not reset isWithdrawClaimed for any period. When the unblocked user deposits and withdraws again, _requestWithdraw targets currentPeriod() + 1. If the current period has not advanced since the rescue, the same period P is targeted again and _requestWithdraw credits the (period, account) slot:
No flag check or reset occurs. The user now has a nonzero withdrawSharesOf[period][account] balance alongside isWithdrawClaimed[period][account] = true.
When the period becomes claimable, claimWithdraw checks the flag before computing assets:
The call reverts with AlreadyClaimedPeriod(period) despite the positive withdrawal balance.
Re-blocklisting the account and invoking rescueWithdrawFromBlocklisted also fails at the same flag check:
An exhaustive search of FirelightVault.sol confirms only two writes to isWithdrawClaimed (both = true in claimWithdraw and rescueWithdrawFromBlocklisted), with no reset, delete, or administrative setter. The frozen withdrawal has no in-contract recovery mechanism.
Impact Details
The affected user's principal-derived withdrawal is permanently inaccessible. The new withdrawal amount remains in pendingWithdrawAssets; when the old rescued recipient successfully claims their unrelated withdrawal, the newly created amount remains retained in the pending-withdrawal accounting, while the affected user receives zero from that new withdrawal. While the user can avoid future losses by targeting later periods, the already-created withdrawal for period P cannot be claimed, rescued, rerouted, or refunded through any public or permissioned vault function.
This maps to the Critical impact "Permanent freezing of funds" under the competition scope. The root cause is a code-level invariant failure in _requestWithdraw, which permits withdrawSharesOf[P][account] > 0 alongside isWithdrawClaimed[P][account] = true, violating the intended protocol constraint that any nonzero withdrawal balance must remain claimable or rescuable.
Scope eligibility note: The blocklist, rescue, and unblock operations act within their explicitly documented authority (BLOCKLIST_ROLE and RESCUER_ROLE). The defect is not privilege overreach or a malicious compliance decision. The report argues eligibility under the scope's "bypass of an intended protocol constraint" exception: the protocol constraint is that a newly created, nonzero withdrawal for a compliant receiver must remain accessible, but the stale flag permanently blocks all recovery paths after a correct role sequence completes. Historical audit finding M-02 addressed period-duration remapping and claim-flag reuse through append-only period configuration; this finding's trigger is the rescue operation's direct write to isWithdrawClaimed[period][from] = true, followed by same-period re-entry after unblocklisting, with no period-duration change involved.
References
Frozen source:
https://github.com/immunefi-team/audit-comp-firelight.git, branchv1_audit_ready, commit42f9ea5e43d88b35197b901acc2a8c24b314fa62Affected contract:
contracts/core/FirelightVault.solDirect blob:https://github.com/immunefi-team/audit-comp-firelight/blob/v1_audit_ready/contracts/core/FirelightVault.solStorage:
contracts/core/FirelightVaultStorage.solDirect blob:https://github.com/immunefi-team/audit-comp-firelight/blob/v1_audit_ready/contracts/core/FirelightVaultStorage.solKey functions:
rescueWithdrawFromBlocklisted(lines 845-876),removeFromBlocklist(lines 560-563),_requestWithdraw(lines 994-1028),claimWithdraw(lines 788-811)Storage mappings:
FirelightVaultStorage.sollines 68 and 71 (withdrawSharesOf,isWithdrawClaimed)
Proof of Concept
Proof of Concept
Add the following as a new test file at test/poc/FirelightVaultWithdrawalRegression.test.js. It uses the existing repository fixture at test/setup/fixtures.js and exercises only the vault's public interface and documented role calls. Run it with:
The equivalent test was executed against the frozen source and passed with 1 passing (2s). It proves that the rescue operation sets the old (period, account) claim flag, a later compliant same-period withdrawal receives a positive balance without clearing that flag, the user's claim and a subsequent rescue both revert with AlreadyClaimedPeriod, and the rescued recipient's original withdrawal remains claimable.
Was this helpful?