zkLink hacked via forged batch root exploit

An attacker exploited design flaws in zkLink's Arbitrator contract to withdraw $783K

zkLink hacked via forged batch root exploit

Context

The zkLink multi-chain zk-rollup protocol suffered a loss of approximately $783,000 on October 29, 2026, through an exploit that manipulated cross-chain message verification. SlowMist's forensic examination revealed that the incident stemmed from design flaws in the Arbitrator contract's message-claiming mechanism, which permitted an attacker to forge batch roots and execute unauthorized withdrawals.

Protocol Architecture

On Ethereum, zkLink operates through the ZkLink contract at 0x5fd9f73286b7e8683bab45019c94553b93e015cf, which functions as the L1 messaging hub and ETH treasury. The L1ERC20Bridge at 0xad16edcf7deb7e90096a259c81269d811544b6b6 holds ERC-20 assets.

Withdrawal authorization relies on batch roots: each L2 withdrawal generates an L2→L1 log, with logs from a single batch forming a Merkle tree. Once L2 execution completes for a batch, its log root—stored as l2LogsRootHashes[batchNumber]—syncs to the L1 ZkLink contract. Users submit withdrawal messages alongside Merkle proofs during L1 withdrawal; ZkLink validates proofs against the stored root before releasing funds. A forged root renders this verification meaningless, as any fabricated message can be accompanied by a matching proof. The totalBatchesExecuted counter tracks the highest executed batch number, with verification requiring _batchNumber ≤ totalBatchesExecuted.

Cross-chain messaging routes through gateway intermediaries rather than direct chain-to-chain transmission. EthereumGateway (0x83bc7394738a7a084081af22eec0051908c0055c) handles Ethereum-side operations, while LineaL1Gateway (0x803460416c2682ac54fccf03ef77b10a12f2809b) manages Linea. The Arbitrator contract at 0x1ee09a2caa0813a5183f90f5a6d0e4871f4c6002 coordinates these gateways centrally.

The Arbitrator's claimMessage function processes message claiming and forwarding when invoked by trusted relayers. It uses the source-chain messaging service designated by the relayer to claim cross-chain messages, then forwards them to the destination gateway. To ensure forwarded content matches the claimed message, the Arbitrator writes the message hash into transient storage via a receiveMessage callback during claiming, which claimMessage subsequently verifies.

Transient storage, introduced in Solidity 0.8.24 through EIP-1153, persists across an entire transaction rather than individual function calls: values written in one call remain accessible to subsequent calls within the same transaction, clearing only at transaction completion.

The attack vector originated at the SyncL2TxHashRelayer contract (0x0fdc28e64772a70109d10fd068f859cfa7dea2c6). Its claimPrimaryChainSyncL2TxHashMessage function lacks access restrictions and operates as a passthrough, forwarding caller-supplied parameters directly to Arbitrator.claimMessage. Its successful invocation of claimMessage confirms its registration as an authorized relayer within the Arbitrator.

Vulnerability Mechanics

The exploit centered on two interconnected weaknesses in the Arbitrator contract's claimMessage function, which handles both message claiming and forwarding but contained flawed trust assumptions in both operations.

The first weakness: the function grants relayers complete control over external execution targets. Both _sourceChainCanonicalMessageService and _sourceChainClaimCallData are relayer-supplied parameters, and the Arbitrator executes the call under its own authority. Legitimate usage targets source-chain messaging service claiming interfaces, but an attacker can redirect this to any gateway registered in the Arbitrator, effectively invoking gateway functions protected by onlyArbitrator modifiers.

The second weakness: the consistency check proves bypassable due to transient storage behavior. The verification reads finalizeMessageHash from transient storage, but this slot never clears within a transaction and persists beyond individual function call boundaries. A genuine claim occurring earlier in the transaction writes a value to this slot, which a subsequent claimMessage invocation can reuse to satisfy the check—even when the second call claims no message whatsoever. The attacker need only ensure identical (gateway, value, callData) parameters across both calls. The check therefore confirms only that "a matching claim occurred somewhere within this transaction," not that "this invocation claimed this message."

Batch root forgery becomes possible by redirecting the external call to EthereumGateway, causing the Arbitrator to invoke its sendMessage function. This function verifies only that the caller is the Arbitrator contract before forwarding calldata directly to the ZkLink contract. Since the call originates from the Arbitrator, the check passes. The attacker populates _callData with syncBatchRoot(116409, forgedRoot, 0).

ZkLink's syncBatchRoot function employs an onlyGateway modifier requiring the caller to be a configured gateway. Because EthereumGateway forwards the call, this requirement is satisfied. The function accepts any batch number and root value without verifying batch proofs, directly overwriting l2LogsRootHashes[_batchNumber] with the supplied root. If _batchNumber exceeds totalBatchesExecuted, the counter advances to _batchNumber. The attacker selected 116409—the next unexecuted batch at the time—paired with a self-constructed log root, overwriting l2LogsRootHashes[116409] from zero to the forged value while incrementing totalBatchesExecuted from 116408 to 116409. With the root forged, Merkle proofs inevitably validate, permitting arbitrary withdrawal message construction and fund extraction via fabricated proofs.

Execution Sequence

