Cross-chain bridge security guidance for message verification, replay prevention, and validator risk.
Bridges transfer assets/messages between blockchains. They are the highest-value targets in DeFi — $2B+ stolen from bridges. Core security concerns: message verification, validator security, and replay attacks.
┌─────────────────┐ ┌─────────────────┐
│ Chain A │ │ Chain B │
│ │ │ │
│ ┌───────────┐ │ Message │ ┌───────────┐ │
│ │ Bridge │ │ ──────────────────► │ │ Bridge │ │
│ │ Contract │ │ │ │ Contract │ │
│ └───────────┘ │ │ └───────────┘ │
│ │ │ │ │ │
│ Lock/Burn │ ┌──────────────┐ │ Mint/Unlock │
│ Tokens │ │ Validators │ │ Tokens │
│ │ │ / Relayers │ │ │
└─────────────────┘ └──────────────┘ └─────────────────┘
│
Sign attestations
Attack Vectors:
Checklist:
// VULNERABLE: Incomplete verification
function processMessage(
bytes32 messageHash,
bytes[] calldata signatures
) external {
uint256 validSigs;
for (uint i; i < signatures.length; i++) {
address signer = recoverSigner(messageHash, signatures[i]);
if (isValidator[signer]) validSigs++;
}
require(validSigs >= threshold, "Not enough sigs");
// Missing: Check for duplicate signers!
}
// SECURE: Track used signatures
function processMessage(
bytes32 messageHash,
bytes[] calldata signatures
) external {
uint256 validSigs;
address lastSigner;
for (uint i; i < signatures.length; i++) {
address signer = recoverSigner(messageHash, signatures[i]);
require(signer > lastSigner, "Invalid sig order"); // Prevents duplicates
if (isValidator[signer]) validSigs++;
lastSigner = signer;
}
require(validSigs >= threshold, "Not enough sigs");
}
Attack Vectors:
Checklist:
// VULNERABLE: No replay protection
function executeMessage(bytes calldata message, bytes calldata proof) external {
require(verifyProof(message, proof), "Invalid proof");
_execute(message); // Can be called again with same message!
}
// SECURE: Mark as processed
mapping(bytes32 => bool) public processedMessages;
function executeMessage(bytes calldata message, bytes calldata proof) external {
bytes32 messageHash = keccak256(message);
require(!processedMessages[messageHash], "Already processed");
require(verifyProof(message, proof), "Invalid proof");
processedMessages[messageHash] = true;
_execute(message);
}
Attack Vectors:
Checklist:
Attack Vectors:
Checklist:
// Ideal invariant
assert(wrappedTokenSupply <= originalTokenLockedAmount);
Attack Vectors:
Checklist:
What happened:
Root cause: Insufficient validator distribution + social engineering
What happened:
Root cause: Invalid signature verification on Solana side
What happened:
Root cause: Zero initialization treated as valid state
// The Nomad bug pattern
mapping(bytes32 => uint256) public messages;
function process(bytes memory _message) public {
bytes32 _messageHash = keccak256(_message);
// messages[_messageHash] == 0 was treated as confirmed!
require(acceptableRoot(messages[_messageHash]), "not accepted");
}
What happened:
Root cause: Insufficient validation of cross-chain call targets
struct Message {
uint256 srcChainId;
uint256 dstChainId;
address srcContract;
address dstContract;
uint256 nonce;
bytes payload;
}
function hashMessage(Message memory m) internal pure returns (bytes32) {
return keccak256(abi.encode(
m.srcChainId,
m.dstChainId,
m.srcContract,
m.dstContract,
m.nonce,
keccak256(m.payload)
));
}
function verifySignatures(
bytes32 hash,
bytes[] calldata sigs
) internal view returns (bool) {
address lastSigner = address(0);
uint256 valid;
for (uint i; i < sigs.length; i++) {
address signer = ECDSA.recover(hash, sigs[i]);
require(signer > lastSigner, "Signatures not ordered");
if (guardians[signer]) valid++;
lastSigner = signer;
}
return valid >= threshold;
}