Skip to main content
Sigvex

Validator/Relayer Compromise Remediation

How to harden bridge validator and relayer infrastructure against compromise through high consensus thresholds, hardware key management, validator diversity, rotation schedules, and fraud proof challenge windows.

Validator/Relayer Compromise Remediation

Overview

Validator and relayer compromise attacks target the off-chain infrastructure that authorises on-chain bridge operations. Unlike smart contract bugs that can be exploited permissionlessly, validator compromise requires targeted infrastructure attacks, phishing, or insider access — but the potential payoff is correspondingly enormous. The Ronin Bridge lost $625M (March 2022) when an attacker compromised 4 developer-controlled validators through infrastructure attacks and socially engineered a 5th from an emergency-access node that had never had its permissions revoked. With 5 of 9 validators controlled, the attacker submitted fraudulent withdrawal transactions for 173,600 ETH and 25.5M USDC. The attack went undetected for six days.

The on-chain component of the defence is the signature threshold. The off-chain component is validator independence. Neither alone is sufficient: a high threshold is meaningless if the validators share infrastructure, and a diverse validator set is undermined by a low threshold that requires compromising only a minority.

Related Detector: Access Control Detector

Before (Vulnerable)

contract VulnerableBridge {
    address[] public validators; // 9 validators — 5-of-9 threshold (56%)
    uint256   public threshold = 5;

    function processWithdrawal(
        bytes32      messageHash,
        bytes[]      calldata signatures
    ) external {
        // VULNERABLE 1: Low threshold — compromising 5 of 9 grants full control
        require(signatures.length >= threshold, "Not enough signatures");

        uint256 validCount = _countValid(messageHash, signatures);
        require(validCount >= threshold, "Invalid signatures");

        // VULNERABLE 2: No timelock — funds released immediately
        // VULNERABLE 3: No monitoring window — attack detectable only after execution
        _transfer(messageHash);
    }
}

// VULNERABLE: Single relayer — one key controls all cross-chain messages
contract CentralizedRelayer {
    address public relayer;

    modifier onlyRelayer() { require(msg.sender == relayer); _; }

    // If relayer's private key is stolen, attacker controls everything
    function relayMessage(address to, uint256 amount) external onlyRelayer {
        _processTransfer(to, amount);
    }
}

After (Fixed)

contract SecureBridge {
    address[] public validators;
    uint256   public constant TOTAL_VALIDATORS = 21;
    uint256   public constant THRESHOLD        = 15;  // 71% consensus
    uint256   public constant TIMELOCK_DELAY   = 2 days;

    enum WithdrawalStatus { UNPROPOSED, PENDING, EXECUTED, CANCELLED }
    mapping(bytes32 => WithdrawalStatus) public withdrawalStatus;
    mapping(bytes32 => uint256)          public withdrawalExecuteAfter;

    event WithdrawalProposed(bytes32 indexed id, uint256 executeAfter);
    event WithdrawalExecuted(bytes32 indexed id);
    event WithdrawalCancelled(bytes32 indexed id, address guardian);

    function proposeWithdrawal(
        bytes32    messageHash,
        bytes[]    calldata signatures
    ) external {
        require(
            withdrawalStatus[messageHash] == WithdrawalStatus.UNPROPOSED,
            "Already proposed"
        );
        require(signatures.length >= THRESHOLD, "Insufficient signatures");
        require(
            _countUniqueValidSignatures(messageHash, signatures) >= THRESHOLD,
            "Invalid signatures"
        );

        withdrawalStatus[messageHash]      = WithdrawalStatus.PENDING;
        withdrawalExecuteAfter[messageHash] = block.timestamp + TIMELOCK_DELAY;

        emit WithdrawalProposed(messageHash, withdrawalExecuteAfter[messageHash]);
    }

    // Anyone can execute after the timelock expires — no single executor needed
    function executeWithdrawal(bytes32 messageHash) external {
        require(
            withdrawalStatus[messageHash] == WithdrawalStatus.PENDING,
            "Not pending"
        );
        require(
            block.timestamp >= withdrawalExecuteAfter[messageHash],
            "Timelock active"
        );
        withdrawalStatus[messageHash] = WithdrawalStatus.EXECUTED;
        emit WithdrawalExecuted(messageHash);
        _transfer(messageHash);
    }

    // Guardians can cancel fraudulent proposals during the challenge window
    function cancelWithdrawal(bytes32 messageHash) external onlyGuardian {
        require(
            withdrawalStatus[messageHash] == WithdrawalStatus.PENDING,
            "Not cancellable"
        );
        withdrawalStatus[messageHash] = WithdrawalStatus.CANCELLED;
        emit WithdrawalCancelled(messageHash, msg.sender);
    }

    function _countUniqueValidSignatures(
        bytes32    messageHash,
        bytes[]    calldata signatures
    ) internal view returns (uint256 count) {
        address lastSigner = address(0);
        for (uint256 i; i < signatures.length; i++) {
            address signer = _recover(messageHash, signatures[i]);
            // Sorted addresses ensure uniqueness without an additional mapping
            require(signer > lastSigner, "Signatures not sorted or duplicate");
            if (_isValidator(signer)) count++;
            lastSigner = signer;
        }
    }
}

