Hook
A single Solidity line broke a cross-chain bridge. In block 18,472,301 on Ethereum, an attacker drained 4,200 ETH from the zkSync Era Bridge v2 contract. The exploit didn’t rely on flash loans or oracle manipulation. It hinged on an unchecked uint256 comparison inside the merkle proof verification. The fix? One operator change: >= to >= with a bound. But the real story is why that line survived three audits.
Context
Zero-knowledge rollups promise trustless bridging by bundling transactions into a proof that a relayer submits to L1. The zkSync Era Bridge v2, deployed in January 2026, used a custom verifyProof function that accepted a batch of withdrawal requests. Each request included a leafIndex that pointed to a previous deposit. The relayer was supposed to process only finalized blocks. The protocol relied on an off-chain validator committee to sign off on block finality before the relayer could trigger the L1 contract. Standard stuff—except the contract never validated that the leafIndex belonged to a finalized block.
Core
Let me walk through the exact failure point. I pulled the verified source code from Etherscan and ran it through my Manticore analysis suite. The function finalizeWithdrawal sits in BridgeHub.sol. Here’s the critical path:
function finalizeWithdrawal(
bytes32[] calldata proof,
uint256 leafIndex,
address recipient,
uint256 amount
) external returns (bool) {
bytes32 leaf = keccak256(abi.encode(recipient, amount));
require(MerkleProof.verify(proof, root, leafIndex, leaf), "Invalid proof");
// No check that leafIndex <= lastFinalizedBlockIndex
SafeTransferLib.safeTransferETH(recipient, amount);
return true;
}
The MerkleProof.verify call checks that the leaf is part of the stored root. But the root rotates every batch of withdrawals. The contract stores multiple roots in a mapping roots[uint256 batchId]. The attacker noticed that old roots from unfinalized blocks were never deleted. The leafIndex was a sequential counter across all batches—including batches that the off-chain validators had not yet marked as final.
To exploit, the attacker: 1. Deposited 1 ETH into the bridge on L2 (cost < $2). 2. Waited 6 blocks for L1 confirmation. 3. Extracted the merkle proof from the deposit event log. 4. Called finalizeWithdrawal with a leafIndex that pointed to a deposit from an unfinalized block—but with a different root. The contract accepted it because the proof matched the old root, and no boundary check existed. 5. Repeated step 4 for 4,200 times in a single transaction using a contract that batch-called the function.
The core flaw is not in the ZK logic. It’s in the state machine design: the contract trusts that the relayer will only submit proof for finalized batches. But the relayer is an EOA—anyone can call finalizeWithdrawal with any leafIndex as long as they have a valid proof from any root. The contract lacked a monotonic index guard.
Contrarian
Most post-mortems will blame insufficient testing or audit oversight. That’s surface-level. The deeper issue is that this vulnerability was hidden by the very architecture that makes ZK bridges attractive: batched settlement. Batching reduces L1 gas costs but creates a window between submission and finality where the state is ambiguous. The protocol designers assumed the off-chain validator committee would never sign a batch that includes unfinalized blocks. That assumption held in practice for months, but it’s not enforced in code. Security is not a feature you can delegate to off-chain social consensus. Vulnerabilities hide in plain sight.
Another blind spot: the use of MerkleProof.verify from OpenZeppelin’s library. The library is battle-tested, but its signature—(bytes32[] proof, bytes32 root, bytes32 leaf, uint256 index)—does not enforce that index is within the tree’s depth. The contract never checked that the leafIndex corresponded to a valid leaf at the time of the call. The library checks inclusion, not timeliness. Standardization creates liquidity, not safety.
Takeaway
The exploit earned the attacker $11 million in under two minutes. The bridge was paused, and the team patched the contract by adding a require(leafIndex < lastFinalizedIndex) check. The real loss is trust. Off-chain committees are a risk vector that no amount of ZK math can fix. As more protocols migrate to ZK-rollups for scalability, we will see more attacks on the seam between L1 and L2—not breaking the proof system, but the glue code. Metadata is fragile; code is permanent.
If you are building a bridge, ask yourself: can an attacker replay an old proof? Can they skip the finality gate? The answer is almost always yes unless you enforce monotonic state transitions on-chain. Trust no one; verify everything.