ROOTVIELROOTVIEL
← Knowledge Base

Web3 & smart contracts

Partial12 min

Cross-Chain Message Replay

A cross-chain message that was meant to execute exactly once on the destination is accepted and executed more than once — because the destination does not bind each message to a unique, consumed identity (per-message nonce) and to the correct source and chain.

Classification

Domain
Web3 & smart contracts
ROOTVIEL support
Partial — Core is built; parts are not yet wired end-to-end.
Analysis family
RootRaven
CWE
CWE-294, CWE-345
OWASP
SC02

Definition

Cross-chain message replay is the re-execution of a single, canonical cross-chain instruction on the destination chain. A source chain emits a message — "mint 100 tokens to Alice", "release the bridged deposit" — that a relayer or user delivers to a destination contract. If the destination does not record that this specific message has already been consumed, the same message (or a bit-identical copy) can be submitted again and its effect applied repeatedly.

A closely related variant is cross-domain replay: a message validly produced for chain A is replayed on chain B because the message, or the signature authorizing it, is not bound to a chain identifier. In both cases the failure is the absence of a consumed-exactly-once identity for the message at the point of execution.

Context

This class appears throughout cross-chain infrastructure: token bridges, generalized messaging layers, and any contract that acts on an instruction asserted to have originated elsewhere. It is a frontier area — support is partial — because correctness depends not only on the destination contract but on the delivery and finality assumptions of the messaging layer beneath it.

  • ▸Lock-and-mint / burn-and-release token bridges where a release is authorized by a source-chain event.
  • ▸Generalized message-passing layers delivering arbitrary calldata to a destination handler.
  • ▸Signature-authorized cross-chain actions where one signature could be valid on multiple chains.
  • ▸Multi-deployment systems where the same contract address exists on several chains.

Technical fundamentals

Exactly-once execution across chains is not a primitive the EVM provides; it must be constructed. The destination needs three bindings per message: a unique message identifier that it marks as consumed (so a second delivery is rejected), a source binding (which chain and which authorized sender the message came from), and, where a signature authorizes the action, a domain binding so the signature cannot be reused on another chain or contract. Omit any one and a replay becomes possible.

The standard construction is a monotonic or unique nonce per message, persisted in a consumed set (a mapping from message hash or nonce to a boolean), checked-and-set atomically before effects run. Signature-authorized flows add EIP-712 domain separation, which folds the chain id and the verifying contract address into the signed digest so a signature is valid only on its intended chain and contract. Delivery-layer idempotency (the relayer deduplicates) is not a substitute: the destination contract is the authority that must enforce once-only.

Message nonce
A unique identifier per cross-chain message, recorded as consumed so re-delivery is rejected.
Consumed set
Persisted mapping marking which message ids/nonces have already executed; checked-and-set before effects.
Domain separation (EIP-712)
Binding a signed message to a chain id and verifying contract so the signature cannot be replayed elsewhere.
Source binding
Verifying the message truly originated from the expected chain and authorized sender.

Root cause

The root cause is a missing or incomplete consumed-exactly-once check at the destination: the message carries no unique identity, or the identity is not persisted-and-checked before the effect, or it is not bound to the chain and source, so two acceptances of the same message both pass. In signature-authorized designs, the specific defect is a signed payload lacking chain-id/contract domain separation, letting one signature authorize the action on more than one chain.

It is a protocol-design defect: each individual contract may validate inputs correctly, yet the system lacks a global uniqueness invariant for cross-chain effects.

How the attack works, step by step

  1. 1A legitimate cross-chain message is produced on the source chain and delivered once to the destination, executing its effect (e.g. a mint or release).
  2. 2The attacker captures the delivered message and its authorizing proof/signature from public data.
  3. 3The attacker resubmits the identical message to the destination handler.
  4. 4The destination has no consumed-set entry for this message (or no per-message nonce at all), so the check passes again.
  5. 5The effect executes a second time — minting or releasing funds that were authorized only once; repeat to drain.

