zkLink Nova Exploit Post-Mortem
A public relayer and a never-cleared transient-storage latch let an attacker write their own Merkle root into zkLink bridge and drain $743k in one transaction.
On 2026-10-28, a single Ethereum transaction emptied both zkLink Nova mainnet escrows, about $743k in total. The attacker did not steal a key, forge a signature, or use a flash loan. They wrote their own Merkle root into the bridge’s batch registry and then withdrew against proofs they had crafted themselves.
This post-mortem explains the zkLink Nova exploit step by step. We rebuilt the deployed system from verified sources, forked Ethereum mainnet one block before the attack, replayed the exploit byte for byte, and hot-swapped patched code into the live proxy to prove which lines were responsible. Every claim below is backed by an executable test.
| Incident date | 2026-10-28 22:21:35 UTC (Ethereum block 26294188) |
| Exploit transaction | 0x4f3c23e7…462c |
| Estimated loss | ≈ $743k at exploit-time prices (ETH ≈ $2,358, BTC ≈ $81,171 via Chainlink at block 26294188) |
| Second wave | 2026-10-29 07:16 UTC: a copycat drained four more chains (Optimism, Base, Blast, Mantle), ≈ 24.76 ETH plus token inventories |
| Root cause | An arbitrary privileged external call combined with a transient-storage latch that is never consumed |
| Public flag | Blockaid on X, 2026-10-29 02:24 UTC (about 4 hours after the exploit) |
| Status at time of writing (updated 2026-10-29 ~10:00 UTC) | Vulnerability still unpatched and unpaused on Ethereum; second wave confirmed; Manta Pacific (~$39k) still injectable |
Summary: what happened in zkLink Nova exploit#
zkLink Nova is an aggregated L3. Withdrawals on each connected chain are proven against a Merkle root (a compact fingerprint of a batch of L2 withdrawal records) stored in an escrow contract. Whoever controls that stored root controls every withdrawal proven against it.
Roots reach the escrow through a relay layer, and its L1 message router, the Arbitrator, contained two compounding flaws:
- An arbitrary call with a trusted identity.
claimMessage()calls a relayer-supplied target withmsg.sender == Arbitrator, an identity every zkLink gateway trusts unconditionally (onlyArbitrator). - A validation latch that is never consumed. Its authenticity check uses an EIP-1153 transient-storage slot that is never cleared. Once any legitimate cross-chain message is claimed in a transaction, any number of forged claims in the same transaction pass validation against the stale hash.
Because the whitelisted relayer contract (SyncL2TxHashRelayer) is callable by anyone, the attacker needed no privileges. In one atomic transaction they:
- Relayed one real Linea→L1 message, which seeded the latch.
- Made a forged call that smuggled
EthereumGateway.sendMessageintoZkLink.syncBatchRoot, writing an attacker-chosen root for batch 116409. - Called the permissionless
finalizeEthWithdrawalandfinalizeWithdrawalfunctions with self-crafted Merkle proofs, draining 22.43 ETH and 11 ERC-20 tokens.
Both flaws are individually necessary, and either fix alone stops the exploit. We verified this on a pre-exploit mainnet fork
Impact: assets drained and where they went#
All funds went to the attacker EOA 0xaCA0655a11200A80d33D0E48786317304cDE5718 through the attack contract 0xD5f162B68a0281686E8Dc76dB8d67d2f3E6Aff07 (entrypoint selector 0x2ca7edea).
| Asset | Amount | ≈ USD | Drained from |
|---|---|---|---|
| ETH | 22.431294525563808989 | $52.9k | ZkLink escrow 0x5fD9F732…e015Cf |
| pufETH | 245.737898851061074796 | $579.4k | L1ERC20Bridge 0xAd16eDCF…544B6B6 |
| ezETH | 12.569253862270157793 | $29.6k | L1ERC20Bridge |
| USDC | 18,113.448053 | $18.1k | L1ERC20Bridge |
| rsETH | 6.866647159999 | $16.2k | L1ERC20Bridge |
| STONE | 6.299750644063014297 | $14.9k | L1ERC20Bridge |
| WBTC | 0.17360197 | $14.1k | L1ERC20Bridge |
| USDT | 3,360.898152 | $3.4k | L1ERC20Bridge |
| mswETH | 2.482619199006740438 | $5.9k | L1ERC20Bridge |
| mstETH | 2.6372057701 | $6.2k | L1ERC20Bridge |
| wstETH | 0.465958551040794797 | $1.3k | L1ERC20Bridge |
| rswETH | 0.500000001 | $1.2k | L1ERC20Bridge |
Both Ethereum escrows were emptied to zero. Liquid restaking tokens (LRTs) are approximated at the ETH price.
The attacker started moving funds within minutes:
- About 9 minutes later: USDC hopped to
0x45312ea0eff7e09c83cbe249fa1d7598c4c8cd4eat block 26294234. - About 40 minutes later: roughly 102 ETH, including proceeds from liquidating the LRT positions, consolidated to the fresh EOA
0x75dcf3395b6cbefc6b66b28d6d93f193b58dc0f3at block 26294387. - At time of writing: the original attacker EOA holds only 0.0009 ETH.
Nine hours later, a second, unrelated actor repeated the attack on four secondary chains; see Second wave.
Timeline#
All times are UTC.
| Time | Event |
|---|---|
| 2024-03-01 06:39 | Arbitrator proxy deployed (block 19338591). The vulnerable code was live from day one. |
| 2024-03-01 07:39 | ZkLink proxy deployed (block 19338891) |
| 2024-04-18 12:06 | SyncL2TxHashRelayer deployed (block 19682235) and whitelisted as a public relayer |
| 2025-03-10 05:58 to 06:26 | zkLink pauses its secondary-chain contracts (Base block 27398473, Arbitrum block 314133925, and others) as part of a wind-down unrelated to this incident. Deposits were disabled; withdrawals stayed enabled. |
| 2026-10-28 18:51 | Attack contract 0xD5f162B6…ff07 deployed (block 26293139), about 3.5 hours before the attack |
| 2026-10-28 22:21:35 | Exploit transaction (block 26294188): full drain in a single transaction |
| 2026-10-28 22:30 | Attacker starts moving funds (USDC hop) |
| 2026-10-28 ~23:00 | About 102 ETH consolidated to a fresh address |
| 2026-10-29 02:24 | Blockaid flags the attack publicly on X |
| 2026-10-29 04:03 | Second-wave actor prepares: receiver EOA 0xedac2d89…d0c8 funds sweeper EOA 0xfc3facd6…be92f |
| 2026-10-29 04:53 to 05:06 | Sweeper deploys three helper contracts |
| 2026-10-29 ~06:00 | Verified: Arbitrator implementation unchanged, public relayer still whitelisted, Ethereum contracts unpaused. The operator even kept executing batches on top of the forged root (totalBatchesExecuted advanced 116409 → 116410). |
| 2026-10-29 07:16:11 | Second wave: one Ethereum transaction (block 26296841) injects forged roots for Base, Optimism, Blast, and Mantle; a second EOA duplicates the Optimism message 36 seconds later |
| 2026-10-29 07:16:54 to 07:19:27 | Mantle, Optimism, Blast, and Base escrows drained to 0xedac2d89…d0c8 |
| 2026-10-29 09:25 | Wave-2 receiver starts cashing out through Blast’s canonical 7-day portal (9.55 ETH proven for withdrawal) |
How the zkLink Nova relay layer works#
zkLink Nova is an aggregated L3. Every connected chain hosts a zkLink escrow, a contract that holds bridged funds and stores l2LogsRootHashes[batch]: the Merkle roots of L2→L1 message logs against which withdrawals are proven.
A relay layer delivers those roots:
(relayer EOA or contract)
→ SyncL2TxHashRelayer 0x0FDC28e64772a70109d10fd068f859cFa7Dea2c6 ← callable by ANYONE
→ Arbitrator 0x1Ee09A2cAa0813A5183f90F5a6d0E4871f4C6002 ← privileged router
→ <Chain>Gateway 0x83Bc7394738A7A084081aF22EEC0051908c0055c ← EthereumGateway: onlyArbitrator
→ ZkLink.syncBatchRoot 0x5fD9F73286b7E8683Bab45019C94553b93e015Cf ← onlyGateway; writes l2LogsRootHashes
The relayer is permissionless by design. That is a legitimate feature, and it is also the front door for this exploit.

