zkLink因伪造批次根漏洞被黑客攻击

攻击者利用zkLink Arbitrator合约的设计缺陷,提取了78.3万美元

zkLink因伪造批次根漏洞被黑客攻击

背景

2026年10月29日,zkLink多链zk-rollup协议因跨链消息验证机制被操纵,遭受了约78.3万美元的损失。慢雾科技的链上取证分析显示,事件源于Arbitrator合约在消息声明机制上的设计缺陷,使攻击者能够伪造批次根并执行未授权提取。

协议架构

在以太坊上,zkLink通过0x5fd9f73286b7e8683bab45019c94553b93e015cf地址的ZkLink合约运行,作为L1消息中枢和ETH金库。L1ERC20Bridge(0xad16edcf7deb7e90096a259c81269d811544b6b6)则持有ERC-20资产。

提取授权依赖于批次根:每一次L2提取都会生成一条L2→L1日志,同一批次的日志构成一个Merkle树。L2完成该批次执行后,其日志根(存储为l2LogsRootHashes[batchNumber])将同步至L1的ZkLink合约。用户在L1提取时需提交提取消息及Merkle证明,ZkLink会根据存储的根验证证明后释放资金。若根被伪造,则验证将失效,任何伪造的消息都可配合匹配的证明通过验证。totalBatchesExecuted计数器记录执行过的最大批次号,验证要求_batchNumber ≤ totalBatchesExecuted。

跨链消息通过网关中间件路由,而非直接链对链传输。EthereumGateway(0x83bc7394738a7a084081af22eec0051908c0055c)负责以太坊侧操作,LineaL1Gateway(0x803460416c2682ac54fccf03ef77b10a12f2809b)负责Linea侧。Arbitrator合约(0x1ee09a2caa0813a5183f90f5a6d0e4871f4c6002)集中协调这些网关。

Arbitrator的claimMessage函数在受信任的中继者调用时处理消息声明和转发。它通过中继者指定的源链消息服务来声明跨链消息,然后转发到目标网关。为确保转发内容与声明消息一致,Arbitrator在声明时通过receiveMessage回调将消息哈希写入临时存储,claimMessage随后进行验证。

临时存储是Solidity 0.8.24通过EIP-1153引入的新功能,其值在整个交易期间持续有效,而非单独函数调用:在一个调用中写入的值,后续调用仍可访问,直到交易结束才会清除。

攻击向量起始于SyncL2TxHashRelayer合约(0x0fdc28e64772a70109d10fd068f859cfa7dea2c6)。其claimPrimaryChainSyncL2TxHashMessage函数没有访问限制,且作为透传,将调用者提供的参数直接转发给Arbitrator.claimMessage。其成功调用claimMessage,表明其已在Arbitrator中注册为受信任的中继者。

漏洞机制

此次攻击围绕Arbitrator合约claimMessage函数的两个相互关联的弱点展开,该函数同时处理消息声明和转发,但在两项操作中都存在信任假设缺陷。

第一个弱点:该函数赋予中继者对外部执行目标的完全控制权。_sourceChainCanonicalMessageService和_sourceChainClaimCallData都是中继者提供的参数,Arbitrator以自身权限执行调用。正常情况下应调用源链消息服务的声明接口,但攻击者可将其指向Arbitrator中注册的任意网关,从而调用仅由onlyArbitrator修饰符保护的网关函数。

第二个弱点:由于临时存储的特性,一致性校验可被绕过。校验过程读取finalizeMessageHash自临时存储,但该槽在同一交易中不会被清除,且在单个函数调用间持续。若同一交易中先有一次真实声明写入此槽,后续的claimMessage调用即便未声明消息,也可复用该值通过校验——只需保证两次调用的(gateway, value, callData)参数完全一致。因此,校验只确认“本次交易内曾有一次匹配的声明”,而非“本次调用声明的就是该消息”。

通过将外部调用重定向至EthereumGateway,批次根伪造成为可能,致使Arbitrator调用其sendMessage函数。该函数仅验证调用者为Arbitrator后,将calldata直接转发至ZkLink合约。由于调用来自Arbitrator,该检查顺利通过。攻击者将_callData填为syncBatchRoot(116409, forgedRoot, 0)。

ZkLink的syncBatchRoot函数带有onlyGateway修饰符,要求调用者为配置过的网关。因EthereumGateway转发调用,这一要求被满足。该函数未验证批次证明,接受任意批次号和根值,直接覆盖l2LogsRootHashes[_batchNumber]为提供的根。若_batchNumber大于totalBatchesExecuted,计数器将提升至_batchNumber。攻击者选择了当时的下一个未执行批次116409,并自造日志根,将l2LogsRootHashes[116409]从零覆盖为伪造值,同时totalBatchesExecuted从116408自增至116409。根被伪造后,Merkle证明必然通过,可随意构造提取消息并通过伪造证明提取资金。

攻击流程

