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

89579 sc high rescuewithdrawfromblocklisted flags a still open period as claimed without paying anything out permanently bricking the rescued user s next legitimate withdrawal into that same period

Submitted on Aug 19th 2026 at 23:50:07 UTC by @MangalJup for Audit Comp | Firelight

  • Report ID: #89579

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

    • Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield

Description

Brief/Intro

rescueWithdrawFromBlocklisted moves a blocklisted user's pending withdrawal to a clean address, but also sets isWithdrawClaimed[period][from] = true even though from was paid nothing — and it does this without checking whether period is still open. Withdrawal requests always book into the pending period (currentPeriod() + 1) and add to the same slot with +=, so once the rescued user is un-blocklisted (a routine false-positive resolution) and books a new, ordinary withdrawal before that period rolls over, the new claim lands on the already-flagged slot. claimWithdraw checks the flag before it ever looks at the balance, so that claim reverts forever — and the rescue function itself refuses to touch the same (period, account) a second time, closing off the one role that could otherwise fix it.

Vulnerability Details

The rescue marks the source account "claimed" with no payout and no period check:

// contracts/core/FirelightVault.sol:861-873
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]);
    if (_withdrawOf == 0) revert NoWithdrawalAmount(periods[i]);

    withdrawSharesOf[periods[i]][to] += _withdrawOf;
    withdrawSharesOf[periods[i]][from] = 0;
    isWithdrawClaimed[periods[i]][from] = true;   // :870 -- no payout, no `periods[i] < currentPeriod()` check
    ...
}

The flagged slot is not single-use — every new withdrawal request adds into the pending period:

claimWithdraw consults the flag before the balance:

isWithdrawClaimed has exactly two writers in the whole contract — claimWithdraw and the rescue — both only ever write true. There is no resetter, and a second rescue of the same (period, from) reverts on the rescue's own guard at :864, so RESCUER_ROLE cannot move the poisoned entry out either.

Impact Details

  • Alice's second, fully paid-for withdrawal is unclaimable by her, unrescuable by RESCUER_ROLE, and permanently excluded from totalAssets().

  • Only precondition: the compliance team un-blocklists the user before the pending period rolls over — an ordinary review turnaround, not a narrow race.

  • No malicious input required anywhere: blocklist, rescue, and un-blocklist are all exercised exactly within their documented authority; the loss is realized by the victim's own ordinary redeem(). Exact figures from the PoC below.

Mitigation

  • Settle by balance, not by flag: zero withdrawSharesOf[period][sender] on claim and drop the isWithdrawClaimed write entirely — the zeroing already fully consumes a rescued or claimed entry, making the boolean redundant and the sole source of the bug.

  • If keeping the flag, at minimum refuse to rescue a still-open period: if (periods[i] >= currentPeriod()) revert InvalidPeriod(); — smaller diff, but blocks rescuing the common case (an outstanding withdrawal) until it settles.

References

https://github.com/immunefi-team/audit-comp-firelight/blob/42f9ea5e43d88b35197b901acc2a8c24b314fa62/contracts/core/FirelightVault.sol#L845-L876

https://github.com/immunefi-team/audit-comp-firelight/blob/42f9ea5e43d88b35197b901acc2a8c24b314fa62/contracts/core/FirelightVault.sol#L788-L811

https://github.com/immunefi-team/audit-comp-firelight/blob/42f9ea5e43d88b35197b901acc2a8c24b314fa62/contracts/core/FirelightVault.sol#L994-L1028

Proof of Concept

Proof of Concept

One honest user, one routine compliance rescue, no malicious actor. Save as test/RescueBricksClaim.t.sol and run forge test --match-contract RescueBricksClaimReportPoC -vv.

Result (actually run — forge test --match-contract RescueBricksClaimReportPoC -vv):

Alice's 50,000e6 fXRP is unclaimable by her, unrescuable by RESCUER_ROLE, and permanently excluded from totalAssets() — the vault still physically holds it but reports zero assets.

Was this helpful?