deniz.in

Markets

Weather

Loading weather

· via dev.to (home feed)

zkLink Nova drained of about $780K via stale transient hash that let attacker forge a Merkle root

A single transaction drained zkLink Nova's Ethereum escrows of roughly $780K after an unreset transient-storage hash let an attacker register a fake batch root and withdraw against self-built proofs.

zkLink Nova drained of about $780K via stale transient hash that let attacker forge a Merkle root

One transaction emptied the escrows

At 22:21:35 UTC on 28 October 2026, inside Ethereum block 26,294,188, a single transaction drained the Ethereum escrows of zkLink Nova, an aggregated rollup. According to a write-up published on dev.to, the attacker made off with 22.43 ETH and eleven ERC-20 tokens, led by roughly 245.74 pufETH. Pricing the haul depends on the source: estimates ranged from about $743K to $805K, with BlockSec Phalcon at about $780K and SlowMist at about $783.1K.

No key was stolen and no signature forged. The attacker wrote a Merkle root of their own choosing into the bridge and then withdrew against proofs they had built themselves. zkLink's response, per the report, was to suspend block production on Nova, emergency-patch the affected contracts and temporarily disable withdrawals. Follow-on attacks on other chains were also reported.

How batch roots reach Ethereum

Each Nova escrow stores one Merkle root per batch of L2-to-L1 messages, and a withdrawal on Ethereum is valid only if its proof matches the stored root for that batch. Whoever can write a root can approve any withdrawal.

Roots do not arrive directly. A whitelisted relayer contract, SyncL2TxHashRelayer, which anyone may call, triggers the Arbitrator, a cross-chain message router. The Arbitrator claims a message from a source chain's canonical service (Linea's L1MessageService in Nova's case), the source gateway calls back into Arbitrator.receiveMessage() to record what was genuinely delivered, and the Arbitrator forwards the message to destination gateways such as the EthereumGateway. That gateway, trusted via onlyArbitrator, calls ZkLink.syncBatchRoot(), trusted via onlyGateway, to write the root. Every hop trusts the previous one purely on msg.sender.

Two flaws combined in claimMessage

The dev.to analysis, which draws on zkLink's public zklink-evm-contracts repository, locates the failure inside Arbitrator.claimMessage(), gated by onlyRelayer and nonReentrant.

First, the function executes Address.functionCall() with both the target address and the calldata chosen by the caller. Because that call originates from the Arbitrator, the callee sees the Arbitrator's address as msg.sender — exactly the identity every gateway trusts. A relayer could point the message-service parameter at the EthereumGateway itself and have it run a wrapped syncBatchRoot call that both the gateway and ZkLink would accept.

Second, the safeguard meant to prevent exactly that was a hash called finalizeMessageHash kept in EIP-1153 transient storage. receiveMessage() writes it when a real message arrives, and claimMessage() checks it after the external call. But transient storage lasts for the entire transaction, and the slot was never cleared. The check therefore did not establish that a genuine delivery happened during this claim, only that one happened at some earlier point in the same transaction. The nonReentrant guard is useless against this because the two claims are sequential calls from an attacker contract, not nested re-entrancy.

The attack, step by step

BlockSec Phalcon and SlowMist describe the same sequence, which matches the transfers in the transaction receipt:

  • The attacker first called the public relayer to claim a genuine pending Linea message that synchronised two L2 transaction hashes. Linea verified the proof, receiveMessage() armed the transient hash, the legitimate message was forwarded, and the slot stayed set.
  • A second relayer call in the same transaction named the EthereumGateway as the target, with calldata wrapping syncBatchRoot(116409, attackerChosenRoot, 0). The gateway executed it because the caller was the Arbitrator; ZkLink stored the attacker's root because the caller was its gateway.
  • The gateway never calls receiveMessage(), so nothing overwrote the slot. By reusing the same gateway, value and calldata as the first call, the attacker made the recomputed hash match the stale one and the forged root survived.
  • Finalising withdrawals is permissionless and checks only the stored root, so the attacker submitted twelve proofs — one ETH withdrawal from the ZkLink escrow and eleven token withdrawals from the L1ERC20Bridge — all paid to their own address.

The token transfers included 18,113.45 USDC, 3,360.90 USDT, 12.57 ezETH, 6.87 rsETH, 6.30 STONE and smaller amounts of mstETH, mswETH, rswETH, wstETH and WBTC. Liquid restaking tokens made up most of the value, with pufETH the single largest loss.

Why it matters

Neither flaw alone would have been enough; together they let a permissionless entry point manufacture trust that the bridge accepted as genuine. The incident carries two direct lessons for rollup and bridge designers. EIP-1153 transient storage is transaction-scoped, not call-scoped, so validation state kept there must be explicitly cleared, or a check intended to cover one action can be replayed by another later in the same transaction. And a privileged function that calls a caller-supplied target lends its own trusted identity to whatever runs at the far end, which turns every onlyArbitrator-style check behind it into an open door. Chains of trust built purely on msg.sender are only as strong as the router in the middle.

  • #ethereum
  • #security
  • #smart-contracts
  • #bridges
  • #defi

Related posts