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

90105 sc high a routine non malicious blocklist rescue permanently poisons the rescued account s next withdrawal with no recovery path for any role

Submitted on Aug 23rd 2026 at 02:32:29 UTC by @BalkarSingh for Audit Comp | Firelight

  • Report ID: #90105

  • 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 exists to move a blocklisted account's pending withdrawal claim to a clean address. It does that part correctly — but as a side effect it also permanently marks the source account's (period, account) slot as "claimed," even though the source account was paid nothing, and even though nothing requires the rescued period to have already settled. Withdrawal requests always book into whichever period is currently pending, and the storage slot they book into is additive rather than single-assignment. So the moment the rescued user is cleared (an unremarkable, expected outcome of a compliance review) and makes one more ordinary withdrawal before the period rolls over, that brand-new, fully-paid-for claim lands directly on top of the stale "claimed" flag — and is rejected forever. The one role built to correct rescue mistakes cannot touch the slot again either, because it treats "already flagged" as "already handled."

Vulnerability Details

rescueWithdrawFromBlocklisted unconditionally stamps the source account's slot as settled, with no payout and no requirement that the period already be closed:

// contracts/core/FirelightVault.sol:845-876
function rescueWithdrawFromBlocklisted(
    address from,
    address to,
    uint256[] calldata periods
)
    external
    onlyRole(RESCUER_ROLE)
    onlyBlocklisted(from)
    notBlocklisted(to)
{
    ...
    for (uint256 i = 0; i < len; i++) {
        uint256 _withdrawOf = withdrawSharesOf[periods[i]][from];

        if (isWithdrawClaimed[periods[i]][from]) revert AlreadyClaimedPeriod(periods[i]);
        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; 
        ...
    }
}

The slot this writes to is not single-use. Every ordinary withdrawal request targets the currently-pending period and adds into whatever is already sitting in that account's slot:

And the claim path checks the stale flag before it ever looks at whether there's a real, funded balance behind it:

isWithdrawClaimed has exactly two writers anywhere in the contract — claimWithdraw:806 and the rescue's :870 — and both only ever write true. Nothing resets it. Attempting a second rescue of the same (period, from) to work around this doesn't help either: it hits the rescue function's own duplicate guard (:864) and reverts, so RESCUER_ROLE has no path to un-poison the slot it created.

Impact Details

  • The victim's second, fully-funded withdrawal becomes permanently unclaimable — by the victim, and by RESCUER_ROLE itself.

  • Trigger requires nothing adversarial: (1) compliance blocklists an account with an open withdrawal in the pending period, which is a routine, false-positive-prone screening action; (2) RESCUER_ROLE rescues that one pending claim, exactly as the function is meant to be used; (3) compliance clears the account before the period rolls over — an ordinary review turnaround, not a race; (4) the cleared account, with zero special knowledge, simply uses the vault again. Every step is either a role acting exactly inside its documented job, or an unprivileged user doing nothing unusual.

  • Beyond the direct victim, the stranded assets stay counted in pendingWithdrawAssets forever, which totalAssets() (:427-429) subtracts from the vault's real balance — so every other LP's share price is permanently understated by the stranded amount too, even LPs who never touched blocklist or rescue at all. Exact figures, including the bystander-LP effect, are in the PoC below.

References

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

Proof of Concept

Proof of Concept

Below test is written in foundry

Was this helpful?