Web3 & smart contracts
Partial12 minCross-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
- 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).
- 2The attacker captures the delivered message and its authorizing proof/signature from public data.
- 3The attacker resubmits the identical message to the destination handler.
- 4The destination has no consumed-set entry for this message (or no per-message nonce at all), so the check passes again.
- 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 ... } }
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.
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.
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.
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]EIP-712: Typed structured data hashing and signing (domain separation)
- [2]EIP-155: Simple replay attack protection (chain id in transactions)
- [3]OpenZeppelin — ECDSA & EIP-712 utilities
- [4]OWASP Smart Contract Top 10
- [5]Foundry Book — fork testing & cheatcodes
References point to public, well-known technical sources for further study.
Related articles
