Skip to main content
Sigvex

Bridge Message Remediation

How to secure cross-chain bridge message processing by implementing nonce-based replay protection, chain ID binding, explicit state initialization, and validator threshold requirements.

Bridge Message Remediation

Overview

Cross-chain bridge attacks have produced some of the largest losses in DeFi history. The Ronin Bridge ($625M, March 2022), Wormhole Bridge ($325M, February 2022), Nomad Bridge ($190M, August 2022), and Poly Network ($611M, August 2021) all shared a common thread: the bridge’s message validation logic had an exploitable gap that allowed attackers to fabricate or replay authorised messages without meeting the true security requirements.

The specific vulnerabilities span four categories: replay attacks (the same message processed twice because no nonce is tracked), cross-chain replay (a message signed for chain A accepted on chain B because the chain ID was not included in the signed hash), uninitialized state (Nomad-style: any message accepted as “not yet processed” because messages[hash] == 0 is indistinguishable from “never seen”), and signature verification bypass (Wormhole-style: a guardian signature check that could be circumvented through an implementation bug).

Related Detector: Access Control Detector

Before (Vulnerable)

contract VulnerableBridge {
    address public validator;
    mapping(bytes32 => bool) public processed;

    function processMessage(
        bytes calldata message,
        bytes calldata signature
    ) external {
        // VULNERABLE 1: No chain ID — same message valid on every EVM chain
        bytes32 messageHash = keccak256(message);

        // VULNERABLE 2: Boolean mapping — 'false' means both "never seen" and "unprocessed"
        // An attacker can craft an arbitrary message; its hash maps to false (default),
        // which the contract treats as "not yet processed" — Nomad-style exploit.
        require(!processed[messageHash], "Already processed");

        // VULNERABLE 3: No nonce — same signed message replayed after being marked processed
        // would be blocked, but there is no sequential ordering
        require(_verify(messageHash, signature), "Invalid signature");

        processed[messageHash] = true;
        _executeMessage(message);
    }
}

After (Fixed)

contract SafeBridge {
    address public validator;

    // Three-state enum eliminates the "uninitialized == unprocessed" ambiguity
    enum MessageStatus { UNINITIALIZED, PENDING, PROCESSED }
    mapping(bytes32 => MessageStatus) public messageStatus;

    // Per-sender sequential nonce prevents replay while preserving ordering
    mapping(address => uint256) public nonces;

    event MessageSubmitted(bytes32 indexed messageHash, address indexed sender);
    event MessageProcessed(bytes32 indexed messageHash);

    function submitMessage(bytes calldata message) external {
        // Include: chain ID (anti-cross-chain replay) + nonce (anti-same-chain replay)
        // + this contract address (anti-cross-contract replay)
        bytes32 messageHash = keccak256(abi.encodePacked(
            message,
            block.chainid,
            address(this),
            nonces[msg.sender]++
        ));

        // Explicitly transition from UNINITIALIZED to PENDING
        // An arbitrary unsubmitted hash will always be UNINITIALIZED, not PENDING
        require(messageStatus[messageHash] == MessageStatus.UNINITIALIZED, "Already submitted");
        messageStatus[messageHash] = MessageStatus.PENDING;

        emit MessageSubmitted(messageHash, msg.sender);
    }

    function processMessage(
        bytes32 messageHash,
        bytes calldata signature
    ) external {
        // Must have been explicitly submitted (PENDING) — not just defaulting to zero
        require(messageStatus[messageHash] == MessageStatus.PENDING, "Not pending");

        require(_verify(messageHash, signature), "Invalid validator signature");

        messageStatus[messageHash] = MessageStatus.PROCESSED;
        emit MessageProcessed(messageHash);

        _executeMessage(messageHash);
    }

    function _verify(bytes32 messageHash, bytes calldata sig) internal view returns (bool) {
        bytes32 ethHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash));
        address signer  = _recoverSigner(ethHash, sig);
        return signer == validator && signer != address(0);
    }
}

Alternative Mitigations

Threshold multi-validator approval — require M-of-N independent validator signatures on every message. A single compromised validator is insufficient to approve any withdrawal. The Ronin attack succeeded with 5-of-9; a 15-of-21 threshold would have required compromising 15 independent entities:

