Attested message hash omits the destination chain and endpoint, allowing cross-chain replay
src/libraries/MessageCodec.sol:L41–L52 (messageHash), src/Endpoint.sol:L208–L236 (receiveMessage)
- Likelihood
- High
- Impact
- High
- Weakness
- CWE-294
- Description
Attesters sign
keccak256(abi.encode(srcChainId, nonce, sender, receiver, keccak256(payload))). The destination chain ID and destination endpoint travel in the message envelope but are not part of the signed hash. Each endpoint records delivered messages bykeccak256(abi.encode(srcChainId, nonce))in its own storage.A message sent from Optimism to Avalanche is therefore also valid on Arbitrum: the signatures verify, and the Arbitrum endpoint has never seen that
(srcChainId, nonce). Many applications deploy receivers at the same address on every chain with deterministic deployment, so the Arbitrum endpoint resolves a real receiver that uses the same verifier.This is the root cause of the 22 July 2026 incident, in which a release message delivered on Avalanche was replayed on Arbitrum. Every attested message for an application deployed on more than one destination was replayable once on each other destination.
- Preconditions
- A receiver deployed at the same address on two or more destination chains.
- Access to any delivered message and its signatures, which are public calldata.
- Exploit scenario
- 1.A user bridges assets from Optimism to Avalanche; the application's receiver on Avalanche releases funds.
- 2.The replayer copies the message and signatures from the Avalanche delivery transaction.
- 3.It calls
receiveMessageon the Arbitrum endpoint. The hash matches, the threshold check passes and the receiver at the same address releases funds again from its Arbitrum liquidity.
- Impact
Duplicate execution of any message on each additional chain where the receiver exists, draining receiver liquidity.
- Recommendation
Sign EIP-712 typed data whose domain binds the destination chain ID and the destination endpoint as
verifyingContract, include source endpoint, destination fields and verifier version in the struct, reject messages not addressed toblock.chainidandaddress(this), and record delivered messages by full typed-data hash.- Fix
- src/libraries/MessageCodec.sol and src/Endpoint.sol (remediation commit)diff
- function messageHash(Message calldata m) internal pure returns (bytes32) {- return keccak256(abi.encode(m.srcChainId, m.nonce, m.sender, m.receiver, keccak256(m.payload)));- }+ bytes32 constant MESSAGE_TYPEHASH = keccak256(+ "Message(uint64 srcChainId,address srcEndpoint,uint64 dstChainId,uint64 nonce,address sender,address receiver,bytes32 payloadHash)"+ );+ function messageHash(Message calldata m) internal view returns (bytes32) {+ return _hashTypedDataV4(keccak256(abi.encode(+ MESSAGE_TYPEHASH, m.srcChainId, m.srcEndpoint, m.dstChainId, m.nonce, m.sender, m.receiver, keccak256(m.payload)+ )));+ } ... function receiveMessage(Message calldata m, bytes[] calldata sigs) external {- bytes32 key = keccak256(abi.encode(m.srcChainId, m.nonce));+ if (m.dstChainId != block.chainid) revert WrongDestination();+ bytes32 key = messageHash(m); // domain verifyingContract = address(this) if (delivered[key]) revert AlreadyDelivered(); - Resolution
Fixed on 12 August 2026 and verified. Attesters sign the new typed-data format; property P2 holds on the remediation commit. A scan of the 90 days before the incident found no earlier replays.
