Originally published at zkLink Nova Exploit: A Stale Transient Hash Forged a Root
At 22:21:35 UTC on 28 October 2026 (Ethereum block 26,294,188), a single transaction emptied the Ethereum escrows of zkLink Nova, an aggregated rollup. The attacker walked away with 22.43 ETH plus 11 ERC-20 tokens, led by about 245.74 pufETH. Security firms priced the haul at roughly $743K to $805K depending on the pricing source; BlockSec Phalcon put it at about $780K and SlowMist at about $783.1K.
No key was stolen and no signature was forged. The attacker wrote their own Merkle root into the bridge and then withdrew against proofs they had built themselves. Two small design decisions in one contract made that possible: a transient-storage hash that was never cleared, and a privileged function that called whatever target its caller supplied.
TL;DR
- Protocol: zkLink Nova, an aggregated rollup with escrows on Ethereum and several other chains.
- Chain: Ethereum mainnet. Follow-on attacks were reported on other chains (see below).
- Loss on Ethereum: 22.43 ETH and 11 ERC-20 tokens, roughly $780K by most estimates.
-
Root cause:
Arbitrator.claimMessage()checked a transientfinalizeMessageHashthat it never reset, and it executed a caller-supplied target and calldata under the Arbitrator's own trusted identity. -
Entry point:
SyncL2TxHashRelayer, a whitelisted relayer contract that anyone can call. - Response: zkLink suspended block production on Nova, emergency-patched the affected contracts and temporarily disabled withdrawals.
How zkLink Nova Moves Batch Roots
Each zkLink Nova escrow stores Merkle roots of L2-to-L1 message logs, one per batch. A withdrawal on L1 is valid if its Merkle proof matches the stored root for that batch. In other words, whoever can write a root can approve any withdrawal they like.
Roots do not arrive directly. They travel through a relay chain that looks like this on Ethereum:
- A relayer calls the Arbitrator, a cross-chain message router gated by
onlyRelayer. - The Arbitrator claims a message from a source chain's canonical message service (for Nova, Linea's
L1MessageService). - During that claim, the source chain's gateway calls back into
Arbitrator.receiveMessage(), which records a hash of what was genuinely delivered. - The Arbitrator checks that hash and forwards the message to destination gateways, such as the EthereumGateway.
- The EthereumGateway, gated by
onlyArbitrator, callsZkLink.syncBatchRoot(), gated byonlyGateway, which writes the root.
Every hop trusts the hop before it purely by msg.sender. That chain of trust is only as strong as the Arbitrator's guarantee that it forwards nothing it did not genuinely receive.
Root Cause: Two Flaws in claimMessage
The relevant code is public in zkLink's zklink-evm-contracts repository. The function signature makes the first problem visible:
function claimMessage(
address _sourceChainCanonicalMessageService, // chosen by the relayer
bytes calldata _sourceChainClaimCallData, // chosen by the relayer
IL1Gateway _sourceChainL1Gateway,
uint256 _receiveValue,
bytes calldata _receiveCallData,
bytes calldata _forwardParams
) external payable nonReentrant onlyRelayer whenNotPaused
Flaw 1: An Arbitrary Call With a Trusted Identity
claimMessage performs Address.functionCall(_sourceChainCanonicalMessageService, _sourceChainClaimCallData). Both the target and the calldata come from the caller. Because the call originates from the Arbitrator, the callee sees msg.sender == Arbitrator, which is exactly the identity that every gateway's onlyArbitrator modifier trusts.
So if the "canonical message service" is set to the EthereumGateway itself, and the calldata is sendMessage(...) wrapping syncBatchRoot(...), both access checks pass. The gateway believes the Arbitrator asked it to forward a root, and ZkLink believes its gateway delivered one.
Flaw 2: A Validation Hash That Outlives Its Claim
The only thing standing between that arbitrary call and a forged root is a check that runs after it:
bytes32 _finalizeMessageHash;
assembly { _finalizeMessageHash := tload(finalizeMessageHash.slot) }
require(
_finalizeMessageHash ==
keccak256(abi.encode(_sourceChainL1Gateway, _receiveValue, _receiveCallData)),
"Incorrect finalize data"
);
finalizeMessageHash lives in EIP-1153 transient storage. It is written by receiveMessage() when a registered gateway delivers a real message. Transient storage is scoped to the whole transaction, not to a single call. The Arbitrator never reset the slot after the check.
That turns the check into a much weaker statement. It no longer says "a genuine delivery happened during this claim". It says "a genuine delivery with these parameters happened at some point earlier in this transaction". Once one real claim arms the slot, any later claim in the same transaction that repeats the same gateway, value and calldata passes, even if its external call delivered nothing.
Note that nonReentrant does not help here. The two claims are sequential calls from the attacker's contract, not nested ones.
The Attack, Step by Step
BlockSec Phalcon and SlowMist describe the same sequence, and the token transfers we read from the transaction receipt match it. All steps ran inside one transaction sent from the attacker EOA to a purpose-built attack contract.
Step 1: Arm the Hash With a Genuine Message
The attacker called the public SyncL2TxHashRelayer to claim a real, pending Linea message that synchronised two L2 transaction hashes. The Linea bridge verified the proof, the Linea gateway called back into the Arbitrator, and finalizeMessageHash was set to the hash of that genuine message. The legitimate message was forwarded as normal. The slot stayed armed.
Step 2: Smuggle a Root Update Through the Same Function
The attacker called the relayer a second time in the same transaction. This time the "canonical message service" was the EthereumGateway, and the claim calldata was a sendMessage call wrapping syncBatchRoot(116409, attackerChosenRoot, 0). The gateway accepted it because the caller was the Arbitrator, and ZkLink stored the attacker's root for batch 116409 because the caller was its gateway.
Step 3: Pass Validation With the Stale Hash
The EthereumGateway never calls back into receiveMessage, so nothing overwrote the slot. The attacker supplied the same gateway, value and calldata as in Step 1, so the recomputed hash matched the stale one. The forged root survived.
Step 4: Withdraw Against the Forged Root
Finalising a withdrawal on zkLink is permissionless and relies only on the stored root. The attacker submitted 12 withdrawal proofs built against their own root: one ETH withdrawal from the ZkLink escrow and eleven token withdrawals from the L1ERC20Bridge. Every receiver was the attacker's address.
From the transaction receipt we read the following transfers out of the L1ERC20Bridge to the attacker EOA:
| Asset | Amount |
|---|---|
| pufETH | 245.7379 |
| USDC | 18,113.45 |
| ezETH | 12.5693 |
| rsETH | 6.8665 |
| STONE | 6.2998 |
| USDT | 3,360.90 |
| mstETH | 2.6372 |
| mswETH | 2.4826 |
| rswETH | 0.5000 |
| wstETH | 0.4660 |
| WBTC | 0.1736 |
On top of these, 22.43 ETH came out of the ZkLink escrow itself. Liquid restaking tokens made up most of the value, with pufETH alone accounting for the bulk of the loss.
A Simplified Version of the Bug
The snippet below is a simplified, illustrative model of the pattern, not zkLink's production code.
// Simplified illustration only
contract Router {
bytes32 transient lastDelivered; // armed by trusted callbacks
function onDelivered(bytes32 h) external onlyGateway {
lastDelivered = h;
}
function claim(address target, bytes calldata data, bytes32 expected) external {
(bool ok, ) = target.call(data); // caller picks target and data
require(ok);
require(lastDelivered == expected); // stale value still passes
// BUG: lastDelivered is never cleared
}
}
A safer shape pins the target and consumes the evidence:
// Simplified illustration only
function claim(bytes calldata data, bytes32 expected) external {
(bool ok, ) = canonicalService[msg.sender].call(data); // fixed target
require(ok);
require(lastDelivered == expected);
lastDelivered = bytes32(0); // one claim, one use
}
Parapet Labs, which replayed the attack on a mainnet fork, reports that either change alone makes the forged claim revert, and that both belong in a fix.
Follow-On Attacks and Response
BlockSec Phalcon later reported similar attack transactions against zkLink Nova on several L2 chains, and zkLink's own statement at 09:06 UTC on 29 October referred to an ongoing exploit of the Arbitrator on Ethereum and Optimism. Parapet Labs attributes this second wave, at about 07:16 UTC on 29 October, to a different actor and reports roughly 24.76 ETH plus token balances taken from escrows on Optimism, Base, Blast and Mantle. We have not independently verified those second-wave figures.
zkLink offered a 20% white-hat bounty for returned funds. At 13:08 UTC on 29 October it said block production on Nova had been suspended, the affected contracts emergency-patched and withdrawals temporarily disabled. Parapet also notes that pauses applied to secondary chains in 2025 blocked deposits but not withdrawal finalisation, which is why those paused escrows could still be drained.
Cross-chain message routers keep failing in similar ways. Our analyses of the Initia MoveVM bridge exploit and the WAX bridge negative fee exploit show the same theme: a bridge is only as safe as the weakest check on what it is told to release.
Lessons for Builders and Auditors
1. Consume transient flags after use. EIP-1153 slots persist for the whole transaction. If a value represents "this specific claim was authenticated", clear it with tstore(slot, 0) immediately after the check. Do the same for session flags such as claiming.
2. Never make arbitrary calls from a privileged identity. If other contracts authorise by msg.sender == address(this), then any caller-chosen target and calldata is a privilege escalation path. Pin the canonical service per gateway or keep an explicit allowlist of call targets and selectors.
3. Check before you act, not after. Here the privileged call ran first and validation came second. Ordering validation before effects is the same discipline that prevents reentrancy, and it removes whole classes of bugs.
4. Treat permissionless relayers as public entry points. An onlyRelayer modifier means little if one of the whitelisted relayers forwards any caller's arguments unchanged.
5. Make pauses cover withdrawals. An emergency stop that blocks deposits but leaves finalizeWithdrawal open does not protect escrowed funds.
6. Test multi-call transactions. Fuzz harnesses should call the same entry point several times within one transaction with mixed genuine and malicious inputs. Single-call unit tests will never catch a stale transient value. Our smart contract security checklist covers these test patterns.
Key Addresses and Transactions
- Exploit transaction:
0x4f3c23e7...297462c - Related transaction listed by SlowMist:
0x8a7e38b9...f8848ae9 - Attacker EOA:
0xaca0655a11200a80d33d0e48786317304cde5718 - Attack contract:
0xd5f162b68a0281686e8dc76db8d67d2f3e6aff07 - Second address listed by SlowMist:
0xdab137f390df77caa97fd492540e973fd5e2f8c8 - Arbitrator (proxy):
0x1ee09a2caa0813a5183f90f5a6d0e4871f4c6002 - ZkLink escrow:
0x5fd9f73286b7e8683bab45019c94553b93e015cf - L1ERC20Bridge:
0xad16edcf7deb7e90096a259c81269d811544b6b6
Sources
- BlockSec Phalcon alert on X: root cause, five-step breakdown, batch number and asset amounts
- SlowMist TI Alert on X: root cause, attacker, victim and vulnerable contract addresses, transactions
- Defimon alert on X: per-asset breakdown and loss estimate
- zkLink statement on X and follow-up: confirmation, bounty offer and emergency measures
- Parapet Labs post-mortem: fork replay, fix isolation and second-wave tracing
-
zkLink Arbitrator.sol on GitHub:
claimMessagesource - Transaction receipt, block timestamp and token transfers, read by us from a public Ethereum RPC node
Closing Thoughts
Transient storage is a useful tool, but it changes what "temporary" means. A flag that lasts for a transaction is not a flag that lasts for a call, and in a contract that can be called many times per transaction, that difference is the whole security model. Pair that with a privileged contract that will call anything it is told to, and one honest message becomes a key to every escrow.
Building a bridge, rollup or cross-chain messaging layer? Contact Panda Security for a review of your message authentication and access-control paths, or see our audit pricing.
Top comments (0)