Remediating Bridge Security Vulnerabilities
How to protect cross-chain bridge implementations against missing message verification, replay attacks, unauthorized relay, and protocol-specific LayerZero, Axelar, and Hyperlane integration errors.
Remediating Bridge Security Vulnerabilities
Overview
Related Detector: Bridge Security Vulnerabilities
Cross-chain bridge vulnerabilities require defense in depth: message integrity (signatures), message uniqueness (replay protection), and access control (who can relay) must all be enforced simultaneously. A failure in any one layer can compromise the entire bridge. Use the protocol SDK base contracts wherever available — they encapsulate the security requirements for each messaging framework.
Recommended Fix
General Bridge: Verify-Then-Execute Pattern
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";
contract SecureBridge is AccessControl {
bytes32 public constant VALIDATOR_ROLE = keccak256("VALIDATOR_ROLE");
uint256 public immutable THRESHOLD;
mapping(bytes32 => bool) public executed;
event MessageExecuted(bytes32 indexed messageId, bytes payload);
constructor(address[] memory validators, uint256 threshold) {
require(threshold >= validators.length * 2 / 3, "Threshold too low");
THRESHOLD = threshold;
for (uint256 i = 0; i < validators.length; i++) {
_grantRole(VALIDATOR_ROLE, validators[i]);
}
}
function executeMessage(
bytes calldata message,
bytes[] calldata signatures
) external {
bytes32 messageId = keccak256(message);
// 1. Replay protection
require(!executed[messageId], "Already executed");
// 2. Signature threshold verification
uint256 validCount;
bytes32 ethHash = MessageHashUtils.toEthSignedMessageHash(messageId);
address lastSigner = address(0);
for (uint256 i = 0; i < signatures.length; i++) {
address signer = ECDSA.recover(ethHash, signatures[i]);
// Signers must be ordered to prevent double-counting the same key
require(signer > lastSigner, "Signatures not ordered");
if (hasRole(VALIDATOR_ROLE, signer)) {
validCount++;
}
lastSigner = signer;
}
require(validCount >= THRESHOLD, "Below signature threshold");
// 3. Mark executed before any external interaction (CEI)
executed[messageId] = true;
// 4. Execute payload
(address target, bytes memory callData) = abi.decode(message, (address, bytes));
(bool ok,) = target.call(callData);
require(ok, "Execution failed");
emit MessageExecuted(messageId, message);
}
}
LayerZero Integration
import "@layerzerolabs/solidity-examples/contracts/lzApp/NonblockingLzApp.sol";
contract SecureLzApp is NonblockingLzApp {
constructor(address _endpoint) LzApp(_endpoint) {}
// After deployment, register trusted remotes for each source chain:
// setTrustedRemote(srcChainId, abi.encodePacked(srcContractAddress, address(this)))
function _nonblockingLzReceive(
uint16 srcChainId,
bytes memory, // srcAddress — already validated by NonblockingLzApp
uint64 nonce,
bytes memory payload
) internal override {
// NonblockingLzApp guarantees:
// - srcAddress matches trustedRemoteLookup[srcChainId]
// - reverts are caught and stored (non-blocking — future messages can still arrive)
_processPayload(srcChainId, nonce, payload);
}
}
Axelar Integration
import "@axelar-network/axelar-gmp-sdk-solidity/contracts/executable/AxelarExecutable.sol";
import "@axelar-network/axelar-gmp-sdk-solidity/contracts/interfaces/IAxelarGateway.sol";
contract SecureAxelarApp is AxelarExecutable {
mapping(string => string) public trustedSenders; // chainId → address string
constructor(address _gateway) AxelarExecutable(_gateway) {}
function _execute(
string calldata sourceChain,
string calldata sourceAddress,
bytes calldata payload
) internal override {
// AxelarExecutable base has already called gateway.validateContractCall()
// but we add an additional trusted sender check for defense in depth
require(
keccak256(bytes(sourceAddress)) == keccak256(bytes(trustedSenders[sourceChain])),
"Untrusted sender"
);
_processPayload(sourceChain, sourceAddress, payload);
}
}
Hyperlane Integration
import {IMailbox} from "@hyperlane-xyz/core/contracts/interfaces/IMailbox.sol";
import {IMessageRecipient} from "@hyperlane-xyz/core/contracts/interfaces/IMessageRecipient.sol";
import {IInterchainSecurityModule, ISpecifiesInterchainSecurityModule}
from "@hyperlane-xyz/core/contracts/interfaces/IInterchainSecurityModule.sol";
contract SecureHyperlaneApp is IMessageRecipient, ISpecifiesInterchainSecurityModule {
IMailbox public immutable mailbox;
IInterchainSecurityModule public immutable ism; // Deploy and configure separately
mapping(uint32 => bytes32) public trustedSenders;
constructor(address _mailbox, address _ism) {
mailbox = IMailbox(_mailbox);
ism = IInterchainSecurityModule(_ism);
}
function interchainSecurityModule() external view override returns (IInterchainSecurityModule) {
return ism; // Mailbox calls this to determine which ISM to use
}
function handle(
uint32 origin,
bytes32 sender,
bytes calldata payload
) external override {
require(msg.sender == address(mailbox), "Only mailbox");
require(sender == trustedSenders[origin], "Untrusted sender");
_processPayload(origin, sender, payload);
}
}
Common Mistakes
Mistake: Not Ordering Validator Signatures
// WRONG: same validator can sign twice and be counted twice
for (uint256 i = 0; i < signatures.length; i++) {
address signer = ECDSA.recover(hash, signatures[i]);
if (isValidator[signer]) validCount++;
}
Sort signatures by recovered address and require strict ascending order to prevent the same key being used in multiple signature slots.
Mistake: Checking Replay After Execution
// WRONG: replay check after execution allows reentrancy or race conditions
target.call(callData); // Execute first
require(!executed[messageId], "Replayed"); // Check too late
executed[messageId] = true;
Follow Checks-Effects-Interactions: check replay → mark executed → call external.
Mistake: LayerZero lzReceive Without Trusted Remote Check
// WRONG: direct override of lzReceive without validation
function lzReceive(uint16, bytes memory, uint64, bytes memory payload) public override {
_process(payload); // No source validation
}
Extend NonblockingLzApp and override _nonblockingLzReceive instead. Never override lzReceive directly.