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.
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.SyncL2TxHashRelayer, a whitelisted relayer contract that anyone can call.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:
onlyRelayer.L1MessageService).Arbitrator.receiveMessage(), which records a hash of what was genuinely delivered.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.
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
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
0x4f3c23e7...297462c0x8a7e38b9...f8848ae90xaca0655a11200a80d33d0e48786317304cde57180xd5f162b68a0281686e8dc76db8d67d2f3e6aff070xdab137f390df77caa97fd492540e973fd5e2f8c80x1ee09a2caa0813a5183f90f5a6d0e4871f4c60020x5fd9f73286b7e8683bab45019c94553b93e015cf0xad16edcf7deb7e90096a259c81269d811544b6b6claimMessage sourceTransient 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.
How a UTC day rollover booked a scheduled principal payment as received, reopened draw capacity and let an attacker drain 1.013M USDC on Avalanche.
How unlimited approvals to an address that was empty on Avalanche let anyone deploy a sweeper there and pull $7.45M from a 2-of-7 Safe multisig.
A MoveVM script-caching flaw let one account credit itself unbacked WETH, USDC and AUSD on Initia, and three bridge reserves paid out about $2.7M in real assets.
A zero-quorum DAO vote gave an attacker Yapeswap's feeToSetter role, and a fake Yearn registry then routed every pair's farmed reserves into attacker vaults.