攻击交易: 0x4f3c23e75b9134e8cae5a462550daad42fbbc19242567fcc34c0a035e297462c
攻击者EOA: 0xaca0655a11200a80d33d0e48786317304cde5718
攻击合约: 0xd5f162b68a0281686e8dc76db8d67d2f3e6aff07

  • 攻击合约查询了ZkLink的totalBatchesExecuted()以获取最新执行批次号,并加一得到116409作为伪造目标。后续提取证明要求批次号不大于totalBatchesExecuted,因此必须将伪造批次注册为“已执行”。syncBatchRoot函数接受任意批次号和根,并将totalBatchesExecuted提升至指定批次,因此攻击者可通过操控网关调用决定何为“已执行”批次。
  • 攻击者首先通过SyncL2TxHashRelayer调用Arbitrator.claimMessage,从Linea发起了一次真实的跨链消息声明。提交内容含有合法的Linea消息证明,指定Linea Rollup为源链消息服务地址,并提供claimMessageWithProof证据。该Linea→Ethereum消息由攻击者在Linea上发起,指定LineaL1Gateway为接收者,内容指令其向目标网关转发syncL2TxHash(_l2TxHash,_primaryChainL2TxHash)调用。在Linea Rollup验证证明后,调用LineaL1Gateway的回调,进而调用Arbitrator的receiveMessage函数,将该消息哈希写入finalizeMessageHash临时存储槽。claimMessage函数随后与此值比对。攻击者由此通过一次真实声明获得finalizeMessageHash校验值,该值在交易期间持续有效。SyncL2TxHashRelayer作为Arbitrator注册中继者,且其claimPrimaryChainSyncL2TxHashMessage函数无访问控制,任何地址都可用其调用Arbitrator.claimMessage。与此同时,claimMessage允许中继者自行指定源链消息服务地址,且合法声明写入的校验值会在交易未结束前保留在临时存储中。
  • 攻击合约随后立即再次调用同一中继者,仅修改两个参数:将源链消息服务由Linea Rollup替换为(Arbitrator中已注册的)EthereumGateway,并将_sourceChainClaimCallData替换为sendMessage(0, syncBatchRoot(116409, 0x1934a1...a8, 0), "")。其余参数——源链网关、接收金额、声明接收calldata及转发参数——与前次调用字节级一致。这些参数共同构成finalizeMessageHash校验基础。由于finalizeMessageHash位于临时存储,在一笔交易中不会被清空,且与当前调用是否声明消息无关。外部调用目标地址及其calldata不参与校验,可直接复用前次声明遗留的值。此外,Address.functionCall允许Arbitrator以自身身份执行攻击者指定的任意调用——这是漏洞的核心。该第二次调用并未声明消息,却通过了claimMessage校验,而攻击者对EthereumGateway.sendMessage的外部调用已在校验前被执行。
  • 当Arbitrator调用EthereumGateway的sendMessage函数时,calldata正是攻击者指定的syncBatchRoot(116409, forgedRoot, 0)调用。ZkLink因此将伪造根记录为该批次的日志根。链上证据显示,ZkLink发出了SyncBatchRoot(116409, 0x1934a1c3…)事件,l2LogsRootHashes[116409]从0被更改为攻击者构造的根,totalBatchesExecuted由116408提升至116409。此时伪造根已被协议账本“合法”记录。两次调用还转发了syncL2TxHash调用,将l2TxHashMap[0x2575…]设为0xe9ec…——这只是校验值复用带来的无害副作用。由于EthereumGateway.sendMessage仅验证调用者为Arbitrator即转发calldata至ZkLink,而ZkLink.syncBatchRoot也只验证调用者为网关,不校验批次证明,形成了“验证调用者但不验证内容”的双重漏洞,允许伪造根被记录为合法。
  • 根被覆盖后,任何能对该根生成有效证明的提取消息都可通过验证。攻击合约随后连续执行了12笔最终提取调用,全部针对批次116409:包括1笔ETH和11笔ERC-20提取。每条消息都包含基于伪造根的4个攻击者自造Merkle节点,保证证明通过。ZkLink合约直接处理ETH提取,L1ERC20Bridge处理11笔ERC-20提取。证明通过后,资产直接从桥合约转账至攻击者EOA。两合约均将消息记录为已提取,但这一标记仅防重复提交,无法验证消息是否真在L2发生。提取合约只验证“消息包含于所存批次根对应的树”,而不会校验根本身是否真实源自L2执行。一旦根被伪造,任何消息都可配以有效证明。

修复建议

本事件结合了Arbitrator合约的任意外部调用能力,以及校验时复用未清除的临时存储值。Arbitrator.claimMessage允许中继者指定任意目标及calldata,并以自身权限执行任意外部调用。其唯一的最终性校验,只是比对一个残留的临时存储值,既不清除,也不确认当前调用完成了声明。攻击者先用一次真实声明写入校验值,再在第二次claimMessage调用中复用该值,使Arbitrator以攻击者身份调用网关的onlyArbitrator接口,最终将伪造的批次根写入ZkLink,使桥资金“可被证明”地提取。

修复需涵盖三层。首先,claimMessage应在调用Address.functionCall前清除finalizeMessageHash,并要求其为非零且与本次调用声明一致,或通过每次调用递增的nonce将校验值绑定,确保校验值只能由当前调用的receiveMessage写入。其次,_sourceChainCanonicalMessageService应实施白名单,禁止Arbitrator中注册的网关作为“源链消息服务”。开发辅助合约不应注册为中继者,或必须加入访问控制,防止任意地址间接获得claimMessage调用权限。第三,ZkLink.syncBatchRoot应拒绝覆盖已存在的批次根,且应在网关或消息层添加独立的证明层,确保批次根更新不能仅因“调用者是网关”就成功。

热门文章

提交申请

留下申请

如何联系您?

请根据所选的联系方式,填写您的 Telegram 用户名或电子邮箱。