For the complete documentation index, see llms.txt. This page is also available as Markdown.

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 in contracts/core/FirelightVaultStorage.sol, both in scope)

  • Repo: https://github.com/immunefi-team/audit-comp-firelight

  • Branch/commit: v1_audit_ready @ 42f9ea5e43d88b35197b901acc2a8c24b314fa62

  • Affected 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

1

Request an initial withdrawal

A staker deposits 2,000,000 base units and requests a withdrawal of 1,000,000 for period P+1 during period P.

2

Rescue the pending withdrawal

The compliance role blocklists the account, and the rescuer role moves the pending P+1 right to a recovery address. Both are documented, intended lifecycle operations. The source account's isWithdrawClaimed[P + 1] becomes true.

3

Restore the account

The compliance role removes the account from the blocklist, still during period P.

4

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.

5

Claim the rescued right

After maturity, the recovery address claims the old rescued right normally.

6

Attempt to claim the new right

The restored staker's claimWithdraw(P + 1) reverts with AlreadyClaimedPeriod.

7

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 notBlocklisted checks on withdraw/redeem pass because the account was legitimately restored.

  • _requestWithdraw has no check on isWithdrawClaimed[period][receiver] at all, so the new liability is booked and the shares are burned.

  • claimWithdraw reverts on the already-true flag — it protects against double-claiming but also permanently bricks the new right.

  • The rescue retry contains the same AlreadyClaimedPeriod guard, 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 withdraw and redeem variants 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_ROLE as 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 persistent isWithdrawClaimed flag 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, claimWithdraw

    • contracts/core/FirelightVaultStorage.sol — isWithdrawClaimed terminal flag

  • PoC package and raw runtime output: attached in the Proof of Concept field

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

1

Rescue marks the source account's period terminal

The rescue moves the pending right to the recovery address and permanently sets isWithdrawClaimed[P+1][staker] = true (asserted on-chain).

2

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.

3

The rescued right is claimable, but the new right is not

After maturity, the rescued right is claimed normally by the recovery address, while the staker's claimWithdraw reverts with AlreadyClaimedPeriod.

4

The stranded withdrawal cannot be rescued

Re-blocklisting and retrying rescueWithdrawFromBlocklisted also reverts with AlreadyClaimedPeriod: neither the delivery path nor the protocol's own compliance recovery path can ever reach the stranded 1,000,000 units.

5

The redeem path has the same result

The redeem sibling entrypoint produces the same terminal state.

6

Ordinary same-period withdrawals work without rescue

The no-rescue control pays two ordinary same-period withdrawals in full (2,000,000), proving the freeze is caused specifically by the rescue terminal-flag interaction.

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?