Ethereum
Signature and replay

zkLink's $783K Loss and How Olympix Would Have Prevented It

Loss
Loss
Exploit date
October 28, 2026

At 22:21:35 UTC on October 28, 2026, a single Ethereum transaction convinced zkLink Nova's bridge that a batch of withdrawals had been proven when it never had. The bridge paid out 245.74 pufETH, 18,113 USDC, 3,361 USDT, a spread of liquid staking tokens and 22.43 ETH to a wallet nobody had seen before. Three hours later a second wallet came back for whatever was left. SlowMist put the damage at about $783,100.

The opening the attacker used was a single value. zkLink's Arbitrator contract wrote it into transient storage to prove a message was authentic, then read it and never wiped it. A hash that was meant to vouch for one message ended up vouching for a second one the attacker wrote themselves.

The Contract Every Batch Root Passes Through

zkLink Nova is an aggregated rollup that settles on Linea and accepts deposits from Ethereum and several Layer 2 networks. Assets stay locked in contracts on the chain they came from, and users withdraw them by proving their withdrawal against a batch root the network has finalized. On Ethereum, the ZkLink contract held the bridged ETH and the L1ERC20Bridge held the ERC-20 tokens.

According to zkLink's documentation, once a batch passes proof verification on the primary chain, its root travels back out through each chain's canonical bridge to the Arbitrator on Ethereum, and the Arbitrator forwards it to the secondary chains. Only after a confirmed root lands on a chain can withdrawals there execute. That makes the Arbitrator the gatekeeper for every root that unlocks bridged funds. If it forwards a fake root, the bridge on the other end has no way to tell.

A Handshake Written in Transient Storage

The Arbitrator verifies messages with a handshake built on transient storage, the EIP-1153 feature that keeps a value alive for the length of one transaction and clears it when the transaction ends. Two transient variables do the work. The claiming flag shows that a claim started inside the Arbitrator, and finalizeMessageHash holds the hash of the message the canonical bridge just delivered.

The flow runs like this. A permitted relayer calls claimMessage, which sets claiming to 1 and then calls the source chain's canonical message service with whatever address and calldata the relayer passed in. That service is supposed to call back into the gateway, which calls receiveMessage on the Arbitrator. receiveMessage checks the claiming flag, hashes the sender, value and calldata, and writes the hash to finalizeMessageHash. Control returns to claimMessage, which reads the hash and compares it with the message the relayer claims to be forwarding. From the Arbitrator implementation's verified source on Blockscout:

// Set `claiming` to 1 and check it in `receiveMessage`
assembly {
    tstore(claiming.slot, 1)
}
// Call the claim interface of source chain message service
// And it will inner call the `claimMessageCallback` interface of source chain L1Gateway
// In the `claimMessageCallback` of L1Gateway, it will inner call `receiveMessage` of Arbitrator
// No use of return value
Address.functionCall(_sourceChainCanonicalMessageService, _sourceChainClaimCallData);

// Load the transient `finalizeMessageHash`
bytes32 _finalizeMessageHash;
assembly {
    _finalizeMessageHash := tload(finalizeMessageHash.slot)
}
require(
    _finalizeMessageHash == keccak256(abi.encode(_sourceChainL1Gateway, _receiveValue, _receiveCallData)),
    "Incorrect finalize data"
);

The require is the whole security check, and it quietly assumes that the external call just above it refreshed finalizeMessageHash. Nothing enforces that. The function reads the slot and moves on, and nowhere in the contract does it write the slot back to zero or reset claiming. Within a single transaction, the hash from the first claim stays in place for every claim that follows. If a later call to the message service never triggers receiveMessage at all, the require still compares against the old hash and passes. The comment says the return value isn't used, and in practice neither is any proof that the callback happened.

Two Claims, One Hash

According to SlowMist, the attacker went in through SyncL2TxHashRelayer, a relayer contract the Arbitrator trusted as an authorized relayer but that anyone could call. The first claim was genuine. It passed through the canonical message service, triggered receiveMessage and loaded a valid hash into finalizeMessageHash. Then, SlowMist says, the attacker reused that hash while substituting EthereumGateway.sendMessage as the claim target. claimMessage made its external call straight into the Ethereum gateway with the Arbitrator's own identity, the stale hash satisfied the require, and the gateway executed ZkLink.syncBatchRoot. An attacker-chosen l2LogsRootHashes root was now installed on Ethereum, and withdrawal proofs built against it looked valid.

The on-chain trace of the first transaction, 0x4f3c...462c, fits that account. Sent from attacker wallet 0xaca0...5718 to its attack contract in block 26294188, it calls the Arbitrator three times and its implementation five times in delegatecalls before the payouts begin. The L1ERC20Bridge then released 245.737898 pufETH, 12.569253 ezETH, 18,113.448053 USDC, 6.866471 rsETH, 6.299750 STONE, 0.17360197 WBTC, 3,360.898152 USDT, 2.482619 mswETH, 2.637205 mstETH, 0.465958 wstETH and 0.5 rswETH to the attacker, and the ZkLink contract sent 22.43 ETH. According to Blockfence, that one transaction was worth about $779,000, with the pufETH alone worth roughly $615,000.

