88636 sc high period boundary withdrawal overwrites totalassets exposure checkpoint payout cap collapses to zero and the withdrawer escapes the slash
Submitted on Aug 14th 2026 at 09:47:33 UTC by @scoot69 for Audit Comp | Firelight
Report ID: #88636
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:
Griefing (e.g. no profit motive for an attacker, but damage to the users or the protocol)
Description
Brief/Intro
FirelightVault.payout() caps an incident payout to the vault's total assets at the capture period start, using a checkpoint that a withdrawal mined in the exact period-boundary block can overwrite with the post-withdrawal value. A withdrawer who times a withdrawal to the boundary block collapses that exposure cap to zero: the incident payout pays nothing, the beneficiary receives nothing, and the withdrawer's bucket stays fully payable, so after the payout window they claim the full amount back and escape the slash entirely. In production any incident whose capture timestamp falls on a period boundary where a withdrawal was mined pays zero to the covered party, and the withdrawer walks away with funds that should have been slashed.
Vulnerability Details
Every deposit and withdraw flow ends in _logTrace, which checkpoints totalAssets keyed by block timestamp:
// contracts/core/FirelightVault.sol:1044-1056
function _logTrace(address owner, uint256 balance, uint256 _totalSupply, uint256 _totalAssets, bool isLogAssets) private {
uint48 ts = Time.timestamp(); // key = block timestamp
_traceBalanceOf[owner].push(ts, balance);
_traceTotalSupply.push(ts, _totalSupply);
if (isLogAssets) _traceTotalAssets.push(ts, _totalAssets); // line 1055
}Checkpoints.Trace256.push replaces the stored value when the key equals the last checkpoint key, and upperLookupRecent returns the most recent checkpoint with key <= the lookup key:
payout() derives its exposure cap from this trace and separately treats capture-period withdrawals as payable:
totalAssetsAt is a straight upperLookupRecent (FirelightVault.sol:456-458).
The bug: when a withdrawal's block timestamp equals the period start (capturePeriodStart), _logTrace writes a checkpoint with key exactly capturePeriodStart and value equal to the post-withdrawal totalAssets (0 if the withdrawer takes everything). If a checkpoint at that key already exists, push overwrites it (Checkpoints.sol:207-208); if not, the new entry is still the most recent checkpoint with key <= periodStart, so upperLookupRecent returns the post-withdrawal value anyway. Either way totalAssetsAt(capturePeriodStart) collapses from the pre-boundary exposure to 0. Meanwhile lines 916-926 still count withdrawAssets[capturePeriod + 1] (the full withdrawal) as payable, and the payout code itself documents the contradiction: it intends capture-period withdrawals to "remain payable" (lines 914-915) while its cap lookup is corrupted by the very transaction that created that bucket. After paidAmount = 0, withdrawAssets[capturePeriod + 1] is never reduced (the decrement at line 954 runs only inside if (paidAmount > 0)), so claimWithdraw(period) pays out the full amount once currentPeriod() > capturePeriod + 1.
Recommended fix: read the exposure from just before the period start — totalAssetsAt(capturePeriodStart - 1) — which returns the most recent checkpoint strictly before the boundary and is immune to any same-timestamp boundary-block write. Alternatively store a dedicated exposureAtPeriodStart[period] mapping written at rollover, or prevent user flows from writing checkpoints at exact period-start keys.
Impact Details
Severity: Medium, under the Griefing impact (damage to users or the protocol, no profit motive required). The payout exposure cap — the mechanism that guarantees an incident captured in a period is paid from the assets exposed during that period — can be reduced to zero by one withdrawal mined in the first block of the period. The payout then pays nothing while the withdrawal bucket stays fully payable, so the loss is real, direct, and quantified.
Scenario (from PoC): the vault holds 200 units (6-decimal asset). Alice withdraws all 200 in the exact period-1-start block. An incident is captured for period 1 and assessed at 200.
payout(receiver, 200, period1Start)returns 0 — the incident receives nothing — whilewithdrawAssets[2] = 200remains payable. Two periods later Alice claims the full 200.Who loses what: cover beneficiaries and incident payout receivers lose the entire assessed amount (200 units in the PoC; the full vault exposure in general). The exposure-cap protection is bypassed, the withdrawer escapes any reduction of their payout, and the loss is transferred to the incident side of the protocol.
Generality: the corruption is not limited to full collapse. Any deposit or withdrawal mined in the first block of a period rewrites the period-start checkpoint (deposits inflate it, withdrawals deflate it), so organic boundary-block activity also mis-states the cap; the adversarial case just makes the collapse complete.
Exploitation requirement: the withdrawal must be mined with
block.timestampequal to the period start. This is standard transaction timing (submit in the first block of a period); no privileged role, no external state, and no out-of-scope contract is involved.
References
Repository: https://github.com/immunefi-team/audit-comp-firelight — branch
v1_audit_ready, commit42f9ea5e43d88b35197b901acc2a8c24b314fa62contracts/core/FirelightVault.sol—withdraw()755-777,_logTrace()1044-1056,payout()892-966,claimWithdraw()788-811,totalAssetsAt()456-458,_isPeriodInPayoutWindow()1130-1132contracts/core/lib/Checkpoints.sol—Trace256.push()198-214 (overwrite at 207-208),Trace256.upperLookupRecent()221-224PoC:
test/audit-exploratory/F2-boundary-overwrite.js(embedded in the Proof of Concept section)
Link to Proof of Concept
https://gist.github.com/piegunn/b86b4e121fef208093817d0876810af8
Proof of Concept
The PoC is a standalone Hardhat test file. No existing contract, fixture, or configuration file is modified; only one new file is created. Both tests pass on a clean clone of the audited commit.
What each test proves
Test 1 — mines Alice's full withdrawal in the exact period-start block (
time.setNextBlockTimestamp(boundaryTs)), assertstotalAssetsAt(boundaryTs) == 0,withdrawAssets(2) == 200,paidAmount == 0, receiver balance0, and finallyclaimWithdraw(2)transferring all 200 to Alice after the payout window. This is the exploit end-to-end against the production vault.Test 2 (control) — the identical capture (
captureTimestamp = boundaryTs) but with no deposit/withdrawal mined in the exact boundary block; the payout executes atboundaryTs + 1and assertsPayoutExecuted(receiver, amount, amount, boundaryTs), i.e. the full 200 is paid. This isolates the boundary-block checkpoint write as the sole cause of the collapse in test 1.
The test runs locally against the in-scope contracts (no mainnet/testnet interaction, no forks of external systems).
Was this helpful?