Bridge & Cross-Chain Security
Cross-chain bridges have collectively lost more money to exploits than any other category of DeFi protocol. The three largest hacks in crypto history — Ronin ($625M), Wormhole ($320M), and Nomad ($190M) — all targeted bridges. The reason is simple: bridges are forced to operate across trust boundaries that blockchains are not designed to handle natively. Understanding why bridges fail is essential for any security researcher working in DeFi.
A bridge holding 100,000 ETH on Ethereum is a single smart contract with a single set of keys controlling all of it. Unlike a DEX where value is spread across hundreds of pools, or a lending protocol where risk is partially isolated by market, a bridge's TVL is often fully concentrated and immediately accessible to anyone who can forge a valid withdrawal message. This concentration makes bridges uniquely attractive targets — the attacker's effort-to-reward ratio is extraordinarily favorable.
How Bridges Work: The Three Models
All bridges solve the same fundamental problem: how do you move value from chain A to chain B without a shared state? The answer always involves some form of message passing between chains, and the security of that message channel is the core attack surface.
// Lock-and-Mint: most common bridge design
// Step 1: User locks native ETH on Ethereum
function depositETH(address destinationRecipient) external payable {
lockedETH[msg.sender] += msg.value;
emit Deposit(msg.sender, destinationRecipient, msg.value, block.chainid);
// Event picked up by validators/relayers
}
// Step 2: Validators observe the deposit event on Ethereum
// Step 3: Validators sign a "mint message" for the destination chain
// Step 4: User (or relayer) submits the signed message on destination chain
function mintWrapped(
address recipient,
uint256 amount,
bytes32 sourceChainTxHash,
bytes[] memory validatorSignatures
) external {
// Verify signatures from required number of validators
require(
countValidSignatures(recipient, amount, sourceChainTxHash, validatorSignatures)
>= REQUIRED_SIGS,
"Insufficient validator signatures"
);
// Check the deposit has not already been processed
require(!processedDeposits[sourceChainTxHash], "Already processed");
processedDeposits[sourceChainTxHash] = true;
// Mint wrapped tokens on the destination chain
wrappedToken.mint(recipient, amount);
}
// To return: burn wrapped tokens → validators observe → unlock native ETH
// Liquidity Pool Bridge (Stargate, Hop Protocol)
// Each chain holds a pool of the SAME token (e.g., native USDC)
// No wrapped tokens: user gets native USDC on the destination
contract LiquidityBridge {
mapping(uint256 => address) public remotePool; // chainId => pool address
IERC20 public token; // Native USDC on this chain
function swap(
uint256 dstChainId,
address recipient,
uint256 amount
) external {
// Pull USDC from user on this chain
token.transferFrom(msg.sender, address(this), amount);
// Send a message to the destination pool to release USDC there
ILayerZero(endpoint).send(
uint16(dstChainId),
remotePool[dstChainId],
abi.encode(recipient, amount),
payable(msg.sender),
address(0),
""
);
}
// Called by LayerZero endpoint when a message arrives from another chain
function lzReceive(uint16 srcChainId, bytes memory payload) external {
require(msg.sender == address(endpoint), "Only endpoint");
(address recipient, uint256 amount) = abi.decode(payload, (address, uint256));
token.transfer(recipient, amount);
}
}
Bridge Architecture Trust Model Comparison
| Bridge Model | How It Works | Trust Assumption | Examples | Primary Attack Surface |
|---|---|---|---|---|
| Lock-and-Mint (Multisig) | Lock native, mint wrapped, validators sign | Majority of validators honest and keys secure | Ronin (5/9), Wormhole | Validator key compromise, signature bypass |
| Burn-and-Release | Burn wrapped, validators confirm, release native | Same as lock-and-mint, applied to exit | Most lock-and-mint bridges (reverse) | Burn proof forgery |
| Liquidity Pool | Pools of native tokens per chain, rebalancing messages | Message relay network honest | Stargate, Hop Protocol | Message forgery, pool rebalancing manipulation |
| Optimistic | Messages accepted after 7-day fraud window | At least one honest fraud watcher online | Across, Connext (older) | Watcher DoS during fraud window |
| ZK Proof | ZK proof of source chain state verified on destination | Soundness of ZK proof system + correct circuit | Succinct, zkBridge, Polyhedra | ZK circuit bugs, implementation errors |
Attack 1: Missing Message Validation — The Nomad Bridge ($190M)
The Nomad bridge exploit is famous for being so simple that hundreds of copycat attackers drained it within hours of the first exploit being discovered. The root cause was a single line of code added in a routine upgrade that made any message valid by default.
// Nomad's Replica.sol processes cross-chain messages
// Messages are proven against a Merkle root of valid messages
// SECURE design (before the bug):
mapping(bytes32 => MessageStatus) public messages;
// messages[hash] = MessageStatus.None (0) by default
// Messages are only set to Proven via prove() after Merkle verification
function process(bytes memory _message) public returns (bool) {
bytes32 _messageHash = keccak256(_message);
// Only process messages that have been proven via Merkle proof
require(
messages[_messageHash] == MessageStatus.Proven,
"not a proven message"
);
...
}
// BUGGY design (after the upgrade introduced the vulnerability):
function initialize(uint32 _remoteDomain, address _updater, bytes32 _committedRoot, uint256 _optimisticSeconds) public initializer {
...
// THIS LINE was added: marks bytes32(0) as PROVEN
messages[bytes32(0)] = MessageStatus.Proven;
// Fatal consequence: any message whose hash is NOT in the mapping
// returns the default value: 0 = MessageStatus.None
// Wait — but bytes32(0) IS the zero value, and the default is None (0)...
// The ACTUAL bug: prove() was changed to accept bytes32(0) as a valid root
// So prove() succeeded for ANY message without verifying the Merkle path
// → Any arbitrary message could be marked Proven
// → process() then executed it without resistance
}
// Attack: any attacker copies a legitimate past transaction,
// changes only the recipient to their own address,
// calls prove() (succeeds due to bug), then calls process()
// Bridge releases funds to attacker
Within hours of the first exploit transaction being visible on-chain, over 300 unique addresses drained Nomad. Many were opportunistic copycats who simply copied the calldata from the first exploit transaction and changed only the recipient address to their own wallet. No technical knowledge was required beyond knowing how to submit a transaction. The total loss was $190M across 300+ attackers. This illustrates a unique DeFi property: once an exploit is visible on-chain, it becomes freely available to anyone monitoring the mempool.
Attack 2: Validator Key Compromise — Ronin Bridge ($625M)
// Ronin Bridge (Axie Infinity sidechain) architecture
// Required: 5-of-9 validator signatures for any withdrawal
contract RoninBridge {
address[9] public validators;
uint256 public requiredSignatures = 5;
mapping(bytes32 => bool) public processedWithdrawals;
function withdraw(
address recipient,
uint256 ethAmount,
uint256 usdcAmount,
bytes32 withdrawalId,
bytes[] memory signatures
) external {
require(!processedWithdrawals[withdrawalId], "Already processed");
require(
_countValidSigs(recipient, ethAmount, usdcAmount, withdrawalId, signatures)
>= requiredSignatures,
"Insufficient signatures"
);
processedWithdrawals[withdrawalId] = true;
payable(recipient).transfer(ethAmount);
USDC.transfer(recipient, usdcAmount);
}
}
// The cryptography was sound. The key management was not.
// Sky Mavis controlled validator keys for nodes 1-4 (4 of 9)
// A temporary arrangement gave Axie DAO's key (node 5) to Sky Mavis
// to help with a gas-free transaction program — and was NEVER REVOKED
// Effective threshold: 5 of 9 controlled by one organization
// Lazarus Group (North Korea) attack:
// 1. Spear-phishing attack on Sky Mavis employees
// 2. Fake job offer PDF containing remote access trojan
// 3. Lateral movement through Sky Mavis infrastructure
// 4. Exfiltrated all 5 private keys
// 5. Forged 2 withdrawal transactions totaling 625,000,000 USD
// March 23: exploit occurred
// March 29: discovered (6 days later!) when a user couldn't withdraw
// Recovery: Axie raised $150M; Sky Mavis covered remainder over 3 years
Attack 3: Signature Verification Bug — Wormhole ($320M)
// Wormhole uses Guardian nodes to sign cross-chain messages
// On Solana, signature verification uses a system program instruction
// The vulnerable function (pseudocode — actual is Rust/Solana program):
// fn verify_signatures(ctx: Context, data: SignatureSet) -> Result<()> {
//
// // Load the "sysvar instructions" to check what instruction ran before
// let instruction_accounts = ctx.accounts.instruction_sysvar;
//
// // ❌ BUG: Does NOT verify that instruction_sysvar is the REAL sysvar
// // The account key is never checked against sysvar::instructions::id()
// // Attacker can pass ANY account as "instruction_sysvar"
//
// // Read "secp256k1 verification result" from attacker-controlled account
// // Attacker's fake account says: "yes, all 13 guardian signatures valid"
// // verify_signatures() returns Ok(()) — signatures "verified"
// }
// Attack sequence (February 2, 2022):
// 1. Attacker creates a fake "instruction sysvar" account
// 2. Populates it with data claiming 13/19 guardians signed a message
// 3. Calls verify_signatures() with the fake account
// 4. Wormhole accepts the fake verification → creates a VAA
// (Verified Action Approval = the signed message authorizing a bridge action)
// 5. Uses the fraudulent VAA to mint 120,000 wETH on Solana
// 6. Bridges 93,750 wETH back to Ethereum → receives real ETH
// 7. Total: ~$320M drained
// Fix: verify that the account passed as instruction_sysvar is ACTUALLY
// the system sysvar: require account.key == &sysvar::instructions::id()
Attack 4: Signature Replay Across Chains
// ❌ VULNERABLE: Message signed without chain ID or contract address
function verifyMessage_BUGGY(
address recipient,
uint256 amount,
bytes memory signature
) internal view {
// Hash does NOT include chainId or bridge contract address
bytes32 hash = keccak256(abi.encodePacked(recipient, amount));
address signer = ECDSA.recover(hash, signature);
require(isValidator[signer], "Invalid signer");
// Problem: The SAME signature is valid on Ethereum AND Polygon
// (both chains use the same validator set and same message format)
// Attacker takes a signature from Polygon, replays it on Ethereum
// Double spend: funds released on both chains for one deposit
}
// ✅ SECURE: EIP-712 with domain separator
bytes32 public immutable DOMAIN_SEPARATOR;
constructor() {
DOMAIN_SEPARATOR = keccak256(abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256(bytes("BridgeV1")),
keccak256(bytes("1")),
block.chainid, // ← Ethereum: 1, Polygon: 137, etc.
address(this) // ← This specific bridge contract
));
}
mapping(bytes32 => bool) public processedMessages;
function verifyMessage_SAFE(
address recipient,
uint256 amount,
bytes32 messageId, // Unique per message
bytes memory signature
) internal {
require(!processedMessages[messageId], "Already processed");
processedMessages[messageId] = true;
bytes32 structHash = keccak256(abi.encode(
WITHDRAWAL_TYPEHASH,
recipient,
amount,
messageId
));
bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash));
address signer = ECDSA.recover(digest, signature);
require(signer != address(0), "Invalid signature"); // ← CRITICAL check
require(isValidator[signer], "Not a validator");
}
Solidity's ecrecover() built-in returns address(0) for any malformed or invalid signature rather than reverting. If you check isValidator[signer] and isValidator[address(0)] is true (which could happen if the zero address was ever accidentally granted the validator role), then any invalid signature passes validation. Always add require(signer != address(0), "Invalid signature") as the very first check after calling ecrecover() or OpenZeppelin's ECDSA.recover().
Attack 5: Fake Deposits and Incorrect Amount Encoding
// Fake deposit: minting on destination without locking on source
// If the bridge relies ONLY on events and does not track deposits on-chain:
// ❌ VULNERABLE: No on-chain record of deposit
contract VulnerableBridge {
event Deposit(address sender, address recipient, uint256 amount);
function deposit(address recipient, uint256 amount) external {
IERC20(token).transferFrom(msg.sender, address(this), amount);
emit Deposit(msg.sender, recipient, amount); // Validators only read events
}
// If validator code has a bug processing the event,
// or if a malicious validator submits a fake event log,
// tokens can be minted on destination without a real deposit
}
// AMOUNT ENCODING BUG: decimal mismatch between chains
// USDC on Ethereum: 6 decimals (1 USDC = 1,000,000)
// Some chain's wrapped USDC: 18 decimals (1 USDC = 1,000,000,000,000,000,000)
// ❌ BUG: Raw amount passed without normalization
function bridgeUSDC(uint256 amount) external {
USDC.transferFrom(msg.sender, address(this), amount);
// Sends amount=1,000,000 (1 USDC) in the message
// Destination mints 1,000,000 wUSDC with 18 decimals
// 1,000,000 wUSDC (18 decimals) = $0.000000000001 ← WRONG
// Or: 1,000,000 wUSDC is treated as 1,000,000 * 1e18 units = $1 trillion
}
// ✅ CORRECT: Normalize to a standard unit (e.g., 8 decimals) before encoding
uint256 normalizedAmount = amount * 10 ** (8 - USDC.decimals()); // → 8 decimal standard
ZK Bridges: Superior Security Model
ZK bridges replace trusted validators with cryptographic proofs. Instead of "5 people signed off on this deposit," the system produces a zero-knowledge proof that says: "here is a mathematical proof that this specific deposit transaction is included in a finalized Ethereum block." The destination chain verifies this proof on-chain — no trust in human validators required. The trust model reduces to: (1) soundness of the ZK proof system (e.g., Groth16, PLONK), and (2) correctness of the circuit implementation. Current limitations: very high proving costs ($5-50 per proof) and extreme complexity of circuit auditing.
// ZK Bridge destination contract
// Instead of validating signatures, it verifies a ZK proof
interface IGroth16Verifier {
function verifyProof(
uint256[2] memory a,
uint256[2][2] memory b,
uint256[2] memory c,
uint256[] memory pubInputs
) external view returns (bool);
}
contract ZKBridgeReceiver {
IGroth16Verifier public verifier;
function claimDeposit(
address recipient,
uint256 amount,
bytes32 sourceBlockHash, // Ethereum block containing the deposit
// ZK proof that the deposit tx is in that block:
uint256[2] memory proofA,
uint256[2][2] memory proofB,
uint256[2] memory proofC
) external {
// Public inputs to the ZK circuit
uint256[] memory pubInputs = new uint256[](4);
pubInputs[0] = uint256(sourceBlockHash);
pubInputs[1] = uint256(uint160(recipient));
pubInputs[2] = amount;
pubInputs[3] = uint256(depositId); // Prevents replay
// Verify the ZK proof on-chain — no trust in humans required
require(
verifier.verifyProof(proofA, proofB, proofC, pubInputs),
"Invalid ZK proof"
);
wrappedToken.mint(recipient, amount);
}
}
Bridge Security Audit Checklist
| Category | What to Verify | Severity if Missing | Notes |
|---|---|---|---|
| Message Validation | Every withdrawal is verified against proven root or valid threshold signatures | Critical | Nomad bug: default state bypassed this |
| Replay Protection | Nonces/messageIds tracked; domain separator includes chainId and contract address | Critical | Without chainId: replay across chains |
| ecrecover Safety | signer != address(0) checked immediately after ecrecover | Critical | Invalid sigs return address(0) |
| Validator Concentration | No single entity controls enough keys to reach signing threshold | Critical | Ronin: one org held 5/9 |
| Token Accounting | Minted amount on destination equals locked amount on source (decimal normalized) | Critical | Decimal mismatch = free money or loss |
| Deposit Tracking | Deposits tracked on-chain, not just in events | High | Event-only tracking vulnerable to fake events |
| Admin Keys | Bridge admin in hardware wallet, multi-sig, with timelock | High | Operational security, not just code |
| Fraud Window (Optimistic) | Watchers are active and funded; fraud window is sufficient length | High | Watcher DoS during fraud window = loss |
When auditing a bridge, always ask: "What is the minimal set of conditions an attacker needs to forge a valid withdrawal?" In the Nomad exploit, the answer was: literally nothing — any transaction worked. In the Ronin exploit, it was: 5 private keys. In the Wormhole exploit, it was: one Solana transaction with a crafted account. The shorter and more achievable that list of conditions, the higher the severity. If forging a withdrawal requires compromising nothing, that is an instant Critical.
TVL Concentration and Bridge Risk
// Compare attack complexity vs reward for different DeFi protocol types:
// AMM ($100M TVL):
// - Value split across potentially 1000+ pools
// - Each attack targets one pool at a time
// - Max single attack: ~$1-5M (largest single pool)
// - Attack requires: specific vulnerability in pool contract
// Lending Protocol ($100M TVL):
// - Value partially protected by collateral ratios
// - Oracle manipulation requires capital + timing
// - Max attack: $50-80M (above-collateral bad debt)
// Bridge ($100M TVL):
// - ALL value in ONE contract
// - ONE valid signed message = ALL funds released
// - Max attack: $100M (the entire TVL)
// - Attack requires: only break the signing/validation mechanism
// This concentration is WHY bridges are the most hacked category
// The reward-to-effort ratio is simply higher than any other target
Key Takeaways
- Bridges are uniquely high-value targets because their TVL is concentrated in a single contract accessible via one mechanism — forging a valid message.
- The Nomad exploit shows how a single default value (bytes32(0) treated as proven) can make every message valid without any technical sophistication.
- Validator key security is an operational problem, not just a cryptographic one — social engineering and insufficient key distribution are the root causes of the largest bridge hacks.
- Signature validation must include chain ID and contract address in the signed domain to prevent replay attacks across chains or between contract versions.
ecrecover()returnsaddress(0)for invalid signatures — always check for this before consulting the validator mapping.- Token amounts must be normalized for decimal differences between source and destination chains — a naive raw amount transfer causes either a massive minting exploit or a loss of precision.
- ZK bridges offer the strongest cryptographic security model, but circuit implementation bugs are a newly emerging attack surface that requires specialized expertise to audit.