Cross-domain variant: the attacker takes a signature valid for chain A and submits it to the same contract deployed at the same address on chain B; with no chain-id domain binding, ecrecover yields the authorized signer and the action executes on a chain it was never intended for.

Vulnerable example

A synthetic, illustrative destination handler that executes a signed instruction with no consumed set and no chain binding:

synthetic — do not use; illustrates the flaw
// SPDX-License-Identifier: MIT // UNSAFE: no per-message nonce, no consumed set, no chain-id binding. pragma solidity ^0.8.20; contract NaiveBridgeReceiver { address public immutable signer; // the authorized source authority mapping(address => uint256) public balance; constructor(address s) { signer = s; } function release(address to, uint256 amount, bytes calldata sig) external { // digest omits any nonce and any chain id / contract address bytes32 digest = keccak256(abi.encodePacked(to, amount)); address rec = _recover(digest, sig); require(rec == signer, "bad signer"); balance[to] += amount; // can be replayed again and again } function _recover(bytes32 d, bytes calldata s) internal pure returns (address) { // ... ecrecover omitted for brevity ... } }
Note

The same (to, amount, sig) can be submitted repeatedly — each call credits again. And because the digest omits the chain id, the same signature is valid on every chain this contract is deployed to.

Impact

  • ▸Fund loss / inflation: a one-time authorized mint or release is applied many times, draining the bridge or inflating supply.
  • ▸Integrity: destination state diverges from the source’s true intent (a single transfer counted multiple times).
  • ▸Cross-domain abuse: an action authorized for one chain executes on another with no authorization there.
  • ▸Systemic: because bridges hold pooled collateral, a replay can affect every user of the bridge, not only the message’s subject.

Detection

Static analysis checks destination handlers for the three bindings: is there a per-message nonce or message id, is it persisted in a consumed set and checked-and-set before effects, and does the signed/authorized payload include the chain id and verifying contract? Absence of any is a precondition finding. Static analysis cannot by itself confirm the delivery layer’s assumptions (relayer dedup, finality), so it should not conclude safety.

Dynamic detection replays a delivered message on a local fork — submit the same message twice and observe whether the effect applies twice — and, for cross-domain, submits a chain-A signature to a chain-B deployment. An observed double-effect is a reproduced finding; a single rejection on the paths tried is informational. Differentiate a static missing-check finding from a dynamic reproduced replay from a tool counterexample.

How autonomous agents investigate it

An autonomous agent models the end-to-end path: what the source emits, what the destination accepts, and what uniqueness the destination enforces. It looks for a per-message nonce threaded into the effect, a persisted consumed set checked before state change, source/sender verification, and — in signed flows — chain-id and contract binding inside the digest. It also notes the finality assumption: does the destination act before the source event is final?

A missing consumed-set write is a SIGNAL. The agent then constructs the concrete replay — resubmit the identical message, or submit a foreign-chain signature — and predicts the doubled effect. Only after executing both deliveries on a disposable local fork and observing the state applied twice does it record a FINDING. Because cross-chain correctness also rests on the messaging layer’s guarantees, which a single-chain fork cannot fully reproduce, the honest default for the unmodeled parts is UNKNOWN — the agent must not call the bridge "safe".

How RootRaven analyzes it

Support level
Partial — the destination-side replay (missing nonce/consumed set, missing chain binding) is reproducible on a local fork, but full cross-chain finality and relayer behaviour are only partially modeled; frontier area.
Evidence model
Evidence-first. A missing consumed-set check is a signal; a Finding requires delivering the same message twice (or a foreign-chain signature) on a disposable local chain/fork and observing the effect applied more than once.
Declared property
“Each cross-chain message executes at most once at the destination, and an authorization is valid only on its intended chain and contract.”

RootRaven uses AI only to compose the candidate replay — which message to resubmit, which signature to port across domains, in what order. It then stands up the destination contract on a local blockchain or a local fork and executes the duplicate delivery. A deterministic, AI-free verifier reads the affected balances/state after the first and second deliveries and checks the at-most-once property: did the second delivery change state? Only a reproduced double-execution is graded a Finding. There is no live-network attack mode — proof is obtained only against disposable local state, and the cross-chain finality dimension is simulated, not guaranteed.

