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.
Pre-boundary exposure E
Boundary withdrawal W
Active assets after request E - W
Exposed pending assets W
Tokens still held by the vault E
Stored period-start cap E - W
paidAmount = min(L, E - W, E) = E - W
shortfall = W
git clone https://github.com/firelight-protocol/firelight-core.git
cd firelight-core
git checkout 42f9ea5e43d88b35197b901acc2a8c24b314fa62
npm install
npx hardhat test test/poc/c02_boundary_withdrawal.js
npx hardhat test --no-compile test/poc/c02_boundary_withdrawal.js