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
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.The shareholder calls the ordinary public
withdrawpath forWasset units. This creates a delayed withdrawal and appends an_traceTotalAssetscheckpoint with valueA-Wat keyT;totalAssetsAt(T), which previously resolved to the earlier valueA, now resolves toA-W.Honest incident roles create, confirm, assess, and approve a valid incident captured at
T; the shareholder needs no incident, payout, or administrative role.The vault pays only up to
A-W, and the manager sets the incident toCLOSED. 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 EVMSource revision:
42f9ea5e43d88b35197b901acc2a8c24b314fa62Fork: none; the in-scope Phase 2 implementation is intended for deployment/upgrade, so the test deploys the exact frozen production
FirelightVaultandIncidentManagerlocally behindERC1967ProxyEnvironment: 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?