🌳 Advanced ⏱️ 45 min

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.

📖 Why Bridges Are the Highest-Value Targets

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 Bridge Model
// 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 Model
// 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 ModelHow It WorksTrust AssumptionExamplesPrimary Attack Surface
Lock-and-Mint (Multisig)Lock native, mint wrapped, validators signMajority of validators honest and keys secureRonin (5/9), WormholeValidator key compromise, signature bypass
Burn-and-ReleaseBurn wrapped, validators confirm, release nativeSame as lock-and-mint, applied to exitMost lock-and-mint bridges (reverse)Burn proof forgery
Liquidity PoolPools of native tokens per chain, rebalancing messagesMessage relay network honestStargate, Hop ProtocolMessage forgery, pool rebalancing manipulation
OptimisticMessages accepted after 7-day fraud windowAt least one honest fraud watcher onlineAcross, Connext (older)Watcher DoS during fraud window
ZK ProofZK proof of source chain state verified on destinationSoundness of ZK proof system + correct circuitSuccinct, zkBridge, PolyhedraZK 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 Bridge: Trusted Root = bytes32(0)
// 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
🚨 The Nomad Copycat Phenomenon

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: Multisig Threshold Bypassed
// 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: Solana Signature Validation Bypass
// 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

🔍 Cross-Chain Replay Attack Prevention
// ❌ 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"); }
⚠️ The ecrecover(address(0)) Trap

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 and Amount Encoding Attacks
// 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

📖 How ZK Bridges Work

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 Verification Pattern
// 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

CategoryWhat to VerifySeverity if MissingNotes
Message ValidationEvery withdrawal is verified against proven root or valid threshold signaturesCriticalNomad bug: default state bypassed this
Replay ProtectionNonces/messageIds tracked; domain separator includes chainId and contract addressCriticalWithout chainId: replay across chains
ecrecover Safetysigner != address(0) checked immediately after ecrecoverCriticalInvalid sigs return address(0)
Validator ConcentrationNo single entity controls enough keys to reach signing thresholdCriticalRonin: one org held 5/9
Token AccountingMinted amount on destination equals locked amount on source (decimal normalized)CriticalDecimal mismatch = free money or loss
Deposit TrackingDeposits tracked on-chain, not just in eventsHighEvent-only tracking vulnerable to fake events
Admin KeysBridge admin in hardware wallet, multi-sig, with timelockHighOperational security, not just code
Fraud Window (Optimistic)Watchers are active and funded; fraud window is sufficient lengthHighWatcher DoS during fraud window = loss
💡 Bridge Audit Heuristic

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

🔍 Why Bridge TVL Is Uniquely Dangerous
// 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() returns address(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.