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

89319 sc high exact period boundary withdrawal is included in the period start exposure lookup and permanently underpays the payout receiver

Submitted on Aug 18th 2026 at 15:03:18 UTC by @marcin37 for Audit Comp | Firelight

  • Report ID: #89319

  • 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

An ordinary FirelightVault shareholder can reduce an honestly approved incident payout by requesting a delayed withdrawal in a block timestamped exactly at the incident capture period's start T. The withdrawal remains in a bucket that payout treats as exposed, but _logTrace also appends the post-withdrawal active balance at key T. Because totalAssetsAt(T) returns the latest checkpoint whose key is less than or equal to T, it selects that new boundary checkpoint as the period-start exposure cap. IncidentManager then accepts the partial return and permanently closes the incident. In the reproduced case, moving the same 2-asset-unit withdrawal from T+1 to T reduces the receiver's payment from 3 to 1 asset unit while the shareholder later claims 1 asset unit and retains another 1 asset unit as active vault value.

Root cause

withdraw moves value into delayed-withdrawal accounting and records the post-withdrawal active assets at the current timestamp:

uint256 ownerBalance = _requestWithdraw(assets, shares, receiver, owner);

_logTrace(owner, ownerBalance, _totalSupply - shares, _totalAssets - assets, true);

In the reproduced path, the previous total-assets checkpoint precedes T, so Checkpoints.push appends the post-withdrawal value under a new key T. upperLookupRecent(T) then inclusively returns that new checkpoint instead of the pre-boundary value:

self._trace.push(key, uint208(len));
self._values.push(value);

uint208 idx = self._trace.upperLookupRecent(key);
return idx > 0 ? self._values[idx] : 0;

FirelightVault.payout uses the boundary-inclusive lookup as a cap separate from the still-payable withdrawal bucket. IncidentManager records whatever amount the vault returns and closes the incident without requiring the approved amount to be satisfied:

The faulty relation crosses three explicitly in-scope production assets: FirelightVault.sol produces and consumes the exact-boundary checkpoint, Checkpoints.sol appends and inclusively retrieves it, and IncidentManager.sol finalizes the underpaid incident.

Attack path

  1. A shareholder owns withdrawable vault shares before a predictable capture-period boundary. The material reachability precondition is that the shareholder's withdrawal transaction is included in a block whose timestamp equals the exact boundary T.

  2. The shareholder calls the ordinary public withdraw path for W asset units. This creates a delayed withdrawal and appends an _traceTotalAssets checkpoint with value A-W at key T; totalAssetsAt(T), which previously resolved to the earlier value A, now resolves to A-W.

  3. Honest incident roles create, confirm, assess, and approve a valid incident captured at T; the shareholder needs no incident, payout, or administrative role.

  4. The vault pays only up to A-W, and the manager sets the incident to CLOSED. After the delay, the shareholder claims the residual withdrawal and retains the remaining active share value.

Impact

The receiver permanently loses value that the shareholder's collateral was committed to cover, and the shareholder retains the corresponding value after the incident is closed.

Measurement

No-action baseline

Matched T+1 control

Exact-T withdrawal

totalAssetsAt(T)

3 asset units

3 asset units

1 asset unit

Receiver payment

3 asset units

3 asset units

1 asset unit

Incident status

CLOSED

CLOSED

CLOSED

Later shareholder claim

0 asset units; fails

0 asset units; fails

1 asset unit; succeeds

Active vault value after claim

0 asset units

0 asset units

1 asset unit

The gross approved vault request is 3 asset units. The newly affected value is the 2-asset-unit receiver shortfall, not the full request. Of those 2 units, 1 becomes a successful delayed claim and 1 remains active value backing the shareholder's remaining shares. At the PoC's fixed price of USD 1 per asset unit, this miniature witness is a USD 2 shortfall; for Q >= A, the reproduced mechanism scales with the boundary withdrawal W, bounded by the shareholder's withdrawable value and the vault's exposed assets.

Proof of Concept

  • Framework: Foundry 1.7.1 (4072e48705af9d93e3c0f6e29e93b5e9a40caed8), Solidity 0.8.28, London EVM

  • Source revision: 42f9ea5e43d88b35197b901acc2a8c24b314fa62

  • Fork: none; the in-scope Phase 2 implementation is intended for deployment/upgrade, so the test deploys the exact frozen production FirelightVault and IncidentManager locally behind ERC1967Proxy

  • Environment: no variables; Node.js/npm and a Foundry installation with Solidity 0.8.28 cached are required

The ERC-20, allocator, and fixed USD 1 price feed are deterministic interface-conforming dependencies used identically in all three arms. Neither affected production contract is mocked or replaced. Save the complete Solidity block below as test/ExactBoundaryIncidentPayoutShortfall.t.sol, then run:

Decisive output:

Complete PoC

Recommendation

Make the period-start exposure snapshot immutable against user operations timestamped at the boundary. For example, materialize a dedicated snapshot before any key-T deposit or withdrawal can enter the lookup, or read the last total-assets checkpoint strictly before T while continuing to include the separately payable withdrawal buckets. Add a regression that holds actors, amounts, and incident lifecycle constant while moving only the withdrawal from T+1 to T, and require the same receiver payment and no residual shareholder entitlement.

Was this helpful?