A second wallet, 0xDAB1...F8c8, followed at 01:43:11 UTC on October 29 in block 26295187 and swept the leftovers from the same bridge, including 2,132.70 GAL, 53.55 USDe, 0.0188 uniBTC and dozens of smaller balances. Blockfence reports that much of the stolen token balance was swapped to ETH and that about 303 ETH was split across three addresses shortly after the exploit.

How zkLink Responded

Blockaid flagged the attack while it was still running and gave a preliminary loss figure of about $786,000, according to Blockfence. Early on October 29 (UTC), zkLink confirmed an ongoing exploit of the Nova Arbitrator contract on Ethereum and Optimism, asked the attacker to stop and return the funds, and offered a 20% white hat bounty with no further action if the funds came back. A later update, as reported by Bitcoin Sistemi, said the team had halted block production, closed withdrawal channels and applied fixes to the affected contracts, and that Nova would stay offline until the risk was eliminated. As of October 29, 2026, zkLink hadn't published a final loss figure or a post-mortem, and no return of funds had been reported.

Why the Gap Was Easy to Miss

Transient storage is new enough that its habits haven't settled yet. Developers know it clears at the end of a transaction, and that guarantee makes it feel self-cleaning. Inside a transaction it behaves like ordinary storage. Anything written stays readable by every later call until the code overwrites it, which matters a great deal in a contract built to be driven by relayers that can batch several calls together.

The Arbitrator's handshake also hides its weakest assumption inside an external call. A review that follows the intended path sees the message service call back, receiveMessage write a fresh hash and claimMessage check it, and every step looks correct. The failure needs a path the design never pictured: two claims in one transaction, with the second one aimed at a target that never calls back. Audits are a necessary part of shipping a bridge, and their findings depend on which paths they're scoped to walk. This one only shows up when someone asks what claimMessage does when the callback doesn't happen.

How Olympix Finds a Hash That Outlives Its Claim

Olympix reads a contract as a system of states, transitions and entry points, and it takes in the surrounding context, including code comments and design docs, to infer the invariants the system depends on. In the Arbitrator, the states that matter are the two transient slots and the batch roots they let through. The transitions are claimMessage, receiveMessage and the forwarding calls to each gateway. The entry points include every relayer path into claimMessage.

From there, Olympix maps the paths through the contract and uses signals to narrow them, from all paths to the feasible ones, and then to the feasible paths that touch an invariant. Static analysis supplies the signal here. It surfaces a security-critical transient slot that's read with tload in claimMessage and never cleared with a matching tstore on any path, sitting right after an external call to a caller-supplied address. A check that depends on a value the function itself never refreshes is exactly the kind of signal that narrows the search to a handful of paths.

The invariant takes one sentence. Every forwarded message must be backed by a hash that receiveMessage wrote during that same claim. BugPoCer, Olympix's tool for Counterexample-Guided Invariant Refutation, starts from that rule and works outward from the system, searching for a call sequence that breaks it and refining each time a candidate fails.

The sequence it lands on is the October 28 attack. Make a legitimate claim through an authorized relayer so the slot holds a valid hash. In the same transaction, call claimMessage again with a message service target that never calls back, and pass message data that matches the old hash. The require passes, the Arbitrator forwards a message it never authenticated, and a batch root nobody proved reaches the bridge. BugPoCer returns that as a counterexample: the exact sequence of state changes, the minimal conditions that trigger it and a generated Forge test that replays it. Olympix has flagged this class before in other codebases, where a transient flag set for one operation leaked into the next. Because the analysis runs at every merge and before every release, the stale-slot path shows up the moment the code that creates it lands.

The fix the counterexample points to is small. Zero finalizeMessageHash right after the check, reset claiming before claimMessage returns, and the second claim in the attacker's transaction reverts.

A Clear Belongs Next to Every Check

zkLink's design put several strong layers between a forged root and its bridge: zero-knowledge proofs, canonical message services and gateway identity checks. The attacker went around all of them through one transient slot that kept its value longer than the handshake needed it. For any team using transient storage to carry authorization inside a transaction, the rule is simple: the code that consumes the value should erase it, and a check should never rely on an external call having refreshed it. To see how Olympix maps the authorization state in your own contracts and hands back a runnable counterexample for every path that leaves a slot warm, book a technical walkthrough.

On-Chain References

Exploit tx 1 (Oct 28, 22:21:35 UTC): 0x4f3c...462c

Exploit tx 2 (Oct 29, 01:43:11 UTC): 0x8a7e...8ae9

Vulnerable contract, Arbitrator proxy: 0x1ee0...6002

Arbitrator implementation: 0x7a0b...1e34

Victim, ZkLink contract: 0x5fd9...15cf

Victim, L1ERC20Bridge: 0xad16...b6b6

Attacker wallet 1: 0xaca0...5718

Attacker wallet 2: 0xdab1...f8c8

Attack contract 1: 0xd5f1...ff07

Attack contract 2: 0x61f4...f402

Sources

Bitcoin Sistemi: zkLink halts block production and withdrawals after attack

See what Olympix finds on your own code

Send us a repo that's already been audited, and we'll run Olympix at the same commit and show you what comes back.