The Silent Signal: A Deep Dive into the ZK-Proof Replay Attack on OmniYield V3

CobieLion
Exchanges

I trace the shadow before it casts. On the 17th of March, at block height 18,472,093 on Arbitrum, a single transaction stood out. Not because of its value — a mere 0.1 ETH — but because of its fingerprint: a recursive call pattern that matched the OmniYield V3 vault's harvest() function, yet the proof parameter carried an unusually low entropy value. The bytes whispered truth before the exploit even began.

The Silent Signal: A Deep Dive into the ZK-Proof Replay Attack on OmniYield V3

Context: The Rise of OmniYield V3

OmniYield V3 launched in late 2024 as a cross-chain yield aggregator leveraging zero-knowledge proofs (ZKPs) to verify underlying positions across Ethereum, Arbitrum, and Optimism. The core promise was simple: deposit assets into any supported chain, and the protocol would automatically rebalance into the highest-yielding pools using a ZK-rollup layer for state compression. By February 2025, it had attracted over $1.2 billion in total value locked (TVL), with institutional interest from three major custodians. The architecture relied on a single Verifier contract that checked ZK-proofs submitted by keepers. The system assumed proofs were unique per transaction — but that assumption carried a hidden weight.

Core: Code-Level Analysis and Trade-Offs

I began by decompiling the OmniYieldVault.sol contract. Finding the pulse in the static, I isolated the harvest function:

function harvest(
    bytes calldata proof,
    bytes32[] calldata merklePath,
    uint256 index,
    IERC20[] calldata rewardTokens,
    uint256[] calldata amounts
) external onlyKeeper returns (bool) {
    // Verify the ZK proof
    require(verifier.verify(proof, merklePath, index), "Invalid proof");
    // ... distribute rewards
}

The vulnerability was not in the proof verification itself — the Groth16 circuit was sound. The flaw was in the replay protection. The proof bytes were never tied to a unique identifier such as block.timestamp, nonce, or keeper address. The merklePath and index were used only to locate the vault position, not to bind the proof to a specific execution window. This meant that a valid proof harvested from one transaction could be replayed in a subsequent block, as long as the underlying state of the vault position had not changed.

I simulated the attack in a local fork. A keeper submits a legitimate harvest for a vault with a reward of 100 COMP tokens. The proof is recorded. The keeper then crafts a new transaction with the same proof but different reward tokens — say, a fake token with inflated amounts. Since the onlyKeeper modifier does not enforce a per-proof uniqueness check, the verifier accepts the replayed proof. The vault then disburses the fake token amounts as if they were legitimate rewards, draining the real reward pool in subsequent swaps.

The trade-off was clear: the development team optimized for gas efficiency by omitting a mapping(bytes32 => bool) spent check. Each unique proof would cost an extra 20,000 gas. In the early testnet phase, the team decided this was acceptable because keepers were permissioned. But the permissioned set grew to 12 entities by mainnet. The developer comments read: "// proof uniqueness is not needed because keepers are trusted". Trust is a question unasked.

The Silent Signal: A Deep Dive into the ZK-Proof Replay Attack on OmniYield V3

Contrarian Angle: The Blind Spots of Formal Verification

The irony is that OmniYield V3 underwent a formal verification audit by a top-tier firm. The circuit was proven sound, and the harvest function's access control was validated. But formal verification often assumes that the protocol's unwritten rules — like "keepers will not replay proofs" — are enforced by the code. The auditors followed a threat model that assumed replay attacks were only possible via the KeeperRegistry, which was correctly implemented. However, the replay vector existed because the code did not enforce a nonce. Logic blooms where silence meets code — the silence was the missing state check.

This is the contrarian angle: the security community often celebrates formal verification as a silver bullet. In reality, it can create a false sense of safety. The auditors proved that every path in the code is mathematically correct, but they did not prove that the absence of a path is safe. The vulnerability was in what the compiler ignores: the implicit trust assumption between the onlyKeeper modifier and the replay guarantee. The bug hides in the beauty of the proven circuit.

Takeaway: Vulnerability Forecast

The OmniYield team patched the issue within 6 hours by adding a spentProof mapping and setting a nonce for each keeper. But the incident raises a broader question for the ZK-rollup ecosystem: how many other projects reuse proofs without unique transaction binding? I predict that within the next three months, at least two more protocols will suffer similar replay attacks. The exploit pattern is too elegant to remain isolated. Security is the shape of freedom — and freedom to replay a proof is anarchy.

What cannot be measured cannot be secured. The next time you see a `verify` call, ask: what binds this proof to this moment? The answer will tell you if the protocol is a cathedral or a house of cards.