contract ThresholdBridge {
    address[] public validators;
    uint256   public constant THRESHOLD = 15; // 15-of-21 (71%)

    function processMessage(
        bytes32 messageHash,
        bytes[] calldata signatures
    ) external {
        require(messageStatus[messageHash] == MessageStatus.PENDING, "Not pending");
        require(signatures.length >= THRESHOLD, "Insufficient signatures");

        uint256 validCount;
        address lastSigner = address(0);

        for (uint256 i; i < signatures.length; i++) {
            bytes32 ethHash = keccak256(abi.encodePacked(
                "\x19Ethereum Signed Message:\n32", messageHash
            ));
            address signer = _recoverSigner(ethHash, signatures[i]);
            // Sorted unique addresses prevent double-counting a single validator
            require(signer > lastSigner, "Signatures not sorted or duplicate");
            if (_isValidator(signer)) validCount++;
            lastSigner = signer;
        }

        require(validCount >= THRESHOLD, "Insufficient valid signatures");
        messageStatus[messageHash] = MessageStatus.PROCESSED;
        _executeMessage(messageHash);
    }
}

Message expiry window — reject messages older than a defined window. This limits the damage from a validator compromise because stolen keys cannot be used to process historical messages indefinitely:

contract ExpiringBridge {
    uint256 public constant MESSAGE_EXPIRY = 24 hours;

    struct BridgeMessage {
        address  recipient;
        uint256  amount;
        uint256  timestamp;   // When the message was created on the source chain
        uint256  sourceNonce; // Sequential nonce from the source chain
    }

    function processMessage(BridgeMessage calldata msg_, bytes[] calldata sigs) external {
        require(
            block.timestamp <= msg_.timestamp + MESSAGE_EXPIRY,
            "Message expired"
        );
        // ... signature validation ...
    }
}

Large withdrawal timelocks — introduce a challenge period for large withdrawals so that monitoring systems can detect and cancel fraudulent messages before funds are released:

contract TimelockBridge {
    uint256 public constant SMALL_WITHDRAWAL_LIMIT = 10_000e6; // $10k USDC
    uint256 public constant TIMELOCK_DELAY         = 2 days;

    mapping(bytes32 => uint256) public pendingExecutionTime;

    function proposeWithdrawal(bytes32 messageHash, bytes[] calldata sigs) external {
        _validateSignatures(messageHash, sigs);

        if (withdrawalAmount[messageHash] > SMALL_WITHDRAWAL_LIMIT) {
            // Large withdrawals: schedule for future execution
            pendingExecutionTime[messageHash] = block.timestamp + TIMELOCK_DELAY;
        } else {
            // Small withdrawals: execute immediately
            _executeWithdrawal(messageHash);
        }
    }

    function executeTimelocked(bytes32 messageHash) external {
        require(pendingExecutionTime[messageHash] != 0, "Not pending");
        require(block.timestamp >= pendingExecutionTime[messageHash], "Timelock active");
        delete pendingExecutionTime[messageHash];
        _executeWithdrawal(messageHash);
    }

    // Guardians can cancel pending fraudulent withdrawals during the challenge window
    function cancelWithdrawal(bytes32 messageHash) external onlyGuardian {
        require(pendingExecutionTime[messageHash] != 0, "Not pending");
        delete pendingExecutionTime[messageHash];
        emit WithdrawalCancelled(messageHash);
    }
}

Common Mistakes

Including only the message content in the hash, not the chain ID — any signed message without block.chainid can be replayed verbatim on every EVM chain that shares the same validator set. Always include block.chainid and address(this) in the hash preimage.

Using a boolean processed mapping as the only replay guardmapping(bytes32 => bool) processed defaults to false for any key, making unsubmitted message hashes indistinguishable from submitted-but-not-yet-processed ones. Use an explicit three-value enum: UNINITIALIZED, PENDING, PROCESSED.

Granting and never revoking emergency validator access — the Ronin attack’s fifth validator key was obtained from an emergency access grant that had never been revoked after it was no longer needed. Audit and revoke stale permissions on a regular schedule. Use time-limited roles where possible.

Centralising validator infrastructure — if multiple validators share the same cloud provider, physical location, or key management system, their independence is an illusion. Require validators to self-attest to infrastructure diversity and rotate the validator set periodically.

References