Early rate$2,400 of senior audit time for $500. Early members keep the rate as it climbs.$2,400 of senior audit time for $500See how →
F-2026-0001·signature-replay

Receiver-unbound Child reports can drain a later active strategy

Fixedvaultcrosschainepoch-accountinggithub.com/contractlevel/yield-v2
TL;DR

Keystone signatures do not cover the receiver, so a signed epoch-withdraw report delivered to one ChildVault can be replayed at another. An unprivileged caller can pull a historical withdrawal amount out of a later active Child strategy and strand it behind a CCIP message the Parent permanently rejects.

Severity
CRITICAL
Impact
HIGH
Likelihood
HIGH
Method
MManual review
CAT.
Complexity
LOW
Exploitability
HIGH
02Section · Description

Description

WorkflowRouter.onReport authenticates the workflow ID, name, owner, and selector, but it does not authenticate the chain or router for which the report was produced. This matters because KeystoneForwarder.report(receiver, rawReport, reportContext, signatures) is public and its signature digest covers only rawReport and reportContext; receiver is caller-selected after signature verification. Keystone also keys replay state by (receiver, workflowExecutionId, reportId), so success at one receiver does not consume the same report at another receiver.

Every Child router is configured to accept the same workflow identity and executeEpochWithdraw selector. After an honest executeEpochWithdraw(e, amount) report has settled an epoch through Child A, its report bytes, context, and signatures are public. If a later ordinary rebalance activates Child B, B's rebalance high-water advances but its independent epoch high-water can remain below e. An unprivileged caller can submit the unchanged signed report to B's Forwarder/router. B accepts e, withdraws amount from its current adapter, and sends a second EPOCH_NET_WITHDRAW message for the old epoch.

The source CCIP send succeeds, so Child B records no Yieldcoin recovery. The Parent has already made epoch e claimable and permanently rejects the immutable second payload in _finalizeEpoch. CCIP can retry the destination execution, but every retry reaches the same terminal Parent state.

The staging deployment independently confirms the complete primitive. Base Sepolia transaction 0x35025414a798fa1fff584d1614ee9ee4e8bb6aedc08910e4975cdec73743edf6 contains a successful signed executeEpochWithdraw(3,999986) report. At Optimism Sepolia block 47,635,395, read-only execution of those exact bytes through its configured Forwarder accepts all four signatures, calls the other Child, reduces its live Compound adapter TVL from 3,500,014 to 2,500,028, and constructs a second nonce-3 CCIP message. At Arbitrum Sepolia block 299,497,773, Parent epoch nonce is 4 and epoch 3 is already CLAIMABLE.

Where this sits relative to the existing protections

None of the checks on this path are missing or misconfigured. WorkflowRouter performs exactly the authentication it is built to perform — Keystone forwarder role, registered workflow ID, name and owner, and the selector allowlist for the current generation — and every one of them passes here, because the report genuinely originates from the correct workflow and carries an allowlisted selector. The gap is on an axis the router does not consider at all: which chain, and which router, the report was produced for.

The Child nonce high-water is the same. Accepting any nonce strictly greater than the last handled one is deliberate and correct: a Child does not participate in every global epoch, so its nonce sequence is legitimately sparse and skipped values must be tolerated. That design defends the ordering it was built for — commands arriving late, or with gaps. It does not defend a Child whose high-water has never advanced against a historical command it was never intended to receive at all.

The protocol already enforces origin binding on its other cross-chain ingress path. An inbound CCIP message is checked against the immutable router callback, the source chain selector, and the cross-chain vault registered for that selector, so a message is bound to the place it came from. Reports arriving through the CRE path receive no equivalent binding, because the Keystone signature quorum is treated as sufficient authorization on its own. That asymmetry between the two ingress paths is what this finding exploits.

Vulnerable scenario:

  1. CRE honestly signs and delivers executeEpochWithdraw(e, amount) to active Child A; the transaction calldata becomes public.
  2. A normal rebalance later moves the remaining strategy to Child B. B has material TVL, but its epoch high-water is still below e.
  3. The attacker calls B's public Keystone Forwarder with B's router as receiver and the unchanged report, context, and signatures from step 1.
  4. Child B withdraws amount from its live adapter and CCIP accepts an old-epoch message.
  5. Parent rejects that message forever because epoch e is already claimable.
03Section · Impact

Impact

An unprivileged attacker can remove a historical withdrawal amount from a later active Child strategy and strand it behind permanently failing CCIP destination execution. The amount can be a material portion of vault principal and scales with an ordinary historical withdrawal; the attacker supplies no assets and permanently spends only transaction gas.

