Failed close-report retries let late depositors capture incumbent yield
A signed close-epoch report that fails on delivery stays valid for as long as the epoch remains open. A late depositor can make the report fail, then replay its stale TVL after depositing, and capture a pro-rata share of yield that accrued to existing holders.
Description
ParentVault.closeEpoch(expectedEpochNonce, tvl) rejects reports for a different epoch, but it accepts the supplied TVL for as long as the same epoch remains open. This is unsafe for failed authenticated reports: WorkflowRouter forwards the report without using its report ID or enforcing freshness, while Keystone deliberately leaves a failed transmission retryable.
A depositor can make a correct report fail after it has been signed by cancelling the only deposit in the epoch before delivery. The failed transaction publishes the authenticated report bytes and signatures without advancing the epoch nonce. Because the epoch is then empty, later scheduled close attempts skip it; meanwhile the active strategy continues accruing yield. The attacker can later deposit and permissionlessly retry the old failed report before the next fresh close. The nonce still matches, so their deposit is priced against the pre-yield TVL.
The CRE report is truthful when signed. The attacker creates the stale state using ordinary deposit cancellation and the intended failed-report retry behavior; no incorrect CRE observation is required.
This is not the documented delayed-settlement case. KI-007 states that when a scheduled close is missed, "Settlement allocations use TVL at the eventual close, not at the missed scheduled close time," and that the delay "does not by itself create an accounting inconsistency or direct loss of funds." Both statements fail on this path: the eventual close settles against the missed snapshot, and the result is a direct transfer of incumbent assets. KI-010's bounded-staleness reasoning is scoped to the zero-total-shares bootstrap case and inherits the same assumption, so it does not cover this either. The individual mechanisms are documented as intentional — closeEpoch records that the supplied TVL is trusted and unvalidated, and WorkflowRouter.onReport records that it does not reject stale or replayed reports — but no accepted risk covers their composition producing shareholder loss.
Where this sits relative to the existing protections
Nothing on this path is missing or misconfigured, and every guard on closeEpoch behaves as designed. The call is restricted to the epoch operator role, the report must name the epoch it closes, MIN_EPOCH_PERIOD must have elapsed, an epoch containing neither deposits nor withdraw intents cannot be closed, and settlement rejects a TVL-to-share ratio that rounds down to zero. Every one of those checks passes during this sequence, because none of them is the thing that fails.
The epoch nonce binding is explicitly replay protection, and it does its job: a report cannot close an epoch other than the one it names. What a nonce cannot express is which state of that epoch it was authorized against. The nonce advances only when a close succeeds, so deposits and cancellations change an epoch's contents freely while its identity stays fixed. A report signed against the epoch as it stood at one moment remains equally valid against entirely different contents later.
The trust placed in the reported TVL is a deliberate architectural decision rather than an oversight. The contracts do not compute cross-chain strategy value on-chain because for a remote strategy they cannot, and the consequence is documented: an incorrect TVL irreversibly corrupts epoch share accounting once a claim occurs. That boundary is drawn around incorrect values supplied by a trusted reporter. This finding involves no incorrect value. The reported TVL is accurate at the moment it is signed, and becomes wrong only because an unprivileged actor determines when it is applied. The missing axis is not accuracy but freshness: nothing on the path records when the value was observed, and nothing requires it still to hold.
Where the active strategy is local, the boundary is also wider than it needs to be. The Parent already observes the authoritative figure — the adapter-backed TVL it exposes through its own view function — and settlement uses the supplied value regardless.
The last component is worth stating plainly, because it is a correct protection rather than a flawed one. Refusing to close an epoch that contains neither deposits nor withdraw intents is sensible: there is nothing to settle. In this sequence it is also what keeps the stale report usable. An epoch with activity would be closed by the next scheduled report, advancing the nonce and rendering the old report permanently invalid. By emptying the epoch, the attacker relies on that guard to guarantee no fresh close can ever supersede the one they hold.
Vulnerable Scenario:
- Existing shareholders own
Tassets in a local strategy and epochnis open. - The attacker deposits the minimum amount, making the epoch eligible for close.
- CRE reads nonce
nand TVLT, then signs the correct close report. - The attacker cancels their deposit before delivery. The vault call fails because the epoch is empty, and Keystone records the transmission as failed.
- The attacker waits while strategy yield
Yaccrues. With no epoch activity, scheduled close runs do not supersede the failed report. - The attacker deposits
Dand retries the published report.closeEpochaccepts noncenand stale TVLT. - The attacker receives
D / Tof the old share supply rather thanD / (T + Y).
Impact
Existing shareholders permanently lose Y * D / (T + D) of yield to the late depositor. In the PoC, a 10,000,000 USDC deposit captures 500,000 USDC of a 1,000,000 USDC incumbent yield accrual; subsequent reports, cancellation, pausing, or epoch progression cannot reprice the already-claimable shares.
The same replay settles a remote-strategy epoch against its stale snapshot, so the exposure is not confined to a locally held strategy. In that configuration the ParentVault has no locally observable value to compare against at all.
Exploitability. Realising material value requires four conditions to hold together, and three of them are outside the attacker's control. The cancellation must land between the workflow's TVL read and the report's delivery, which on a chain without a public mempool is a blind timing race — though a cheap and freely repeatable one, since cancelling too early simply means no report is signed and too late means the epoch closes normally. The epoch must then stay empty long enough for yield to accrue, because any other deposit or withdraw intent lets the next scheduled close succeed and permanently invalidates the stale report. The attacker must commit capital at least comparable to existing TVL to capture a meaningful fraction, and that capital is not flash-loanable, since exiting requires a subsequent epoch. Finally the accrued yield must be material over that window.
The second and fourth conditions work against each other: a vault quiet enough for epochs to sit empty holds too little for the accrual to be worth taking, while one holding enough for it to matter has the activity that closes the window. The figures in the proof of concept below are chosen to make the arithmetic legible rather than to represent realistic lending yields.
Recommendation
Bind each report to an observation deadline carried in its signed payload. Have the report producer append the deadline to the report body, validate it in WorkflowRouter, and strip it before forwarding to the vault. Because the deadline sits in the report body it falls under the existing signature digest, so no change to the Keystone forwarder is required. The check is independent of where the active strategy lives, so local and remote settlement behave identically and no validation asymmetry is introduced between them.
if (report.length < 36) revert WorkflowRouter__ReportTooShort(report.length);uint256 deadline = uint256(bytes32(report[report.length - 32:]));if (block.timestamp > deadline) revert WorkflowRouter__ReportExpired(deadline, block.timestamp);bytes calldata vaultCall = report[:report.length - 32];// existing metadata and selector checks run against vaultCall(bool success, bytes memory returnData) = i_vault.call(vaultCall);
Verified against the audited commit:
| Property | Result |
|---|---|
| Honest fresh report inside its deadline | Settles normally; transmission SUCCEEDED, epoch advances |
| The stale replay described above | Rejected; transmission stays FAILED, epoch nonce unchanged |
| Liveness after rejection | Preserved; a re-signed report at live TVL settles the epoch |
| Attacker's allocation | Priced at live TVL, capturing none of the incumbent yield |
Fixture cost: 6 WorkflowRouter unit tests and 22 integration tests fail until they append the deadline, all with WorkflowRouter__ReportTooShort or WorkflowRouter__ReportExpired. Every one is a report-format update rather than a behavioural failure. As with any change to the signed payload, the contracts and the report producer must be deployed together.
Selector coverage. Apply the deadline uniformly to every report the router dispatches rather than to closeEpoch alone. Per-selector opt-in fails silently: a command added later is unbound by default, with nothing to signal it. A uniform rule also keeps the length arithmetic relative rather than hardcoded per selector.
Choosing the window. The deadline should be generous enough to absorb ordinary reporting latency and network congestion — otherwise honest reports expire and settlement stalls — while remaining far shorter than the interval over which strategy yield becomes material. Given a roughly daily settlement cadence with a one-hour floor, a window in the minutes range leaves ample margin on both sides, but the exact figure is an operational decision.
If the destination binding recommended for the receiver-redirect finding is also adopted, both values can be carried in a single trailing block and validated together, so the report format changes once rather than twice.
Resolution
Fixed in 14a57d6: the signed envelope carries an observation timestamp and onReport rejects anything future-dated or older than thirty minutes, so a stale report can no longer settle changed epoch contents against its original TVL.
Affected files
evm/src/vaults/ParentVault.sol#L350-L397evm/src/modules/WorkflowRouter.sol#L106-L145evm/src/libraries/vaults/ParentVaultEpochLib.sol#L180-L220evm/src/libraries/vaults/ParentVaultUserEpochLib.sol#L337-L350