Partial

A clean run means no replay was reproduced on the modeled paths — UNKNOWN, not "safe" — and it says nothing about the live messaging layer’s finality and relayer assumptions. Maturity here is explicitly partial; keep the caveat.

Mitigation

Give every message a consumed-exactly-once identity bound to its source and chain, and check-and-set it before any effect.

  • ▸Preventive: assign a unique per-message nonce/id; persist it in a consumed set; require-not-consumed then mark-consumed before running effects (effects-after-mark).
  • ▸Preventive: bind the message to source chain id and authorized sender; verify both at the destination.
  • ▸Preventive (signed flows): use EIP-712 domain separation so chain id and verifying contract are inside the signed digest, preventing cross-domain reuse.
  • ▸Preventive: act only on final source state (respect the messaging layer’s finality), and prefer audited, standardized messaging layers over bespoke relays.

Corrected example

The handler threads a per-message nonce through an EIP-712 digest that includes the chain id, and consumes the nonce before applying the effect:

synthetic — consumed set + EIP-712 domain binding
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract SaferBridgeReceiver { address public immutable signer; uint256 public immutable srcChainId; // bind to the source domain mapping(bytes32 => bool) public consumed; // consumed-exactly-once set mapping(address => uint256) public balance; bytes32 private immutable DOMAIN; // EIP-712 domain: name, version, // block.chainid (this chain), address(this) constructor(address s, uint256 _src, bytes32 domain) { signer = s; srcChainId = _src; DOMAIN = domain; } function release( address to, uint256 amount, uint256 nonce, bytes calldata sig ) external { bytes32 structHash = keccak256( abi.encode(to, amount, nonce, srcChainId) // nonce + source domain ); bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN, structHash)); require(_recover(digest, sig) == signer, "bad signer"); bytes32 id = keccak256(abi.encode(digest)); require(!consumed[id], "already executed"); // check consumed[id] = true; // mark BEFORE effect balance[to] += amount; // effect runs once } function _recover(bytes32 d, bytes calldata s) internal pure returns (address) { // ... ecrecover with malleability + zero-address checks ... } }

Why it works: the nonce makes each message a distinct id; the consumed mapping, set before the effect, rejects any re-delivery; folding srcChainId and the EIP-712 domain (this chain id and this contract) into the digest stops a signature from being reused on another chain or contract.

Note

Limits: this protects the destination contract only. It cannot compensate for a messaging layer that forwards messages from a reorganized (non-final) source block, nor for a compromised signer/validator set — those are separate failures (see bridge authorization). The nonce space and consumed-set growth must also be managed to avoid unbounded storage.

Verifying the mitigation

Verify on a local fork: deliver a valid message, then redeliver the identical message and confirm the second call reverts with no state change; take a signature valid for the configured source and submit it against a deployment with a different chain id and confirm it is rejected; confirm the consumed-set entry is written before the effect. Report "no replay reproduced within the tested scope", never immunity, and state explicitly that the messaging-layer/finality dimension was simulated.

Note

Local verification cannot establish the live relayer’s deduplication or the source chain’s finality; those must be argued from the messaging layer’s own guarantees.

Limitations & honesty

  • ▸Local reproduction covers the destination contract; it does not prove the cross-chain messaging layer’s finality or relayer guarantees.
  • ▸A clean run is UNKNOWN for untried messages, not a safety proof.
  • ▸Consumed-set and nonce designs must bound storage growth; a correct-but-unbounded design has its own operational risk.
  • ▸No figures, incident counts, or CVE/SWC identifiers are asserted beyond the referenced, verifiable sources.

References

  1. [1]EIP-712: Typed structured data hashing and signing (domain separation)
  2. [2]EIP-155: Simple replay attack protection (chain id in transactions)
  3. [3]OpenZeppelin — ECDSA & EIP-712 utilities
  4. [4]OWASP Smart Contract Top 10
  5. [5]Foundry Book — fork testing & cheatcodes

References point to public, well-known technical sources for further study.

Related articles