Why bridges fail in three repeated patterns
A cross-chain bridge is a state machine that says "this thing on chain A authorises that thing on chain B." Everything else — the validator set, the multisig, the signature scheme, the proof verifier — is plumbing around that one sentence. When a bridge gets exploited, it is almost always because the plumbing failed in one of three places:
- Validator key compromise — the off-chain set that signs withdrawals is too small, too centralised, or too easily phished.
- Signature / proof verification gap — the on-chain verifier accepts a value it should not, because of a guardian-set bug, a missing default check, or a stale storage slot.
- Replay or initialisation flaw — a message that was already executed, or a default-zero root, gets accepted as fresh.
Ronin was case 1. Wormhole was case 2. Nomad was case 3. Recurring incidents on newer messaging stacks fit the same shapes. The surface area changes (LayerZero DVN sets, Wormhole's new guardian rotation, custom rollup canonical bridges) but the failure mode rarely does.
For a reviewer or pentester, this is good news: there is a finite checklist, and each item has a corresponding Foundry fork-test you can write in under an hour.
Pattern 1: Validator key compromise (the Ronin shape)
The Ronin Bridge had nine validator nodes and required five signatures to authorise a withdrawal. Five keys were obtained — four from Sky Mavis infrastructure, one from a third-party validator whose access had been left in place after a partnership ended. The signatures were valid. The contract did not see anything wrong because, on-chain, nothing was wrong.
What you can detect on-chain:
- Validator-set centralisation. Count how many validators are operationally controlled by one entity. A "5 of 9" multisig where 6 keys live on the same VPC is a "1 of 9" multisig with extra steps.
- Stale validator entries. Permission-revocation that requires governance is brittle; permission-revocation tied to active heartbeats is more robust.
- Single-signer privileged paths. Many bridges have an "emergency" or "upgrade" path that bypasses the multisig. That path is the bridge's actual security boundary.
A Foundry test cannot detect a key compromise — that is an off-chain ops problem — but it can flag the privileged-path surface so a reviewer knows where to look:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Test} from "forge-std/Test.sol";
interface IBridge {
function owner() external view returns (address);
function emergencyWithdraw(address token, uint256 amount, address to) external;
}
contract PrivilegedPathSurfaceTest is Test {
IBridge bridge;
function setUp() public {
// Pin to a specific block so the test is reproducible.
vm.createSelectFork(vm.envString("MAINNET_RPC_URL"), 18_500_000);
bridge = IBridge(0xDEAD_DEAD_DEAD_DEAD_DEAD_DEAD_DEAD_DEAD_DEAD_DEAD);
}
function test_PrivilegedPathExists() public view {
address o = bridge.owner();
emit log_named_address("bridge owner (privileged path)", o);
// Use cast code <addr> off-test to confirm whether owner is an EOA, a
// Safe, or a Timelock — each implies a different operational risk.
}
}
The test does not "fail" — it produces evidence. That is the right mode for this class. The reviewer's job is to write a one-page note saying "the bridge has a privileged owner path; here is what controls that key."
Pattern 2: Signature / proof verification gap (the Wormhole shape)
Wormhole's February 2022 incident was a missing check on the guardian set. The verifier looked up the guardian set by index and, when given an out-of-range index, used a default-zero address as the signer. The attacker submitted a fabricated VAA whose claimed signer was the zero address, the verifier saw a "match," and 120,000 wETH was minted on Solana with no Ethereum collateral behind it.
The pattern repeats anywhere a bridge:
- accepts a "signer index" or "validator id" from the message itself, and
- looks that index up in storage that may be uninitialised, and
- compares the recovered signer to the looked-up value without first asserting the lookup returned a real entry.
Slither has the static-analysis muscle for this. The controlled-delegatecall and uninitialized-state detectors flag adjacent shapes, and a custom detector for "ecrecover output compared to a storage-loaded address that was never asserted non-zero" is a half-day project. From Crytic's documented detector pattern, the controlled-delegatecall flag emits this kind of trace:
C.bad_delegate_call(bytes) uses delegatecall to a input-controlled function id
- addr_bad.delegatecall(data)
For bridge verifier audits, write a Foundry test that reaches the verifier directly with a malformed VAA whose signer recovery returns address(0), and assert the call REVERTS, not succeeds. If it succeeds — even on a fork pinned to a benign block — you have just rediscovered the Wormhole class.
function test_VerifierRejectsZeroSigner() public {
bytes memory malformedVAA = _craftVAAWithOutOfRangeIndex();
vm.expectRevert(); // any revert is acceptable; success is the bug
verifier.parseAndVerifyVM(malformedVAA);
}
If you cannot get the verifier to revert by sending an out-of-range index, the bug exists. That is the entire test.
Pattern 3: Replay or initialisation flaw (the Nomad shape)
Nomad's August 2022 incident was a single line. During an upgrade, the trusted-roots mapping was migrated, and the zero hash — bytes32(0) — was committed as a "valid" root by accident. From that moment, any unprocessed message whose confirmAt slot defaulted to bytes32(0) looked confirmed. Anyone could re-encode any prior transfer as their own and the bridge would honour it. The exploit was copy-pasted from one wallet to another for hours; that is what made the loss widespread rather than concentrated.
The Nomad pattern shows up wherever:
- a default value (
0x0,bytes32(0),address(0)) is treated as semantically meaningful by ANY downstream check; - migrations or upgrades touch the storage slot containing that default; or
- a "valid root" registry is updated by an action other than the rooted operation itself.
The Foundry pattern for catching this:
function test_ZeroRootIsNotConfirmed() public {
// After deploy, BEFORE any legitimate root is committed, the zero root
// must be treated as un-confirmed. If confirmAt(bytes32(0)) returns
// anything that downstream code reads as "valid," the bridge has the
// Nomad shape.
uint256 confirmedAt = bridge.confirmAt(bytes32(0));
assertEq(confirmedAt, 0, "zero-root must not be auto-confirmed");
// Even more important: assert that submitting a message rooted at 0x0
// reverts cleanly.
bytes memory msg0 = _emptyMessage();
vm.expectRevert();
bridge.process(msg0);
}
The cheapest way to catch this in continuous CI is to bake the assertion above into an invariant test: across any sequence of legitimate operations (commit-root, prove, process), the zero root must remain un-confirmed. Foundry's invariant runner generates random call sequences and asserts the property after each; the moment a sequence breaks the assertion, the framework prints the minimal counter-example. The invariant scaffold is tiny:
contract BridgeInvariants is Test {
Bridge bridge;
function setUp() public { bridge = new Bridge(/* init */); }
function invariant_ZeroRootStaysUnconfirmed() public view {
require(bridge.confirmAt(bytes32(0)) == 0, "zero root confirmed!");
}
}
Per Foundry's documented invariant-testing scaffold, this is the same shape used to verify token conservation laws and AMM curve preservation. It generalises: any bridge invariant ("the contract holds at least the sum of un-claimed deposits"; "the relayed-message count never decreases") plugs into the same harness.
Comparison: where each pattern surfaces in tooling
| Pattern | Static analysis (Slither) | Fork test (Foundry) | Invariant fuzzer |
|---|---|---|---|
| Validator key compromise (Ronin) | Privileged-path inventory; off-chain context required | Surface enumeration test | N/A — operational risk |
| Verification gap (Wormhole) |
uninitialized-state, custom ecrecover-equality detector |
Negative test (malformed input must revert) | N/A — single-tx attack |
| Replay / init flaw (Nomad) |
uninitialized-state, custom default-root detector |
assertEq(confirmAt(zero), 0) |
Yes — invariant_ZeroRootStaysUnconfirmed
|
The point of the table is the leftmost column: each class has a static-analysis tell. None of these incidents were "novel" in the academic sense. They were surface findings that a tool already shipping in 2022 — Slither, Foundry, OpenZeppelin's proxy and access-control libraries — would have flagged with the right rule. The incidents that reach headlines today carry the same shape.
Practical checklist for bridge reviewers
Before you greenlight a cross-chain bridge for production:
- Enumerate every privileged path. Owner, guardian, emergency-withdraw, upgrade, pause. For each, document the key custody and the rotation policy.
- Pin a fork to the deploy block and run negative tests. Out-of-range indices, malformed signatures, zero-default lookups — each must revert.
- Bake invariants into CI. Token conservation, root non-default, message-count monotonicity. Foundry's invariant runner is free and catches the Nomad class deterministically.
- Walk the off-chain side. A bridge's security boundary is wherever the lowest-trust component lives. If five validator keys live on one cloud account, that is the boundary.
- Treat post-mortems as test corpus. Ronin, Wormhole, Nomad, Multichain (July 2023, $126M), and Euler Finance (March 2023, $197M, related class via flawed donate-and-self-liquidate logic) are not "old news." They are reproducible regression tests. Every new incident is another regression test waiting to be encoded.
The recurring lesson is unglamorous: bridges that fail tend to fail at boundaries we already know how to test. The work is in writing the test for YOUR application's specific threat model — not in waiting for the next post-mortem to write them retroactively.
Soken builds and reviews cross-chain infrastructure end-to-end — validator coordination, signature verification, and L1↔L2 message integrity. Public audit reports live at github.com/sokenteam; the team page is at soken.io.
Top comments (0)