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 Sky Mavis 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
Recommended Fix
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.