DEV Community

PandaSecurity
PandaSecurity

Posted on Originally published at panda-security.xyz

zkLink Nova Exploit: A Stale Transient Hash Forged a Root

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 transient finalizeMessageHash that 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:

  1. A relayer calls the Arbitrator, a cross-chain message router gated by onlyRelayer.
  2. The Arbitrator claims a message from a source chain's canonical message service (for Nova, Linea's L1MessageService).
  3. During that claim, the source chain's gateway calls back into Arbitrator.receiveMessage(), which records a hash of what was genuinely delivered.
  4. The Arbitrator checks that hash and forwards the message to destination gateways, such as the EthereumGateway.
  5. The EthereumGateway, gated by onlyArbitrator, calls ZkLink.syncBatchRoot(), gated by onlyGateway, 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
Enter fullscreen mode Exit fullscreen mode

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"
);
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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

Sources

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)