88705 sc high rescued account s later same period withdrawal is accepted and burned behind a terminal claim flag permanently freezing user funds
Submitted on Aug 14th 2026 at 16:17:59 UTC by @Jornason for Audit Comp | Firelight
Report ID: #88705
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
FirelightVault.rescueWithdrawFromBlocklisted permanently sets isWithdrawClaimed[period][from] = true when it moves a blocklisted account's pending withdrawal to a recovery address. If the account is later removed from the blocklist before the period ends, the restored staker can call the public withdraw or redeem entrypoints for that same period: the vault accepts the request, burns the staker's real shares, and books a new withdrawal liability — but the already-true terminal flag makes the new right impossible to claim, and the same flag also blocks a second rescue. The staker's funds backing the second request are permanently frozen in the vault with no on-chain recovery path.
Vulnerability Details
Affected Asset
Program: Audit Comp | Firelight
Asset:
contracts/core/FirelightVault.sol(with state incontracts/core/FirelightVaultStorage.sol, both in scope)Repo: https://github.com/immunefi-team/audit-comp-firelight
Branch/commit:
v1_audit_ready@42f9ea5e43d88b35197b901acc2a8c24b314fa62Affected functions/state:
rescueWithdrawFromBlocklisted(lines 845-876),removeFromBlocklist(560-563),withdraw/redeem(717-778),_requestWithdraw(994-1028),claimWithdraw(788-811),FirelightVaultStorage.isWithdrawClaimed(line 71)
Root Cause
The rescue path uses the same boolean as a permanent terminal marker for the source account:
Removing the account from the blocklist only clears isBlocklisted:
After removal, both public entrypoints reach _requestWithdraw, which creates a new right for currentPeriod() + 1, books the liability, and burns the owner's shares — without checking or resetting isWithdrawClaimed[period][receiver]:
The newly created right is born behind a terminal guard shared by both the claim path and the rescue retry path:
Exploit Flow
Submit a second withdrawal
The restored staker — an ordinary unprivileged user — calls withdraw(1_000_000, staker, staker) (or redeem). The call succeeds: it burns the staker's remaining shares, creates 1,000,000 new withdrawal shares for P+1, and increases pendingWithdrawAssets by 1,000,000. The claimed flag remains true.
Attempt a second rescue
Re-blocklisting the account and retrying rescueWithdrawFromBlocklisted also reverts with AlreadyClaimedPeriod.
Final state for the new right: staker vault shares 0; withdrawal shares for P+1 1,000,000; vault balance attributable to the right 1,000,000; claimed flag true; claim path unreachable; rescue path unreachable.
Triggerability
The value-destroying step is a public, unprivileged call by an ordinary restored staker through the documented delayed-withdrawal flow. The privileged calls (blocklist, rescue, unblock) are benign compliance lifecycle operations explicitly supported by the contract; no privileged actor must behave maliciously, choose false data, exceed a role, or collude with the staker. The missing protocol constraint — "a right whose terminal flag is already set must not accept new liability" — is enforced nowhere: _requestWithdraw accepts and burns behind the flag, and neither the claim path nor the rescue path can ever release the funds. No published rule warns that a restored account must avoid an otherwise accepted request until the period rolls over.
Why Existing Checks Do Not Prevent It
The
notBlocklistedchecks onwithdraw/redeempass because the account was legitimately restored._requestWithdrawhas no check onisWithdrawClaimed[period][receiver]at all, so the new liability is booked and the shares are burned.claimWithdrawreverts on the already-true flag — it protects against double-claiming but also permanently bricks the new right.The rescue retry contains the same
AlreadyClaimedPeriodguard, so the protocol's own compliance recovery path cannot reach the stranded right either.
Impact Details
Selected impact:
Impact:
Affected funds/assets: the vault's staked collateral (FXRP on Flare) belonging to restored stakers.
Per occurrence: the full amount of the second accepted request, bounded only by the staker's remaining balance. In the executable PoC, 1,000,000 base units are permanently stranded with the user's share balance at zero; both the
withdrawandredeemvariants reproduce.Cumulative exposure: repeatable for every restored account that re-enters the withdrawal flow in the same period; the vault this code upgrades held approximately 60.16M FXRP of predeposit capital on Flare mainnet at the time of the previous audit (July 2026).
Attacker cost / preconditions: no attacker is required; the loss arises from the ordinary compliance lifecycle (blocklist → rescue → restore) followed by an ordinary user action.
Recovery status: none on-chain. Both delivery (
claimWithdraw) and the protocol's own recovery path (rescueWithdrawFromBlocklisted) permanently revert. Waiting additional periods does not change the stored right or the terminal flag. Only a contract upgrade or off-chain reimbursement could recover the funds, and the program states that emergency actions are not an automatic reason to downgrade an otherwise valid report.
Why this is not merely theoretical:
The executable PoC (attached) demonstrates the full sequence against the in-scope contracts at the audit commit, using the repository's normal upgradeable vault fixture and a standard zero-fee token: no storage injection, malicious token, callback, or unusual configuration.
A control test proves two ordinary same-period withdrawals aggregate and pay out in full when no rescue poisons the flag, isolating the defect to the rescue terminal-flag interaction.
Distinction from Known Issues
The Macro report describes
BLOCKLIST_ROLE+RESCUER_ROLEas a standard compliance rescue and targeted-confiscation capability — that is a description of intended privilege, not this state-machine bug. No Macro finding identifies the persistentisWithdrawClaimedflag on the rescued source account, the restore-then-re-request sequence, the accept-and-burn behind a terminal flag, or the simultaneous failure of both claim and re-rescue.This is distinct from the withdrawal-rounding family (Macro L-1): the entire second right is lost with exact equal integer amounts and a standard token; no rounding, price, or transfer-fee behavior participates.
The privileged-actor exclusion does not apply: the roles stay within their documented compliance powers, and the bypass is performed by the public withdrawal surface accepting a liability that the protocol's own terminal flag has already made unreachable.
Recommendation
At minimum, prevent _requestWithdraw from accepting a right that is already terminal for its receiver, before any accounting update or share burn:
This safely forces the restored account to wait until its next request maps to a different period. Alternatively, redesign rescue state so moving an old right does not permanently pre-consume a future right for the same account and period; if the claimed flag is cleared or separated from rescue state, ensure the moved right cannot be claimed twice.
Add regression tests for both withdraw and redeem covering: request → blocklist → rescue → unblock → second same-period request; claim and rescue reachability after maturity; and ordinary same-period aggregation without rescue.
References
Official scope: https://immunefi.com/audit-competition/audit-comp-firelight-1/scope/
Affected source (audit commit
42f9ea5e43d88b35197b901acc2a8c24b314fa62):contracts/core/FirelightVault.sol—rescueWithdrawFromBlocklisted,removeFromBlocklist,withdraw/redeem,_requestWithdraw,claimWithdrawcontracts/core/FirelightVaultStorage.sol—isWithdrawClaimedterminal flag
PoC package and raw runtime output: attached in the Proof of Concept field
Link to Proof of Concept
https://gist.github.com/Jornason/84658b4b8a3e66419c837357dc7ee2c8
Proof of Concept
Summary
This is an executable runtime PoC, not static analysis. It runs on the project's own Hardhat setup against the in-scope contracts at the audit commit and demonstrates the full permanent-freeze path: a staker's pending withdrawal is rescued while the account is blocklisted, the account is then restored, and the restored staker's new same-period withdraw (or redeem) is accepted — shares are burned and the liability is booked — even though the terminal isWithdrawClaimed flag makes the new right permanently unclaimable and unrescuable. A control test proves that two ordinary same-period withdrawals pay out in full when no rescue poisons the flag.
Environment
The PoC uses only the repository's own test fixture (test/setup/fixtures.js) and its standard zero-fee test token. No storage injection, malicious token, callback, public deployment, or unusual configuration is involved. The only external helper is the repo's own deployVault fixture.
Files
Reproduction Steps
Expected Output
Key invariants (test 1, withdraw route):
What the PoC Proves
A restored account can create a stranded withdrawal
After removeFromBlocklist, the public withdraw accepts a new same-period request: it emits WithdrawRequest, burns the staker's remaining 1,000,000 shares, books 1,000,000 new withdrawal shares, and increases pendingWithdrawAssets — all behind the already-terminal flag.
PoC Source
Raw Runtime Evidence
The raw passing output is included with this report's local package (evidence/find001_full_run.raw.txt), with EXIT_CODE=0 and 3 passing. The command above reproduces this output from a clean checkout.
Was this helpful?