
A $292M Bridge Hack Came Down to One Verifier: 5 Checks Before Launch
17 min
On this page▾
TL;DR — Quick Summary
- A cross-chain app never sees the other chain. It sees signatures over bytes. Every check below is about who signs, what bytes they sign, and how often those bytes can be used.
- Never let one verifier decide. In April 2026, a route that required a single verifier released 116,500 rsETH, about $292M, for a message that was never sent. Require 2 or more independent verifiers, run by different parties, reading different data.
- Never hash variable-length fields with
abi.encodePacked. In July 2026, an authorization for about 3,110 tokens released over 203M, because the boundary between amount and recipient was not part of what was signed (an encodePacked collision). Useabi.encodeor EIP-712. - Bind every message to its source chain, destination chain and a nonce, and mark it processed the first time. Otherwise it is a replay attack waiting to happen: in our demo, 5,000 sent paid 15,000.
- Reject any signed batch where a key appears twice, and put a rate limit with an automatic pause, a circuit breaker, on every route. With the limit, the same forged message took 1,000 and left 115,500 where it was.
The video runs every attack and every fix on screen. The article below covers
the same ground, with the code, if you would rather read it.
Introduction
Your bridge releases funds when one machine says yes.
In April 2026, one forged message released 116,500 tokens, about $292M. Three months later, two more bridges fell, for over $16M between them.
So before anything else, here is a dare. Open your app's cross-chain config and count how many verifiers must agree before a message is accepted. If the answer is 1, keep reading.
When your app acts on a message from another chain, it cannot see that chain. It trusts whoever tells it the message is real. Everything comes down to who that is, and what exactly they signed.
This article walks through the five checks we run before a cross-chain app launches, the fix for each one in code, and the one line of config to read today. Every demo runs locally:
anvil chains on 127.0.0.1, our own stand-in adapter (not real bridge or messaging-protocol code), test tokens, and "verifiers" signing with anvil's public dev keys. Nothing touches a live network, a real bridge or a real verifier network. The demos reproduce the bug classes only; they are not a model of any real bridge. The full harness is public: ZealynxSecurity/zealynx-demos, newtech-05-crosschain.If you want the long version, every domain an auditor checks on a bridge, we keep that in the Bridge Security Checklist: 100+ Critical Exploit Checks. This article is the hands-on version: five attacks you can run, and five fixes you can copy.
How a message crosses
Your contract on chain A emits a message. Off chain, verifiers watch it and sign that it happened. On chain B, an adapter checks those signatures and releases tokens.
Chain B never sees chain A. It sees signatures over bytes. So there are three questions: how many signers, what bytes, and can those bytes be used twice. The fifth check answers a fourth one: when the first three fail, how much can leave.
Every stand-in adapter in the demos shares the same signature check. Signers must be distinct, registered, in ascending order, and counted against a threshold:
1// contracts/src/Verifiers.sol2function _checkSigs(bytes32 digest, bytes[] calldata sigs) internal view {3 bytes32 h = MessageHashUtils.toEthSignedMessageHash(digest);4 address last;5 uint256 count;6 for (uint256 i; i < sigs.length; i++) {7 address signer = ECDSA.recover(h, sigs[i]);8 require(signer > last, "duplicate signer");9 require(isVerifier[signer], "not a verifier");10 last = signer;11 count++;12 }13 require(count >= threshold, "not enough verifiers");14}
The signature check itself is fine. Each of the five bugs below is a way around it: too few signers behind it, the wrong bytes under it, the same bytes reused, a valid batch that says two things, or no limit on what one bad message can take.
1. One verifier, one yes: a single required verifier
On 18 April 2026, 116,500 rsETH, about 18% of its supply and roughly $292M, was released by a message that was never sent. The route's messaging setup required a single verifier for high-value transfers, a 1-of-1 DVN configuration on LayerZero. That verifier read its data from sources an attacker had poisoned. It confirmed the message, and the adapter paid.
Who approved that configuration is disputed and is now in litigation. This article describes the setup, not who is responsible for it. The point for builders is the setup itself: when one verifier is required, one verifier being wrong is enough.
The demo
Our own
Adapter gets everything else right: abi.encode, chain binding, a nonce and a processed flag. The only thing this demo changes is how many verifiers must agree:1// contracts/src/Adapter.sol2function release(Message calldata m, bytes[] calldata sigs) external {3 require(m.srcChain == srcChain && m.dstChain == block.chainid, "wrong route");4 bytes32 digest = keccak256(abi.encode(m));5 require(!processed[digest], "already processed");6 processed[digest] = true;7 _checkSigs(digest, sigs);8 _pay(m.to, m.amount);9}
With a threshold of 1 of 1, we hold that verifier's key and sign a message chain A never sent:
1adapter on chain B holds 650,000 tRSETH2required verifiers: 1 of 13$ chain A sent nothing · verifier 0x14dC…9955 signs:4 release 116,500 tRSETH → 0x90F7…b9065$ attacker → adapter.release(msg, [1 signature])6 ✓ RELEASED → attacker holds 116,500 tRSETH7FORGED MESSAGE PAID 116,500 tRSETH
The fix
Never 1 of 1. Same contract, same
require(count >= threshold, "not enough verifiers"), deployed with 3 independent verifiers and a threshold of 2. In production, "independent" means run by different parties and reading different data sources, so one poisoned feed cannot produce two signatures.The same forged message, with the same single signature:
1adapter on chain B holds 650,000 tRSETH2required verifiers: 2 of 33$ chain A sent nothing · verifier 0x14dC…9955 signs:4 release 116,500 tRSETH → 0x90F7…b9065$ attacker → adapter.release(msg, [1 signature])6 ✗ reverted: not enough verifiers7NOTHING RELEASED · adapter keeps 650,000
2. Two messages, one hash: packed variable-length fields
In July 2026, two more bridges were exploited, for over $16M combined: the Wanchain Cardano to BNB Chain bridge (reports range from about $9M to $13M) and the Verus-Ethereum bridge ($7.5M). At Wanchain, the mechanism was this one: fields of different lengths were concatenated before hashing, and an authorization for about 3,110 NIGHT was turned into a release of more than 203M.
The demo
When the source chain sends amount and recipient in its own variable-length encoding, an adapter receives them as raw bytes. This one glues them together before hashing:
1// contracts/src/PackedAdapter.sol2function digestOf(uint256 nonce, bytes calldata amount, bytes calldata recipient) public view returns (bytes32) {3 return keccak256(abi.encodePacked(block.chainid, nonce, amount, recipient));4}
abi.encodePacked writes the bytes back to back with no lengths, so the boundary between amount and recipient is not part of what the verifiers signed. The verifiers sign a legitimate transfer of 3,110 to a 22-byte recipient that starts with two zero bytes. The attacker moves those two bytes from the recipient into the amount. The packed bytes are identical, so the hash is identical, and the real signatures still verify. The EVM payee is the last 20 bytes of the recipient, which has not changed. The amount is now 3,110 shifted left by two bytes, 203,816,960:1adapter holds 250,000,000 tNIGHT · 2 of 3 verifiers2$ verifiers sign: 3,110 → 0x000090…b9063forged: 203,816,960 → 0x90F7…b9064 same hash as the signed message: YES5$ attacker → release(forged fields, the real signatures)6 ✓ RELEASED 203,816,960 tNIGHT7SIGNED FOR 3,110 · PAID 203,816,960
Note that this adapter requires 2 of 3 verifiers. Check #1 does not help here: every verifier signed honestly, and the bytes meant two things.
The fix
Encode with fixed boundaries. Same fields, same raw bytes, one word changed:
1// contracts/src/PackedAdapterFixed.sol2function digestOf(uint256 nonce, bytes calldata amount, bytes calldata recipient) public view returns (bytes32) {3 return keccak256(abi.encode(block.chainid, nonce, amount, recipient));4}
abi.encode stores each variable-length field with its own length, so no bytes can move between them. EIP-712 typed data gets you the same property. The forged split now hashes to a different digest, the real signatures recover to an address that is not a verifier, and the release reverts:1adapter holds 250,000,000 tNIGHT · 2 of 3 verifiers2$ verifiers sign: 3,110 → 0x000090…b9063forged: 203,816,960 → 0x90F7…b9064 same hash as the signed message: no5$ attacker → release(forged fields, the real signatures)6 ✗ reverted: not a verifier7FORGERY REJECTED · paid 0
Search your code for
abi.encodePacked anywhere its result is hashed and signed. If any argument is bytes, string or a dynamic array, that is a place where two messages can share one hash.Shipping anything cross-chain? Send us the design before you write the rest. We will tell you where it breaks, and what it would take to make it safe. Request a design review →
3. The message that paid three times: no chain, no nonce
The demo
This message is validly signed by 2 of 3 verifiers. But the signed bytes say only who gets paid and how much: no source chain, no destination chain, no nonce, and nothing is marked as used.
1// contracts/src/ReplayAdapter.sol2function release(address to, uint256 amount, bytes[] calldata sigs) external {3 bytes32 digest = keccak256(abi.encode(to, amount));4 _checkSigs(digest, sigs);5 token.transfer(to, amount);6}
So it works on chain B, works again on chain B, and works on chain C:
1sent once on chain A: 5,000 tRSETH → chain B · signed by 2 of 32$ try 1 · chain B.release(same signed message)3 ✓ paid 5,0004$ try 2 · chain B.release(same signed message)5 ✓ paid 5,0006$ try 3 · chain C.release(same signed message)7 ✓ paid 5,0008SENT 5,000 · RECEIVED 15,000
The fix
Bind every message to its source chain, its destination chain and a nonce, and mark it processed the first time it runs:
1// contracts/src/ReplayAdapterFixed.sol2function release(uint256 nonce, address to, uint256 amount, bytes[] calldata sigs) external {3 bytes32 digest = keccak256(abi.encode(srcChain, block.chainid, nonce, to, amount));4 require(!processed[digest], "already processed");5 processed[digest] = true;6 _checkSigs(digest, sigs);7 token.transfer(to, amount);8}
The second try on chain B hits the processed flag. On chain C,
block.chainid is different, so the digest is different and the signatures recover to an address that is not a verifier:1sent once on chain A: 5,000 tRSETH → chain B · signed by 2 of 32$ try 1 · chain B.release(same signed message)3 ✓ paid 5,0004$ try 2 · chain B.release(same signed message)5 ✗ reverted: already processed6$ try 3 · chain C.release(same signed message)7 ✗ reverted: not a verifier8SENT 5,000 · received 5,000
4. The duplicate that rewrote the root: a signed batch with the same key twice
Some bridges accept signed batches of updates. This is a generic bug class, shown here on our own stand-in contract.
The demo
Verifiers sign a batch of key/value updates. Before applying it, the adapter checks the batch's
root entry against the source checkpoint. But it looks up the first root entry, and then applies every entry:1// contracts/src/RootBook.sol2function applyBatch(uint256 nonce, Entry[] calldata batch, bytes[] calldata sigs) external virtual {3 bytes32 digest = keccak256(abi.encode(block.chainid, nonce, batch));4 require(!processed[digest], "already processed");5 processed[digest] = true;6 _checkSigs(digest, sigs);7 require(_first(batch, ROOT) == checkpointRoot, "root mismatch");8 for (uint256 i; i < batch.length; i++) values[batch[i].key] = batch[i].value;9}
A batch that carries
root twice passes the check with the first entry, and the second entry quietly becomes the trusted root. The attacker then claims against it:1adapter holds 80,000 tRSETH · root = checkpoint root2$ signed batch: fee=5 · root=checkpoint · root=attacker3 ✓ applied · root = ATTACKER'S ROOT4$ attacker → claim(80,000)5 ✓ claimed against the new root6ROOT REWRITTEN · attacker took 80,000
The fix
Validate the whole batch before applying any of it. A key that appears twice rejects the batch:
1// contracts/src/RootBookFixed.sol2function applyBatch(uint256 nonce, Entry[] calldata batch, bytes[] calldata sigs) external override {3 for (uint256 i; i < batch.length; i++)4 for (uint256 j = i + 1; j < batch.length; j++)5 require(batch[i].key != batch[j].key, "duplicate key");6 // ... then the same digest, processed flag, signature and root checks as before7}
1adapter holds 80,000 tRSETH · root = checkpoint root2$ signed batch: fee=5 · root=checkpoint · root=attacker3 ✗ reverted: duplicate key4$ attacker → claim(80,000)5 ✗ reverted: not in root6ROOT INTACT · attacker took 0
The general rule: whenever code checks one element of a list and then acts on all of them, ask what the elements it did not check can do.
5. One call, the whole vault: a rate limit on every route
Assume one of the first four checks fails. When a forged message gets through, how much can it take?
The demo
Here the adapter is back to a 1-of-1 verifier set, and a forged message gets through. With no limit, it takes everything in one call:
1adapter holds 116,500 tRSETH2$ forged message gets through: release 116,500 → attacker3 attacker received 116,500 tRSETH4ONE CALL · ADAPTER EMPTIED
The fix
A rate limit on every route: a maximum outflow per hour, and an automatic pause above it. Same adapter, same weak verifier set, plus this:
1// contracts/src/RateLimitedAdapter.sol2function _pay(address to, uint256 amount) internal override {3 require(!paused, "route paused");4 if (block.timestamp >= windowStart + 1 hours) (windowStart, outInWindow) = (block.timestamp, 0);5 uint256 room = maxPerHour - outInWindow;6 uint256 now_ = amount < room ? amount : room;7 outInWindow += now_;8 if (amount > now_) { held[to] += amount - now_; paused = true; }9 token.transfer(to, now_);10}
With a limit of 1,000 per hour, the same forged message moves 1,000. The excess is held, and the route pauses until a guardian reviews it:
1adapter holds 116,500 tRSETH2$ forged message gets through: release 116,500 → attacker3 attacker received 1,000 tRSETH4 route paused: true · held for review: 115,50051,000 OUT · 115,500 STAYS
A rate limit does not stop a forgery. It decides how much a forgery costs you, and it buys the time to respond. For a related pitfall, a withdrawal limit that prices the wrong asset, see The Asset You Price Is Not Always the Asset You Release.
The checklist
Screenshot this one.
- Two or more independent verifiers. Never 1 of 1. Different parties, different data sources.
- Fixed-boundary encoding.
abi.encodeor EIP-712. Neverabi.encodePackedover variable-length fields. - Every message bound to source, destination and nonce. Marked processed the first time it runs.
- Reject duplicates. Validate the whole batch before applying any of it.
- Rate limits per route, with an automatic pause. Assume one of the first four fails.
Then do the one thing from the introduction. Read your app's messaging config, route by route, and count how many verifiers must agree. If any route says 1, that is check number 1. Fix that first.
FAQ
1. What happened in the rsETH bridge exploit in April 2026?
On 18 April 2026, 116,500 rsETH, about 18% of its supply and roughly $292M, was released on another chain for a message that was never sent. The route's messaging setup required a single verifier (a 1-of-1 DVN configuration on LayerZero) for high-value transfers, and that verifier read its data from sources an attacker had poisoned, so it confirmed the forged message. Who approved the configuration is disputed and in litigation. The lesson for builders is the setup: when one verifier is required, one wrong verifier is enough. Require 2 or more independent verifiers.
2. How many verifiers should a cross-chain app require?
At least 2, out of a set of independent verifiers run by different parties and reading different data sources, on every route that moves value. A 1-of-1 configuration means any single failure, a compromised key, a poisoned RPC or a bug in one verifier, releases funds. In our local demo, the same forged message that released 116,500 tokens with 1 of 1 reverted with
not enough verifiers under 2 of 3, and the adapter kept all 650,000.3. Why is abi.encodePacked dangerous in bridge message hashing?
abi.encodePacked concatenates arguments without their lengths, so when two or more are variable-length (bytes, string, dynamic arrays), different field values can produce the same bytes and therefore the same hash. A signature over one message then also validates the other. In July 2026, that is how a bridge released over 203M tokens from an authorization for about 3,110. In our demo, moving two bytes from the recipient into the amount turned a signed 3,110 into a paid 203,816,960. Hash with abi.encode or EIP-712 instead, which keep each field's length.4. How do I prevent cross-chain message replay?
Include the source chain, the destination chain and a nonce in the bytes the verifiers sign, check the destination against
block.chainid, and mark each message processed the first time it executes. In our demo, a message signed with only recipient and amount paid 5,000 three times, twice on one chain and once on another, for 15,000 received against 5,000 sent. With chain binding and a processed flag, the second attempt reverted with already processed and the attempt on the other chain recovered a non-verifier.5. Do rate limits actually help if a bridge's verification is broken?
Yes. A rate limit does not stop a forged message, but it caps what one can take and gives the team time to respond. In our demo, a forged message on an adapter with no limit emptied it in one call. With a limit of 1,000 per hour and an automatic pause, the same forged message moved 1,000, and 115,500 stayed held for review. Set a limit on every route and pause automatically when it is exceeded.
Glossary
| Term | Definition |
|---|---|
| Cross-Chain | Technology enabling interoperability and asset transfers between different blockchain networks. |
| DVN (Decentralized Verifier Network) | In LayerZero, an independent verifier that checks a cross-chain message on the source chain and attests to it on the destination; an app configures how many must agree. |
| abi.encodePacked Collision | Two different sets of arguments that produce the same bytes, and so the same hash, because abi.encodePacked drops the lengths of variable-length fields. |
| Replay Attack | An attack where a valid transaction or message is maliciously resubmitted to execute the same action multiple times. |
| Nonce | A number used once, which makes each signed message unique so it cannot be executed twice. |
| Circuit Breaker | An emergency mechanism that automatically or manually halts protocol operations when anomalous conditions are detected. |
Build it safely
Every attack and fix in this article is runnable on local chains: ZealynxSecurity/zealynx-demos, newtech-05-crosschain. Clone it, run each demo vulnerable and then with
--fixed, and compare them with your own adapter. The adapter there is a teaching stand-in, not real bridge code, and it says nothing about who was at fault in any real incident.Shipping anything cross-chain? Send us the design before you write the rest. We will tell you where it breaks, and what it would take to make it safe. Request a design review →
Working auditors in your corner, all year
Zealynx Insiders: weekly live sessions, 1:1 advisory, pair-auditing, and Krait runs on your code, from an audit firm that publishes its reports.
No spam. Unsubscribe anytime.
Building a protocol? Start with a three-day pre-audit, credited toward your audit →
Keep reading


