89958 sc high rescuing an unmatured withdrawal lets fresh post unblock shares be burned into a permanently closed period
Submitted on Aug 22nd 2026 at 03:41:55 UTC by @Grant4969 for Audit Comp | Firelight
Report ID: #89958
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
rescueWithdrawFromBlocklisted() can rescue a real, non-zero withdrawal assigned to the still-unmatured period P+1. The rescue permanently sets isWithdrawClaimed[P+1][source] = true.
If the source account is then unblocked during the same withdrawal-request window, the public withdraw() and redeem() paths can write fresh withdrawal shares back into the already-closed (P+1, source) key. _requestWithdraw() does not check isWithdrawClaimed[period][receiver]; it accepts the request, increases pendingWithdrawAssets, and burns the user's shares.
Once the period matures, both claimWithdraw() and a second rescue revert with AlreadyClaimedPeriod. The newly requested assets therefore have no reachable withdrawal or rescue path and remain permanently frozen in the Vault.
Why the privileged-role exclusion does not apply
This issue does not require any privileged role to submit false data, approve an invalid claim, exceed a business limit, or act outside its granted authority.
The rescued item is a real, non-zero
P+1pending withdrawal created by the publicwithdraw()flow.rescueWithdrawFromBlocklisted()accepts this request and does not require the target period to have matured.removeFromBlocklist()is an authorized lifecycle operation with no enforced period restriction, cooldown, or rollover requirement.The blocklist, rescue, and unblock transactions all satisfy their existing on-chain validation.
The irreversible loss occurs afterward, when the unblocked account calls the public _requestWithdraw() path. That path burns fresh shares into a (period, receiver) key that the contract has already permanently closed. Therefore, the vulnerable operation is a public entry point bypassing the existing claimed-key write barrier, not a trusted role maliciously freezing funds.
Existing invariant and distinct trigger
The requirement that a claimed account-period must not receive additional withdrawal shares is supported by the project's own development history.
Commit a2def47716389330c0ebc1f2006fca5c588c9469 is titled:
Fix rescuing withdrawal to a user that already claimed them
It added an isWithdrawClaimed check for the rescue destination and the regression test:
reverts if rescuer tries to rescue pending withdrawals to a user who has already claimed that period
This establishes that a claimed (period, account) is a closed state that must not receive additional withdrawSharesOf entries. However, the fix only protects the rescue write path. _requestWithdraw() also increases withdrawSharesOf[period][receiver] but omits the same barrier.
Sentora M-02 previously described a similar terminal state, but through a different trigger: changing the period duration could remap timestamps to a previously claimed historical period. That trigger was fixed through append-only period configurations.
This report does not modify the period configuration. Its independent trigger is:
future-period rescue -> source key closed early -> authorized unblock -> public withdrawal writes into the closed key
Therefore, this is not a reproduction of the period-duration-remapping issue. It is a separate cross-entry-point omission in the current rescue/unblock state machine.
Vulnerability Details
A normal withdrawal requested during period P is assigned to P+1:
The rescue function can move that pending withdrawal before P+1 matures and permanently close the source key:
After the source is unblocked, _requestWithdraw() can still select the same P+1 key:
There is no check for:
Consequently, the transaction succeeds and burns real shares even though the destination account-period is already closed.
When the period becomes claimable, claimWithdraw() checks the stale terminal flag before reading the newly added withdrawal shares:
A second rescue cannot recover the newly added shares because rescueWithdrawFromBlocklisted() performs the same source-side AlreadyClaimedPeriod check.
Failure Sequence
During period
P, a user callswithdraw(amount, user, user), creating a real pending withdrawal forP+1.BLOCKLIST_ROLEadds the account to the blocklist.RESCUER_ROLErescues the non-zeroP+1pending withdrawal to a non-blocklisted recipient.The Vault sets
isWithdrawClaimed[P+1][user] = truewhile the current period is stillP.BLOCKLIST_ROLEremoves the account from the blocklist. No rollover or cooldown is enforced.The user deposits fresh assets and requests a self-receiver withdrawal before period
Pends._requestWithdraw()accepts the request, burns the fresh shares, and writes the entitlement into the already-closed(P+1, user)key.After maturity,
claimWithdraw(P+1)permanently reverts withAlreadyClaimedPeriod.Re-blocklisting the account and attempting another rescue also reverts with
AlreadyClaimedPeriod.
The PoC additionally rescues all remaining old shares before the account is unblocked. The source balance is therefore zero before it deposits 5,000 units of entirely fresh assets. The full fresh deposit is then withdrawn into the closed key and becomes permanently unreachable, excluding any explanation based on old balances or dust.
Impact Details
Funds written into the affected account-period are permanently frozen:
The corresponding user shares have already been burned.
The assets are included in
pendingWithdrawAssetsand excluded from active Vault assets.claimWithdraw()permanently rejects the account-period.A second rescue permanently rejects the same account-period.
There is no public cancellation, claimed-flag reset, or entitlement-migration path.
The amount is not limited to dust. The PoC demonstrates the permanent freezing of an entire 5,000-unit fresh deposit after all old withdrawals and old shares have already been rescued.
The scope is specifically one rescued source x period key:
Using a different, unclaimed receiver during the same period remains safe.
Waiting for one rollover causes a new request to target the next period and remains safe.
However, once funds have been written into the closed key, no existing path can recover them.
This matches the selected in-scope impact: Permanent freezing of funds.
The report does not claim attacker profit, protocol-wide insolvency, or the ability of an arbitrary external account to freeze the entire Vault.
References
Vulnerable request path: https://github.com/immunefi-team/audit-comp-firelight/blob/v1_audit_ready/contracts/core/FirelightVault.sol#L994-L1027
Rescue path: https://github.com/immunefi-team/audit-comp-firelight/blob/v1_audit_ready/contracts/core/FirelightVault.sol#L845-L875
Claim path: https://github.com/immunefi-team/audit-comp-firelight/blob/v1_audit_ready/contracts/core/FirelightVault.sol#L788-L810
Unblock path: https://github.com/immunefi-team/audit-comp-firelight/blob/v1_audit_ready/contracts/core/FirelightVault.sol#L560-L562
Historical claimed-destination fix: https://github.com/immunefi-team/audit-comp-firelight/commit/a2def47716389330c0ebc1f2006fca5c588c9469
Proof of Concept
Proof of Concept
Environment and execution
Check out the competition repository at branch
v1_audit_ready(tested against commit42f9ea5e43d88b35197b901acc2a8c24b314fa62).Install the repository dependencies with
npm install.Save the following test as
test/poc/RescuedAccountPeriodReopen.test.js.Run:
Complete PoC
Expected output
What the PoC proves
The rescued withdrawal is a real, non-zero request for
currentPeriod() + 1; no invalid period or rollover is used.Rescue permanently closes the source key while that key is still the target of public withdrawal requests.
After unblock,
_requestWithdraw()accepts a new request and burns the user's shares into the closed key.claimWithdraw()and a second rescue both reject the newly accrued withdrawal withAlreadyClaimedPeriod.Even after every old share and old withdrawal has been rescued, an entirely fresh 5,000-unit deposit can be frozen in full.
The two boundary tests confirm that the affected state is precisely one rescued
source x periodkey rather than the whole account or Vault.
Was this helpful?