← Research

How cross-chain bridges get drained

A bridge is two contracts on two chains and a promise between them: lock value here, release the same value there. The lock is rarely where things break. The releases are. Almost every large bridge loss comes back to a single decision in the destination contract, the moment it answers one question. Is this message from the other chain genuine?

That question is the whole product. The token custody, the relayers, the fee accounting are supporting cast. If an attacker can make the destination contract believe in a message that no honest source ever sent, the locks on the other side stop mattering. They mint or unlock against a deposit that was never made.

What the verifier actually checks

Strip a bridge down and the destination side does four things with an incoming message:

  • Confirms who attested to it: a validator signature, a light-client proof, a Merkle root from the source chain.
  • Confirms the message says what it claims: amount, recipient, token, source contract.
  • Confirms it has not been used before.
  • Then acts on it: release, mint, or call.

Each of those is a place to be wrong, and the wrongness is quiet. The happy path passes every test in the suite, because the test suite feeds it honest messages. The bug lives in the message the authors never imagined receiving.

Where verification goes soft

A handful of shapes recur. They are worth naming because once you have seen them, you read every bridge for them.

The proof verifies, but not against the right authority. A signature check is only as good as the key set it checks against. If the validator set, the guardian list, or the trusted root can be supplied or influenced by the same message it is meant to authorize, the check is decorative. The classic version: a function that updates the validator set is reachable through the same relayed-message path that the validator set is supposed to guard.

The proof verifies, but the message is not bound to this context. A signature over (recipient, amount) is replayable anywhere that tuple is valid. If the signed payload does not commit to the destination chain ID, the bridge contract address, and a unique nonce, the same attestation can be replayed on a second chain, against a second deployment, or simply a second time.

The source is never pinned. Many bridges act on events emitted by a source contract. If the destination trusts any contract that emits the right event shape, an attacker deploys their own contract, emits a perfectly formed deposit event, and the relayer carries it faithfully. The event was real. The deposit was not.

Replay protection that protects the wrong thing. A nonce marked consumed after the external call, a per-sender counter that resets, a message hash that omits the amount so two different transfers collide. Each leaves a window where one valid attestation drives more than one release.

A minimal example

Here is the second shape in a few lines. The signature is real, the signer is trusted, and the bridge still loses everything:

function release(bytes32 msgHash, bytes calldata sig, address to, uint256 amount) external {
    require(!used[msgHash], "replayed");
    address signer = ECDSA.recover(msgHash, sig);
    require(isValidator[signer], "bad signer");

    used[msgHash] = true;
    token.transfer(to, amount);
}

Nothing checks that msgHash is actually the hash of (to, amount) for this bridge on this chain. A validator signature gathered for a one-token transfer on another deployment is a valid (msgHash, sig) pair here. The fix is not a bigger signer set. It is binding the hash to its context, then deriving it on-chain so the caller cannot decouple the hash from the values it pays out:

bytes32 msgHash = keccak256(
    abi.encode(block.chainid, address(this), nonce, to, amount)
);
require(!used[msgHash], "replayed");
require(isValidator[ECDSA.recover(msgHash, sig)], "bad signer");

The rule

A relayed message is untrusted input until the contract has verified the authority, the contents, the destination, and the uniqueness. All four, on-chain, before any value moves.

Drop one and the others give a false sense of completeness. A correct signer over an uncommitted payload, a committed payload from an unpinned source, a pinned source with a reusable nonce: each reads as careful, and each is open.

How we test it

The unit tests authors write feed the verifier well-formed messages, so they confirm the happy path and little else. We come at it from the other side and try to construct a release the protocol never intended:

  • Take a valid attestation and replay it against a second deployment, a second chain ID, and a second time. If any succeeds, the binding is incomplete.
  • Emit the deposit event from an attacker-controlled contract and see whether the relayer path accepts it. If the source is not pinned, it will.
  • Fuzz the message encoding for collisions: two distinct transfers that hash to the same committed value.

As an invariant, the destination should never release more than was provably locked at the source for the same message identity:

// for every message identity, total released <= total locked at source
assert(releasedFor[id] <= lockedAtSource[id]);

Why it hides

Bridge verification hides for the same reason it is dangerous. The cryptography is real, so the code feels rigorous, and reviewers anchor on the part that looks hard. The signature check is correct. The Merkle proof verifies. The gap is one layer out, in what the verified thing is actually allowed to authorize, and that gap is invisible unless you ask, for every relayed message, what stops this from being someone else's.

:: dr-note-004 ::

If you operate a bridge or any system that acts on messages from another chain, the verification path is exactly the kind of surface we review closely. Related reading: when a fork inherits an upstream bug.