The displaced funds are not merely delayed. The Parent's rejection of the duplicated nonce is terminal, so no retry, recovery, pause cycle, route restoration, or later epoch can route them back; returning them requires a bespoke upgrade rather than any existing operational path.

ChildVault.executeRebalance carries the same exposure today. Its guards have the identical shape — role, pause, reentrancy, no-recovery, and a rebalance nonce strictly greater than that Child's rebalance high-water — with no binding to the chain or router the command was produced for. Because a rebalance withdraws the active strategy's entire position rather than one historical epoch amount, a redirected rebalance command displaces the whole balance of the targeted Child. A Child that has sat idle through several rebalances holds a correspondingly low rebalance high-water, which is the ordinary sparse-nonce state the design intends to tolerate.

04Section · Recommendation

Recommendation

There are two ways to close this, with different cost profiles. They are not exclusive.

Option A — give each Child chain its own workflow identity. The redirect survives only because every Child router accepts one shared workflow ID, name and owner, so a report authenticated for one chain authenticates on all of them. Registering a distinct identity per Child chain makes the router's existing metadata check reject a foreign report with no contract change; this is confirmed by re-running the proof of concept with a distinct workflow ID registered on the second Child's router, where the redirected delivery no longer executes.

This is not free. A single scheduled workflow currently drives epoch settlement and rebalances across every chain, so distinct per-chain identities mean splitting it into per-chain workflows, with the operational and cost implications that carries. It is offered as a decision, not as an obviously cheaper fix.

Option B — bind the command to its destination in the signed payload. Append (targetChainId, targetRouter) to the signed report, validate both in WorkflowRouter, and strip the suffix before calling the vault. The CRE report producer must append the intended values before report generation so they fall under the existing signature digest. This keeps a single workflow identity and places the guarantee in the contract rather than in deployment discipline.

diff
diff --git a/evm/src/interfaces/modules/IWorkflowRouter.sol b/evm/src/interfaces/modules/IWorkflowRouter.sol
@@
error WorkflowRouter__ReportTooShort(uint256 reportLength);
+ error WorkflowRouter__InvalidReportDomain(uint256 targetChainId, address targetRouter);
diff --git a/evm/src/modules/WorkflowRouter.sol b/evm/src/modules/WorkflowRouter.sol
@@
import {IWorkflowRouter} from "../interfaces/modules/IWorkflowRouter.sol";
+import {IChildVault} from "../interfaces/vaults/IChildVault.sol";
@@
if (report.length < 4) revert WorkflowRouter__ReportTooShort(report.length);
bytes4 selector = bytes4(report[:4]);
+ bytes calldata vaultCall = report;
+
+ if (selector == IChildVault.executeEpochWithdraw.selector) {
+ // selector + two uint256 arguments + chain ID + router address
+ if (report.length != 132) revert WorkflowRouter__ReportTooShort(report.length);
+ uint256 targetChainId = uint256(bytes32(report[68:100]));
+ address targetRouter = address(uint160(uint256(bytes32(report[100:132]))));
+ if (targetChainId != block.chainid || targetRouter != address(this)) {
+ revert WorkflowRouter__InvalidReportDomain(targetChainId, targetRouter);
+ }
+ vaultCall = report[:68];
+ }
@@
- (bool success, bytes memory returnData) = i_vault.call(report);
+ (bool success, bytes memory returnData) = i_vault.call(vaultCall);

The diff above covers executeEpochWithdraw only. executeRebalance needs the same treatment in the same change, not as later work: it is reachable today by the identical redirect and displaces the targeted Child's entire position rather than a single epoch amount. Any future CRE-driven Child command should carry the domain binding from the outset.

If Keystone itself can be changed, including block.chainid and the receiver address directly in its signed digest is the strongest generic protection, since it removes the caller's freedom to choose the destination for an already-signed report rather than compensating for it downstream.

05Section · Resolution

Resolution

Fixed in 14a57d6: reports now carry a signed envelope naming the target chain and router, so the receiver is part of the signature digest and a redirected report is rejected on WorkflowRouter__InvalidReportDomain.

06Section · Affected files

Affected files

  • evm/src/modules/WorkflowRouter.sol#L106-L145
  • evm/src/vaults/ChildVault.sol#L220-L253
  • evm/src/vaults/ChildVault.sol#L621-L645
  • evm/src/libraries/vaults/ParentVaultCcipLib.sol#L55-L82
  • evm/src/libraries/vaults/ParentVaultEpochLib.sol#L356-L366
Status
Fixed
Fix commit
14a57d6
F-2026-0001