Alternative Mitigations

Multi-party computation (MPC) signing — instead of each validator holding an independent key, use threshold signature schemes (e.g., FROST, GG20) where no single party ever holds the complete signing key. Compromising one party’s share reveals nothing about the full key:

// On-chain, the MPC output looks identical to a regular signature.
// The security difference is entirely off-chain:
//   Standard: validator_1 holds key_1, validator_2 holds key_2...
//   MPC:      No single party holds any complete key. Signing requires
//             threshold-many parties to cooperate in a distributed protocol.
//             Stealing one server yields zero signing capability.

contract MpcBridge {
    address public mpcGroupAddress; // Public key of the MPC group

    function processWithdrawal(bytes32 messageHash, bytes calldata signature) external {
        // Single signature from the MPC group — but off-chain it required
        // 15 of 21 parties to cooperate in the signing ceremony.
        address signer = ECDSA.recover(
            ECDSA.toEthSignedMessageHash(messageHash),
            signature
        );
        require(signer == mpcGroupAddress, "Invalid MPC signature");
        _transfer(messageHash);
    }
}

Validator rotation — implement on-chain validator set management with a mandatory rotation schedule to reduce the risk from long-term, patient compromise campaigns:

contract RotatingValidatorSet {
    address[] public validators;
    uint256   public lastRotation;
    uint256   public constant ROTATION_INTERVAL = 90 days;

    event ValidatorRotated(address[] newValidators, uint256 timestamp);

    function rotateValidators(
        address[]  calldata newValidators,
        bytes[]    calldata governanceSignatures
    ) external {
        require(
            block.timestamp >= lastRotation + ROTATION_INTERVAL,
            "Rotation too frequent"
        );
        require(newValidators.length == TOTAL_VALIDATORS, "Wrong validator count");
        // Require current validator set to approve the rotation
        require(
            _countValid(_rotationHash(newValidators), governanceSignatures) >= THRESHOLD,
            "Insufficient approval"
        );
        validators   = newValidators;
        lastRotation = block.timestamp;
        emit ValidatorRotated(newValidators, block.timestamp);
    }
}

Rate limiting and withdrawal caps — limit the maximum value that can be withdrawn in any rolling time window, reducing the impact of a successful compromise:

contract RateLimitedBridge {
    uint256 public constant DAILY_LIMIT  = 10_000_000e6; // $10M USDC per day
    uint256 public constant WINDOW       = 1 days;

    uint256 public windowStart;
    uint256 public withdrawnInWindow;

    function _checkRateLimit(uint256 amount) internal {
        if (block.timestamp >= windowStart + WINDOW) {
            windowStart      = block.timestamp;
            withdrawnInWindow = 0;
        }
        withdrawnInWindow += amount;
        require(withdrawnInWindow <= DAILY_LIMIT, "Daily withdrawal limit exceeded");
    }
}

Common Mistakes

Low threshold as a “convenience” setting — a 5-of-9 threshold was chosen by Ronin for operational convenience (lower latency). The consequence was that 5 parties needed to be compromised. A 15-of-21 threshold makes the same attack require compromising 15 independent parties — qualitatively harder.

Granting emergency access and never revoking it — the Ronin attack’s fifth validator was an emergency access grant from a year earlier that was never revoked. Audit all roles, revoke unused permissions, and use time-limited access grants where possible.

Centralised validator infrastructure — if 4 of 9 validators run on the same cloud account, they are 1 key, not 4. Require validators to attest to independent infrastructure, different legal jurisdictions, and different key custody approaches.

No on-chain monitoring or alerting hooks — the Ronin attack was undetected for six days because there was no automated alert on large withdrawals. Emit granular events for every validator signature, every withdrawal proposal, and every execution. Monitor these events externally and trigger alerts on unusual patterns.

No emergency pause below the validator threshold — a guardian role that can pause the bridge with fewer signatures than the full threshold provides a circuit breaker that can halt a detected attack before all funds are drained. Pausing is a restriction, not a privilege — it is safe to have a lower threshold for it.

References