Attack transaction: 0x4f3c23e75b9134e8cae5a462550daad42fbbc19242567fcc34c0a035e297462c
Attacker EOA: 0xaca0655a11200a80d33d0e48786317304cde5718
Attack contract: 0xd5f162b68a0281686e8dc76db8d67d2f3e6aff07

  • The attack contract queried ZkLink's totalBatchesExecuted() to retrieve the last executed batch number, then incremented by one to obtain 116409 as the forgery target. Subsequent withdrawal proofs require batch numbers not exceeding totalBatchesExecuted, necessitating registration of the forged batch as "executed." The syncBatchRoot function accepts arbitrary batch numbers and roots while advancing totalBatchesExecuted to the specified batch, meaning attacker control over gateway invocation determines which batch counts as executed.
  • The attacker initiated a legitimate cross-chain message claim from Linea by calling Arbitrator.claimMessage through SyncL2TxHashRelayer. The submission included valid proof of a genuine Linea message, specifying Linea Rollup as the source-chain messaging service address and providing claimMessageWithProof evidence. This Linea→Ethereum message, originated by the attacker on Linea, designated LineaL1Gateway as recipient with content instructing it to forward a syncL2TxHash(_l2TxHash,_primaryChainL2TxHash) call to the destination gateway. After Linea Rollup validated the proof, it invoked LineaL1Gateway's callback, which called the Arbitrator's receiveMessage function, writing this message's hash into the finalizeMessageHash transient storage slot. The claimMessage function then compared against this value. The attacker thus obtained the verification value finalizeMessageHash through a genuine, benign claiming operation, leaving it stored for the transaction's duration. SyncL2TxHashRelayer's registration as an Arbitrator relayer, combined with its claimPrimaryChainSyncL2TxHashMessage function's lack of access controls, permits any address to use it for calling the Arbitrator's claimMessage. Meanwhile, claimMessage permits relayers to specify source-chain messaging service addresses themselves, and the verification value written during legitimate claiming persists in transient storage until transaction completion.
  • The attack contract immediately called the same relayer again, modifying only two parameters: substituting EthereumGateway (registered in the Arbitrator itself) for Linea Rollup as the source-chain messaging service, and replacing _sourceChainClaimCallData with sendMessage(0, syncBatchRoot(116409, 0x1934a1...a8, 0), ""). All remaining parameters—source-chain gateway, receive amount, declared receive calldata, and forwarding parameters—matched the previous invocation byte-for-byte. These parameters form the basis for finalizeMessageHash verification. Because finalizeMessageHash resides in transient storage, it never clears within a transaction and remains independent of whether the current invocation actually claimed a message. Neither the external call's target address nor its calldata participate in the check, enabling direct reuse of the value left by the prior claim. Additionally, Address.functionCall first permits the Arbitrator to execute any attacker-specified call under its own identity—the exploit's core vulnerability. This second call claimed no message, yet passed the claimMessage verification while the attacker's external call to EthereumGateway's sendMessage had already executed before the check occurred.
  • When the Arbitrator invoked EthereumGateway's sendMessage function, the calldata contained precisely the attacker-supplied syncBatchRoot(116409, forgedRoot, 0) call. ZkLink consequently recorded the forged root as this batch's log root. On-chain evidence shows ZkLink emitted the SyncBatchRoot(116409, 0x1934a1c3…) event, l2LogsRootHashes[116409] changed from zero to the attacker-constructed root, and totalBatchesExecuted incremented from 116408 to 116409. The forged root was now "legitimately" recorded in the protocol's ledger. Both calls also forwarded a syncL2TxHash call, setting l2TxHashMap[0x2575…] to 0xe9ec…—a harmless side effect of verification value reuse. Because EthereumGateway.sendMessage verifies only that the caller is the Arbitrator before forwarding calldata to ZkLink, and ZkLink.syncBatchRoot verifies only that the caller is a gateway without checking batch proofs, both "caller verification without content validation" layers passed, permitting forged root recording as legitimate.
  • Following root overwriting, any withdrawal message producing valid proof against this root would pass verification. The attack contract executed 12 consecutive final withdrawal calls, all targeting batch 116409: one ETH withdrawal followed by 11 ERC-20 withdrawals. Each message included four attacker-constructed Merkle nodes based on the forged root, guaranteeing proof validation. The ZkLink contract processed the ETH withdrawal directly, while L1ERC20Bridge handled the 11 ERC-20 withdrawals. After proof validation, tokens transferred directly from the bridge contract to the attacker's EOA. Both contracts recorded messages as withdrawn, but this marker only prevents duplicate submissions and cannot verify whether messages actually occurred on L2. Withdrawal contracts verify only "message inclusion in the tree corresponding to the stored batch root," not whether the root itself originated from actual L2 execution. Once the root is forged, any message can be accompanied by valid proof.

Remediation Requirements

This incident combined arbitrary external call capability in the Arbitrator contract with reuse of uncleared transient storage values for verification. Arbitrator.claimMessage permits relayers to designate arbitrary targets and calldata, executing arbitrary external calls under its own authority. Its sole finality check compares against a residual transient storage value without clearing it or confirming the current invocation completed a claim. The attacker used a legitimate claim to write the verification value, then reused it in a second claimMessage call, causing the Arbitrator to invoke the gateway's onlyArbitrator interface on the attacker's behalf, ultimately writing the forged batch root into ZkLink and rendering bridge-held assets "provably" withdrawable.

Remediation must address three layers. First, claimMessage must clear finalizeMessageHash before invoking Address.functionCall, then require it to be non-zero and match the current call's declaration, or employ an incrementing nonce per call to bind the value, ensuring the verification value can only be written by receiveMessage during the current invocation. Second, implement a whitelist for _sourceChainCanonicalMessageService to prevent gateways registered in the Arbitrator itself from serving as "source-chain messaging services." Development helper contracts should not be registered as relayers, or must implement access controls preventing arbitrary addresses from indirectly gaining claimMessage invocation capability. Third, ZkLink.syncBatchRoot should reject attempts to overwrite existing batch roots, and an independent proof layer should be added at the gateway or messaging level to ensure batch root updates cannot succeed solely because "the caller is a gateway."

Hot Stories

Submit an application

Leave a request

How to contact you?

Specify Telegram username or email — depending on the chosen method of communication.