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

88651 sc high inclusive period start checkpoint lets an exact boundary withdrawal erase incident payout exposure and later reclaim the pending funds

Submitted on Aug 14th 2026 at 11:00:45 UTC by @Zoobi for Audit Comp | Firelight

  • Report ID: #88651

  • 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

A normal user can request a delayed withdrawal in a block whose timestamp is exactly the start of a new Firelight period. This writes the post-withdrawal active balance at the period-start checkpoint. Later, the normal IncidentManager approval flow asks the vault to pay a valid incident loss. The vault includes the pending withdrawal in payable capital, but separately limits payment to the corrupted checkpoint.

Vulnerability Details

Intended accounting

A withdrawal request burns shares but does not immediately transfer the underlying tokens. FirelightVault moves the amount into a pending withdrawal bucket. The payout function intentionally includes that bucket when calculating assets exposed to an incident.

Moving assets from active accounting to an exposed pending bucket must not reduce the opening exposure for that period.

Root cause

The issue comes from four operations being combined:

1

withdraw() moves assets into the next withdrawal bucket

2

withdraw() then logs the lower active-assets value

3

The checkpoint is keyed by the current block timestamp

4

totalAssetsAt(periodStart) uses an inclusive lookup and returns a checkpoint written at that exact timestamp

The withdrawal path records the lower post-request value:

The requested assets stay in the vault and are placed in a future withdrawal bucket:

The new active-assets checkpoint uses the transaction timestamp:

The historical lookup is inclusive. It returns the newest checkpoint whose key is less than or equal to the requested timestamp:

Finally, payout() recognizes the pending withdrawal as payable capital but applies the corrupted opening checkpoint as a separate cap:

This creates inconsistent accounting:

For a valid loss L where L is at least E:

If the only shareholder withdraws all assets at the boundary, W equals E. The period-start cap becomes zero even though all E assets remain inside the incident-exposed pending bucket.

Complete attack sequence

1

The attacker owns vault shares before the next period starts

2

Valid cover allocation capacity already exists for the next period

3

The attacker sends a normal withdraw() transaction for inclusion at the exact period-start timestamp

4

The withdrawal burns shares and puts the assets into the new period’s exposed pending bucket

5

The same transaction stores the lower active-assets value at the period-start key

6

A legitimate incident is captured at any time in that period

7

The curator creates and confirms the incident, adds losses that fit the existing allocation, and submits the assessment

8

The assessment approver approves the assessment

9

IncidentManager converts the approved loss to vault-asset units and calls FirelightVault.payout()

10

The vault pays less than the valid loss, or zero, because of the corrupted opening cap

11

The incident closes normally

12

After the withdrawal delay, the attacker calls claimWithdraw() and receives the amount that survived the payout

The PoC does not call payout() from an impersonated payout signer. It deploys the real IncidentManager, configures valid allocation capacity before the boundary, runs createIncident(), confirmIncident(), addAssessmentLosses(), submitCurrentAssessment(), and approveCurrentAssessment(), and lets IncidentManager call the vault.

Impact Details

Sole-holder case: complete vault-leg bypass

The strongest proof uses one shareholder:

Value
Before boundary request
After request
After approved incident

Tokens held by vault

500,000 fXRP

500,000 fXRP

500,000 fXRP

Active assets

500,000 fXRP

0 fXRP

0 fXRP

Exposed pending withdrawal

0 fXRP

500,000 fXRP

500,000 fXRP

Stored opening cap

500,000 fXRP

0 fXRP

0 fXRP

IncidentManager request

—

—

500,000 fXRP

Vault payment

—

—

0 fXRP

Later attacker claim

—

—

500,000 fXRP

IncidentManager accepts and closes the valid assessment. The vault receiver receives zero. Once the delay passes, the user receives the entire pending amount.

Two-holder case: proportional surviving claim

The second proof starts with two deposits of 500,000 illustrative fXRP:

Value
Before boundary request
After request
After approved incident

Tokens held by vault

1,000,000 fXRP

1,000,000 fXRP

500,000 fXRP

Active assets

1,000,000 fXRP

500,000 fXRP

250,000 fXRP

Exposed pending withdrawal

0 fXRP

500,000 fXRP

250,000 fXRP

Stored opening cap

1,000,000 fXRP

500,000 fXRP

500,000 fXRP

IncidentManager request

—

—

1,000,000 fXRP

Vault payment

—

—

500,000 fXRP

Later attacker claim

—

—

250,000 fXRP

The receiver is underpaid by 500,000 fXRP. The proof then advances beyond the delay and shows the attacker actually receiving the surviving 250,000 fXRP.

The impact is not only an incorrect view value. It changes the token amount transferred by an approved incident and preserves a claim that the payout should have reduced.

References

Proof of Concept

Setup and run instructions

1

Clone and install

2

Save the test

Save the complete test below as test/poc/c02_boundary_withdrawal.js.

3

Run the test

If the repository is already compiled:

Complete PoC code

Observed output

Was this helpful?