Key contracts on Ethereum mainnet
| Contract | Address | Role |
|---|---|---|
| ZkLink (“Nova: zkLink”) | 0x5fD9F73286b7E8683Bab45019C94553b93e015Cf | ETH escrow and batch-root registry |
| L1ERC20Bridge | 0xAd16eDCF7DEB7e90096A259c81269d811544B6B6 | ERC-20 escrow |
| EthereumGateway | 0x83Bc7394738A7A084081aF22EEC0051908c0055c | Secondary-chain gateway (Ethereum), onlyArbitrator; forwards raw calldata into ZkLink |
| Arbitrator | 0x1Ee09A2cAa0813A5183f90F5a6d0E4871f4C6002 | Cross-chain message router, onlyRelayer |
| SyncL2TxHashRelayer | 0x0FDC28e64772a70109d10fd068f859cFa7Dea2c6 | Whitelisted relayer with no caller authentication |
| Linea L1MessageService | 0xd19d4B5d358258f05D7B411E21A1460D11B0876F | Linea canonical bridge (L1 side) |
| LineaL1Gateway | 0x803460416C2682Ac54FccF03eF77b10A12f2809b | Primary-chain gateway (Nova settles to Linea); its callback arms the Arbitrator latch |
Root cause: two flaws that combine#
Flaw 1: an arbitrary external call that carries a trusted identity#
Arbitrator.claimMessage() takes the “canonical message service” to call from the relayer, then calls it with the Arbitrator’s own identity:
// Arbitrator.sol (impl 0x7a0Bb4805EcB7fD3C8DAbbE4AFa0E91cB1761E34)
function claimMessage(
address _sourceChainCanonicalMessageService, // ← relayer-controlled target
bytes calldata _sourceChainClaimCallData, // ← relayer-controlled payload
IL1Gateway _sourceChainL1Gateway,
uint256 _receiveValue,
bytes calldata _receiveCallData,
bytes calldata _forwardParams
) external payable nonReentrant onlyRelayer {
assembly { tstore(claiming.slot, 1) }
// executed with msg.sender == Arbitrator, an identity every gateway trusts
Address.functionCall(_sourceChainCanonicalMessageService, _sourceChainClaimCallData);
...
Every gateway enforces onlyArbitrator:
// LineaL1Gateway.sol (same pattern in every L1 gateway)
function sendMessage(uint256 _value, bytes calldata _callData, bytes calldata)
external payable onlyArbitrator { ... }
and ZkLink enforces onlyGateway:
// ZkLink.sol
function syncBatchRoot(uint256 _batchNumber, bytes32 _l2LogsRootHash, uint256 _forwardEthAmount)
external payable onlyGateway
{
if (_batchNumber > totalBatchesExecuted) { totalBatchesExecuted = _batchNumber; }
l2LogsRootHashes[_batchNumber] = _l2LogsRootHash; // ← attacker-chosen root
emit SyncBatchRoot(_batchNumber, _l2LogsRootHash, _forwardEthAmount);
}
So if the relayer names EthereumGateway itself as the “canonical message service” and supplies the payload sendMessage(0, syncBatchRoot(116409, <forgedRoot>, 0), ""), both access-control guards pass and a forged root lands.
Flaw 2: the authenticity latch is never consumed#
The only defense against the arbitrary call above is a validation step built on EIP-1153 transient storage. This is the heart of the exploit, so it is worth being precise about how that storage behaves and exactly where the choreography breaks.
How EIP-1153 transient storage works#
EIP-1153 (live since the Dencun upgrade) adds two opcodes, TSTORE and TLOAD, that operate on a second data space alongside regular storage:
- Scope is the transaction. A value written in any call frame is readable from every later frame of the same transaction, across arbitrary external calls.
- It vanishes when the transaction ends. Nothing persists on chain, so there is nothing to clean up between transactions.
- It rolls back on revert, like regular storage: if a frame reverts, all transient writes made inside that frame are undone.
- It is cheap.
TSTORE/TLOADcost 100 gas each, versus up to 20,000 for a regularSSTORE.
The Arbitrator declares two ordinary-looking state variables that are never actually written to regular storage. They exist only to name their transient slot indices through the .slot compile-time constant:
// Arbitrator.sol
/// @dev A transient storage value for forwarding message from source chain to target chains
bytes32 private finalizeMessageHash;
/// @dev A transient storage value for represent a valid message claim
uint256 private claiming;
All reads and writes happen in inline assembly, and the two slots play different roles:
claimingis a session flag.claimMessagesets it to1on entry (tstore(claiming.slot, 1)), andreceiveMessagerequires it to be1, so that a gateway callback is only honored inside a claim the Arbitrator itself initiated.finalizeMessageHashis the authenticity latch.receiveMessagestoreskeccak256(abi.encode(msg.sender, _value, _callData))in it, recording which message was genuinely delivered during the current claim.
The intended choreography#
A legitimate relay of a secondary-chain message (for example from Linea) is meant to look like this:
relayer
└─ Arbitrator.claimMessage(CMS, claimCallData, gateway, value, receiveCallData, ...)
├─ tstore(claiming, 1) // open a claim session
├─ CALL canonical message service (here Linea L1MessageService.claimMessageWithProof)
│ └─ bridge verifies the Merkle proof, marks the message claimed
│ └─ LineaL1Gateway.claimMessageCallback(...)
│ └─ Arbitrator.receiveMessage(value, callData) // msg.sender = gateway
│ ├─ require(tload(claiming) == 1) // session is open
│ └─ tstore(finalizeMessageHash, keccak256(gateway, value, callData))
├─ require(tload(finalizeMessageHash) == keccak256(gateway, value, receiveCallData))
│ // "the message I was told to claim is the one the bridge just delivered"
└─ forward receiveCallData to the destination gateway(s) (here: EthereumGateway)
The design intent is sound on paper: the latch can be written only by a registered gateway (receiveMessage enforces msg.sender == gateway) and only as the result of a real canonical delivery, so matching it proves the claimed message really arrived.
The flaw: the latch outlives the claim it authorizes#
Two properties collide:
- Transient storage lifetime is the transaction. The latch survives every subsequent call frame until the transaction ends.
- The authorization being checked has a lifetime of one claim. Each
claimMessagecall is supposed to be validated against a delivery that happened inside that call.
The code only enforces the second property if the slot is cleared after each check. It never is:
// claimMessage(), after the arbitrary external call:
bytes32 _finalizeMessageHash;
assembly { _finalizeMessageHash := tload(finalizeMessageHash.slot) }
require(
_finalizeMessageHash == keccak256(abi.encode(_sourceChainL1Gateway, _receiveValue, _receiveCallData)),
"Incorrect finalize data"
);
// ← latch is NEVER reset to zero after the check
So the check does not prove “a canonical delivery happened during this claim”. It proves the weaker statement “a canonical delivery with these parameters happened somewhere earlier in this transaction”. Once any genuine claim has armed the latch, every later claimMessage in the same transaction that replays the same (gateway, value, callData) triple validates against the stale hash, even if its “canonical message service” call delivered nothing at all.
Note also the ordering: the privileged arbitrary call runs before the check. By the time the require executes, the smuggled syncBatchRoot has already been invoked. The only thing that could undo it is the atomic revert if the check fails, which makes this latch the sole runtime barrier for a state write that has already happened inside the current call tree.
(The claiming slot has the same never-cleared defect: after the first claim it remains 1 for the rest of the transaction, so the session guard in receiveMessage is sticky too. It matters less because only registered gateways can call receiveMessage, but it is the same mistake.)
Weaponization: arm the latch once, then forge#
The attacker cannot write the latch directly. receiveMessage requires msg.sender to be a registered gateway, and the only way to make a gateway call it is a genuine canonical delivery, which on Linea requires a valid Merkle proof of a real L2 message. The latch contents must be inherited from a real delivery, not fabricated.
Unfortunately, candidate seeds are public. Linea message #108666 was sitting pending in the canonical bridge, claimable by anyone, and its (gateway, value, callData) triple is fully determined by the message itself: LineaL1Gateway, value 0, and the real receiveCallData carrying the two L2 transaction hashes being synced (0x25754f57…ffadc, 0xe9ec340b…46ba52). Anyone could compute the latch value keccak256(LineaL1Gateway, 0, receiveCallData) in advance.
Why can’t the attacker skip leg 1 and forge directly with an honest-looking triple? Two reasons. First, at the start of every transaction the latch cell is 0, and a keccak256 output is never 0, so the check reverts with Incorrect finalize data. Second, the attacker cannot arm the cell themselves: the only writer is receiveMessage, which requires msg.sender to be a registered gateway, and a gateway only calls it inside a genuine, proof-verified canonical delivery. The cell is armed only by reality. So the honest triple in leg 2 is not a choice; it is borrowed from whatever real message was delivered in leg 1.
The recipe is then:
- Leg 1 (genuine). Relay message #108666 through the public relayer. The real canonical delivery arms the latch with exactly the expected hash. The check passes, the real message is forwarded, and the latch is left armed.
- Leg 2 (forged). Call the same relayer again, this time with
_sourceChainCanonicalMessageServiceset to EthereumGateway and the smuggled payloadsendMessage(0, syncBatchRoot(116409, <forgedRoot>, 0), ""), but with the identical(LineaL1Gateway, 0, receiveCallData)triple. The smuggled call writes the forged root and produces no callback, so nothing overwrites the latch. The post-calltloadreturns the stale leg-1 hash, which matches the replayed triple, and validation passes.
Which of the call’s parameters does the latch actually authenticate? Only three of the six:
claimMessage parameter | In leg 2 | Authenticated by the latch? |
|---|---|---|
_sourceChainCanonicalMessageService | changed to EthereumGateway (the smuggle target) | No |
_sourceChainClaimCallData | changed to the syncBatchRoot payload | No |
_forwardParams | free | No |
_sourceChainL1Gateway | LineaL1Gateway, same as leg 1 | Yes |
_receiveValue | 0, same as leg 1 | Yes |
_receiveCallData | identical bytes to leg 1 | Yes |
The attacker replays the three authenticated parameters verbatim, so the hash recomputed in leg 2 is bit-identical to the one the genuine delivery armed the latch with. The two parameters that cause the damage, who gets called and with what payload, sit outside the hash entirely.
The latch state over the transaction:
| Point in the transaction | claiming | finalizeMessageHash | Consequence |
|---|---|---|---|
| Transaction start | 0 | 0 | A forge attempt here reverts with Incorrect finalize data: a hash never equals 0 |
| Leg 1 entry | 1 | 0 | Claim session opens |
| Leg 1 canonical callback | 1 | H = keccak(LineaL1Gateway, 0, realCallData) | Latch armed by a genuine delivery |
| Leg 1 check and forward | 1 | H | Real message accepted; slots not cleared |
Leg 2 smuggled syncBatchRoot call | 1 | H | Forged root written; no callback; latch untouched |
| Leg 2 check | 1 | H | Stale H matches the replayed triple; the forge is “validated” |
| Leg 3 withdrawals | (discarded at tx end) | (discarded at tx end) | Permissionless finalization pays out against the forged root |
Three properties make this so dangerous:
- One seed, unlimited forges. A single genuine claim validates any number of forged claims replaying the same triple in the same transaction. The attacker needed only one forge, but the ceiling was higher.
- Repeatable while any real message is pending. Every genuinely claimable message is a fresh seed for a fresh attack transaction. Until the code is patched or the public relayer is removed, the vulnerability re-opens with every new relayable message.
- Atomic and invisible. The seed, the forged root write, and the drain happen in one transaction. There is no window in which the forged root exists on chain but funds have not yet moved, so monitoring the root registry alone cannot react in time.
This is also why Fix B (see Recommended fix) is sufficient on its own: consuming the latch with tstore(finalizeMessageHash.slot, 0) immediately after the require restores the intended one-claim lifetime. Our fork test of that single change shows leg 2 reverting with Incorrect finalize data, and because the revert happens after the smuggled syncBatchRoot call in program order, the forged root write is rolled back atomically along with everything else.
Entry point: a public relayer#
claimMessage is onlyRelayer, but the relayer whitelist contained a contract with no caller authentication:
// SyncL2TxHashRelayer.claimPrimaryChainSyncL2TxHashMessage(...) is callable by anyone;
// it forwards its arguments verbatim into Arbitrator.claimMessage(...)
Permissionless relaying is intentional, but it means the exploit required no privileges at all: one EOA, one transaction.
Anatomy of the exploit transaction#
The attack contract’s 0x2ca7edea entrypoint performed three legs atomically.

Leg 1: seed the latch (legitimate)#

Relay the real, pending Linea→L1 protocol message #108666 through this path:
SyncL2TxHashRelayer → Linea L1MessageService.claimMessageWithProof → LineaL1Gateway.claimMessageCallback → Arbitrator.receiveMessage
This legitimately sets the transient latch to keccak256(LineaL1Gateway, 0, <real receiveCallData>). Because the slot is never cleared, it stays armed for the rest of the transaction.
Key values in this leg:
- Canonical message service passed:
0xd19d…876F(the real Linea bridge) - Claim calldata:
claimMessageWithProoffor pending message #108666, with a valid Merkle proof - Latch write:
H = keccak256(LineaL1Gateway, 0, realCallData), performed byreceiveMessageduring the genuine delivery - Latch state afterwards: still
H; nothing clears it
Leg 2: forge the root#

Call the same relayer again with:
_sourceChainCanonicalMessageService= EthereumGateway (the victim, not a message service)_sourceChainClaimCallData=sendMessage(0, syncBatchRoot(116409, 0x1934a1c3…f915ea8, 0), "")- The same Linea gateway, value, and callData as leg 1, so the post-call check compares the latch against a replay of the triple it was armed with
- The same two protocol tx hashes (
0x25754f57…ffadc,0xe9ec340b…46ba52)
EthereumGateway.sendMessage sees msg.sender == Arbitrator and calls ZkLink.syncBatchRoot, which sees msg.sender == gateway. The result: l2LogsRootHashes[116409] = 0x1934a1c3…, and totalBatchesExecuted jumps to 116409. Because EthereumGateway never calls back into Arbitrator.receiveMessage, nothing overwrites the latch, and the check that runs after the forged write still sees the hash leg 1 left behind (see the latch trace in Flaw 2).
The reason no callback happens is structural, not incidental. The gateway code has two separate lanes: the claim lane (claimMessageCallback, invoked only by the canonical bridge after a verified delivery, and the only place the latch is written) and the forward lane (sendMessage, invoked by the Arbitrator to push commands into ZkLink, with no latch involvement). The intended choreography pairs them inside one claimMessage: claim lane writes the latch, check, forward lane spends it. Leg 2 names a forward-lane-only contract as the “canonical message service”, so the claim lane simply never runs, and the check ends up consulting a latch written by a previous, legitimate pairing.
Key values in this leg:
- Changed: the call target (EthereumGateway,
0x83Bc…055c) and the payload (syncBatchRootcarrying the attacker’s root) - Unchanged: the authenticated triple
(LineaL1Gateway, 0, realCallData), so the recomputed hash still equalsH - Latch writes in this leg: none; EthereumGateway never calls back, so the slot keeps its stale value
- Result:
l2LogsRootHashes[116409] = 0x1934a1c3…f915ea8andtotalBatchesExecuted = 116409
Leg 3: drain#

With a root computed from their own withdrawal messages, the attacker used permissionless finalization to get paid:
// ZkLink.sol: anyone can call; validity rests entirely on l2LogsRootHashes
bool proofValid = proveL2MessageInclusion(_l2BatchNumber, _l2MessageIndex, l2ToL1Message, _merkleProof);
require(proofValid, "pi");
...
_withdrawFunds(_l1WithdrawReceiver, _amount);
One finalizeEthWithdrawal call pulled 22.43 ETH from the ZkLink escrow, and eleven finalizeWithdrawal calls on L1ERC20Bridge pulled the tokens. Every receiver was hardcoded to the attacker EOA.
Key values in this leg:
finalizeEthWithdrawal(116409, 0, 1, msg, proof): 22.43 ETH out of the ZkLink escrowfinalizeWithdrawal(116409, 1..11, ...): eleven token withdrawals out of L1ERC20Bridge- Why the checks pass:
116409 <= totalBatchesExecuted(bumped in leg 2), and the computed Merkle root equals the stored root because the attacker built the tree themselves - All receivers:
0xaCA0…5718
Second wave: a copycat drains four more chains#
About 9 hours after the original drain, and about 5 hours after Blockaid’s public disclosure, a different actor re-used the still-unpatched flaw against the OP-stack secondary escrows the original attacker had ignored. Everything in this section is traced on-chain.
One Ethereum transaction, four forged roots#
At 07:16:11 UTC (block 26296841), EOA 0xfc3facd67138966ab0c841e905b0c4bca1abe92f sent one transaction (0xb54c7aea…dc27) to a helper contract (0xb6dc3e9b…b9d0) that pushed four forged-root messages, one per chain, through the canonical L1 messengers of Base, Optimism, Blast, and Mantle.
Two fingerprints prove this was the exploit path, not an operator action:
- Every
SentMessagenames the chain’s registered zkLink L1 gateway as its sender (Optimism’s0x668e8F67…3026, and the per-chain equivalents). A gateway only calls its messenger when someone invokes itssendMessage, and the only legitimate caller, the L1 ZkLink batch flow, did not run:totalBatchesExecutedon Ethereum never moved from 116410. - Each chain received its own next-expected batch number and a distinct root (Optimism got batch 116406 with root
0x392e06f4…246700; Mantle got batch 112381 with root0x95c12563…af08cf), because every secondary tracks its own batch counter.
Thirty-six seconds later, a different EOA (0x17c8b3d9…164c) deployed a one-shot contract that repeated the Optimism message (0x84e76b89…ed19), either a mempool copycat or a teammate.
Landings and sweeps#
Each forged root rode its chain’s canonical bridge to the L2 gateway and was accepted by ZkLink.syncBatchRoot. Finalization followed within seconds, through the normal proof-based path (on each chain the root landed one or two blocks before the sweep; Mantle root tx 0xa0bf17dd…eb68 at 07:16:54, Optimism root tx 0x84fe6d02…12ff at 07:17:27):
| Chain | Sweep transaction | Time (UTC) | Taken |
|---|---|---|---|
| Mantle | 0x2847de46…480d2 | 07:16:58 | 23,424 USDC, 11,033 USDT, 9.9 mETH, 0.86 WETH, 3 minor tokens, 0.00744 MNT |
| Optimism | 0x499cb1f1…a96d | 07:17:29 | 5.86 ETH + 11 tokens |
| Blast | 0x3931ec36…96ed | 07:17:33 | 4.81 ETH |
| Base | 0x2b6b46eb…ec5f | 07:19:27 | 14.09 ETH + 6 tokens |
Total: 24.76 ETH plus the remaining token inventories of all four chains (including 36,830 OP and the Mantle stablecoins), on the order of $100k or more.
Attribution: a second, unrelated actor#
Every withdrawal on all four chains paid a single receiver: 0xedac2d8905bf2bf0033872d420c4ec6ef1ddd0c8. That receiver sent gas money to the sweeper EOA at 04:03:59 UTC, three hours before the sweep, so both are one entity. What they are not:
- Not the original attacker. Neither address overlaps with the wave-1 cluster (
0xaCA0655a…5718and its hops), and both show ordinary DeFi activity predating the exploit by days. - Not a team rescue. A rescue would use operator keys and pay a team multisig; genuine user withdrawals would pay many users against an operator-posted root. This paid one private EOA against a root injected through the public exploit path.
This is a copycat who weaponized the public disclosure. At 09:25 UTC the receiver began cashing out through canonical 7-day portals (a 9.55 ETH proveWithdrawalTransaction against Blast’s OptimismPortal from a fresh gas-funded EOA), which is exit behavior, and law-enforcement relevant because those finalizations are delayed and observable.
Why the second wave was possible at all#
Nothing had been mitigated in the 9 hours since the exploit: same vulnerable Arbitrator implementation, same whitelisted public relayer, unpaused ZkLink. The wave-2 actor needed no new vulnerability, only fresh latch seeds (any pending genuine message works) and gas. The 2025 deposit pauses on the secondary chains did not help, because withdrawal finalizers carry no whenNotPaused (see Finding 1). The pauses did not block a single one of the four drains.
(Methodology note: the fourth message’s chain was initially misread as Manta because zkLink’s L2 gateway shares the address 0xe946abb4…39d0 on both Mantle and Manta. The messenger it was sent through, 0x676a795f…7120, is Mantle’s L1CrossDomainMessenger, proven by the alias math on Mantle’s L2 relay transactions. Manta’s messenger is 0x635ba609…c310.)
Blast radius: what remains exposed after the second wave#
Per the official zkLink docs, zkLink deployed escrows on 9 other chains. Balances before the second wave, and their status now:
| Chain | Native in escrow | Notable bridge tokens | Status |
|---|---|---|---|
| Linea | 601.35 ETH | 8,231 USDC, 29,908 USDT, 31.05 ezETH | Not exposed to this vector (Finding 3) |
| Arbitrum | 52.85 ETH | 23,767 USDC, 11,764 USDT, 246,801 ARB | Behind an incidental L1 fee wall (Finding 2) |
| zkSync Era | 14.50 ETH | 54,353 USDC(.e), 2,504 USDT, 0.35 WBTC | Behind an incidental L1 fee wall |
| Scroll | 2.90 ETH | 4,712 USDC, 136 USDT | Behind an incidental L1 fee wall |
| Manta | 0.56 ETH | 37,709 USDC (at bridge escrow 0x44a65dc1…68FF) | Intact but injectable at any time |
| Mantle | 40.33 MNT residual | none left | Tokens drained in wave 2; residual MNT exposed |
| Optimism | 0 | 0 | Drained in wave 2 (was 5.86 ETH + 11 tokens) |
| Base | 0 | 0 | Drained in wave 2 (was 14.09 ETH + 6 tokens) |
| Blast | 0 | 0 | Drained in wave 2 (was 4.81 ETH) |
| Ethereum | dust 0.0025 ETH | 0 | Drained in wave 1 |
Of the roughly $2M this vector could reach at exploit time, about $100k+ has since been taken in the second wave. What remains immediately injectable today is Manta’s ~$39k and the Mantle residual. The fee-walled chains and Linea stay exposed in principle until the Ethereum Arbitrator is patched, because the fee wall is incidental, not a design property.
Finding 1: the pauses do not protect anything#
The secondary-chain pauses date from the 2025-03-10 wind-down. They only block deposits. finalizeEthWithdrawal and finalizeWithdrawal carry no whenNotPaused modifier, so withdrawals, including withdrawals against forged roots, still work on paused chains. Proven on fork, and confirmed live: all four wave-2 victims were paused chains.
Finding 2: the live Ethereum flaw reaches other chains#
Every chain’s L1 gateway is registered in the same vulnerable Arbitrator and gated by onlyArbitrator, so the same two-leg trick can smuggle a real canonical-bridge message that carries a forged root.
- Optimism, Base, Blast, Mantle: no longer theoretical. The second wave executed exactly this path on all four (see above).
- Manta: same pattern applies and the chain still holds funds; it is the only chain with meaningful balances still reachable by the public exploit path.
- Arbitrum, zkSync, Scroll: the zero-value smuggle cannot pay the canonical bridge’s L1-side fee (
InsufficientFundsinsidecreateRetryableTicket, and the zkSync/Scroll equivalents). That currently, and only incidentally, blocks injection. L2-side acceptance of a forged canonical message is still proven for Arbitrum on fork.
Finding 3: Linea is not exposed to this vector#
Linea holds the largest pool (601 ETH), but its diamond runs a different, validator-permissioned executor (commitBatches and executeBatches) with no syncBatchRoot, and no Arbitrator or relayer code exists at the expected addresses.
Response assessment#
As of 2026-10-29 ~10:00 UTC (about 12 hours after the exploit), no on-chain mitigation had been applied:
- The Ethereum contracts were unpatched and unpaused.
- The public relayer was still whitelisted.
- The operator kept syncing batches on top of the forged root (
totalBatchesExecuted116409 → 116410), so new deposits to the Ethereum escrow remained immediately stealable. - The exploit had been public for about 4 hours (Blockaid), and proceeds were already moving through fresh addresses within the hour.
- The cost of the non-response is now concrete: at 07:16 UTC a copycat re-used the open vector to drain four secondary escrows (~24.76 ETH plus token inventories), about 5 hours after the vulnerability was publicly disclosed. See Second wave.
The March 2025 deposit pauses on secondary chains are unrelated to this incident.
Batch 116409’s root is forged, and any accounting built on it should be considered burned. The escrows’ losses correspond to real L2 withdrawal claims that can no longer be honored on Ethereum.
Lessons for builders and auditors#
- Never let a privileged contract make arbitrary calls to caller-supplied targets for simpler security model.
msg.sender-based authorization turns any such call into a privilege-escalation primitive. - Consume your latches. A transient-storage check that does not invalidate its own evidence works exactly once, and then it works for the attacker.
- Treat permissionless relayers as public entry points.
onlyRelayermeans nothing when a whitelisted relayer contract forwards arguments verbatim. - Make sure emergency controls actually freeze funds. A pause that does not stop withdrawals is a placebo.
- Prove root cause with fix isolation. If a proposed fix does not make the exploit revert on a fork while the unpatched code still allows it, you do not yet know the root cause.
zkLink Nova exploit FAQ#
How was zkLink Nova exploited?#
An attacker used the public relayer to make the privileged Arbitrator call EthereumGateway.sendMessage with a syncBatchRoot payload, writing a Merkle root of their own choosing for batch 116409. A stale EIP-1153 transient-storage latch let the forged call pass validation. They then finalized withdrawals against that root.
How much was lost in the zkLink Nova hack?#
About $743k at exploit-time prices: 22.43 ETH plus 11 ERC-20 tokens, led by 245.74 pufETH.
Were private keys or signatures compromised?#
No. The attacker needed no privileged access. The relayer contract is callable by anyone, so one EOA and one transaction were enough.
Is zkLink Nova still vulnerable?#
Yes. As of 2026-10-29 ~10:00 UTC the Ethereum Arbitrator still runs the vulnerable implementation and the public relayer is still whitelisted, so forged roots can be injected at any time. That is no longer hypothetical: a copycat used it to drain Optimism, Base, Blast, and Mantle about 9 hours after the original exploit (see Second wave). Manta Pacific ($39k) is the only chain with meaningful funds still reachable by the exploit; Arbitrum, zkSync Era, and Scroll sit behind an incidental L1 fee barrier; Linea is not exposed to this vector; and the 2025 deposit pauses do not block withdrawals.
What is the fix?#
Pin the canonical message service per gateway so the relayer cannot choose the call target (Fix A), and clear the transient-storage latch after the check so one real claim authorizes exactly one forward (Fix B). Either fix alone stops this exploit, and both belong in the patch. Also add whenNotPaused to the withdrawal finalizers.
What is the vulnerability class?#
A privilege-escalation bug in a cross-chain message router: an arbitrary external call made under a trusted identity (msg.sender == Arbitrator), combined with a validation latch that was never reset.
References#
- Exploit transaction: etherscan.io/tx/0x4f3c…462c
- Attacker:
0xaCA0655a…5718and attack contract0xD5f162B6…ff07 - Vulnerable contracts:
Arbitrator,EthereumGateway,ZkLink,L1ERC20Bridge,SyncL2TxHashRelayer - Public disclosure: Blockaid on X
- Deployment addresses per chain: zkLink docs