Skip to main content
Sigvex

Remediating Bridge Security Vulnerabilities

How to protect cross-chain bridge implementations against missing message verification, replay attacks, unauthorized relay, and protocol-specific a cross-chain messaging protocol, 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.

General Bridge: Verify-Then-Execute Pattern

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@audited/utils/cryptography/ECDSA.sol";
import "@audited/utils/cryptography/MessageHashUtils.sol";
import "@audited/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);
    }
}

a cross-chain messaging protocol 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: a cross-chain messaging protocol 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.

References