免责声明:金色财经所有资讯仅代表作者个人观点,不构成任何投资理财建议。请确保访问网址为(jinse.com.cn) 举报

    一鱼多吃 —— zkLink 被盗 78 万美元事件解析

    背景

    2026 年 10 月 29 日,知名跨链协议 zkLink 遭到攻击,损失约 78.3 万美元。以下是慢雾安全团队针对本次攻击事件的具体分析:

    前置知识

    zkLink 是一个多链 zk 汇总(rollup) 协议。在以太坊上,ZkLink 合约(0x5fd9f73286b7e8683bab45019c94553b93e015cf) 承担 L1 消息服务的角色,同时持有 ETH 金库;与之配套的 L1ERC20Bridge (0xad16edcf7deb7e90096a259c81269d811544b6b6) 持有 ERC-20 金库。

    提现的信用来源是"批次根"。L2 上每一笔提现都会生成一条 L2→L1 日志,同一个批次的日志组成一棵 Merkle 树。批次在 L2 执行后,其日志根 l2LogsRootHashes[batchNumber] 会被同步到 L1 的 ZkLink 合约。用户在 L1 提现时提交提现消息和 Merkle 证明,ZkLink 用存储中的根计算证明,通过后才放款。换句话说,根是真的,消息才可能是真的;根若是伪造的,任意消息都能"证明"为已发生。ZkLink 还用 totalBatchesExecuted 记录已经执行到的批次号,验证时会要求 _batchNumber 。

    跨链消息并不由各链直接收发,而是通过网关合约转发。以太坊一侧是 EthereumGateway (0x83bc7394738a7a084081af22eec0051908c0055c),Linea 一侧是 LineaL1Gateway (0x803460416c2682ac54fccf03ef77b10a12f2809b)。这些网关由 Arbitrator 合约 (0x1ee09a2caa0813a5183f90f5a6d0e4871f4c6002) 统一协调。

    Arbitrator 合约的职责是认领并转发。受信任的 relayer 调用 claimMessage 函数,Arbitrator 合约会先用 relayer 指定的源链消息服务去认领一条跨链消息,再把消息转发给目标链的网关。为了让"转发内容"与"真正认领到的消息"一致,Arbitrator 在认领过程中会通过 receiveMessage 函数回调把消息哈希写入 transient storage,claimMessage 函数随后会检查比对这一声明。

    transient storage(瞬态存储 EIP-1153)是 Solidity 0.8.24 引入的特性,作用域是整笔交易,而不是单次函数调用:同一交易内后续发起的调用,可以读到前面写入的值,直到交易结束才清除。

    本次攻击的入口是 SyncL2TxHashRelayer 合约 (0x0fdc28e64772a70109d10fd068f859cfa7dea2c6),其函数 claimPrimaryChainSyncL2TxHashMessage 没有任何访问控制,本质上只是一个把调用者参数原样转交给 Arbitrator.claimMessage 的辅助合约。它能成功调用 claimMessage 函数,说明它本身在 Arbitrator 合约中登记为 relayer。

    根本原因

    本次攻击的核心漏洞位于 Arbitrator 合约的 claimMessage 函数。该函数同时负责"认领"和"转发",但在两处信任假设上都出现了偏差。

    function claimMessage( address _sourceChainCanonicalMessageService, bytes calldata _sourceChainClaimCallData, IL1Gateway _sourceChainL1Gateway, uint256 _receiveValue, bytes calldata _receiveCallData, bytes calldata _forwardParams) external payable nonReentrant onlyRelayer { assembly { tstore(claiming.slot, 1) } // 利用点一:Arbitrator 以自身身份,对 relayer 指定的任意地址执行任意 calldata Address.functionCall(_sourceChainCanonicalMessageService, _sourceChainClaimCallData); bytes32 _finalizeMessageHash; assembly { _finalizeMessageHash := tload(finalizeMessageHash.slot) } // 利用点二:比对的是 transient storage 里的旧值,不要求本次调用真的发生过 receiveMessage require( _finalizeMessageHash == keccak256(abi.encode(_sourceChainL1Gateway, _receiveValue, _receiveCallData)), "Incorrect finalize data" ); // ... 后续按分支把消息转发给目标网关}

    第一个利用点是"外部执行内容"完全由 relayer 决定。_sourceChainCanonicalMessageService 和 _sourceChainClaimCallData 都是 relayer 传入的参数,Arbitrator 合约会以自身身份把它们进行调用执行。正常使用时,这里是一个源链消息服务的认领接口;但当攻击者把目标换成 Arbitrator 合约中已注册的网关时,实际执行就可以调用网关中带 onlyArbitrator 修饰器检查的函数。

    第二个利用点是"一致性校验"由于瞬态存储的特性而被绕过。校验读取的是 transient storage 中的 finalizeMessageHash,而该槽位在同一交易内从不清零,也不会因为一次调用结束而失效。只要同一交易里先发生一次真实认领、把该槽位写好,后面的第二次 claimMessage 就能复用它通过校验,哪怕第二次调用根本没有认领任何消息。攻击者只需要让两次调用的 (gateway, value, callData) 声明完全一致即可。也就是说,校验通过证明的是"本交易内曾经发生过一次与这些声明匹配的认领",而不是"本次调用认领了这条消息"。

    那为什么能伪造提款用的批次根?因为 Arbitrator 合约通过外部调用把目标指向 EthereumGateway 后,调用了 sendMessage 函数:

    function sendMessage(uint256 _value, bytes calldata _callData, bytes calldata) external payable onlyArbitrator { require(msg.value == _value, "Invalid value"); (bool success, ) = ZKLINK.call{value: _value}(_callData); // 把 calldata 原样转发给 ZkLink require(success, "Call zkLink failed");}

    这个函数只检查调用者是否为 Arbitrator 合约,然后把 calldata 原样转给 ZkLink 合约。由于发起调用的确实是 Arbitrator 合约,检查自然通过。攻击者把 _callData 填成 syncBatchRoot(116409, 伪造根, 0):

    function syncBatchRoot(uint256 _batchNumber, bytes32 _l2LogsRootHash, uint256 _forwardEthAmount) external payable onlyGateway { // 只检查调用者是网关,不验证任何批次证明 require(msg.value == _forwardEthAmount, "Invalid forward amount"); if (_batchNumber > totalBatchesExecuted) { totalBatchesExecuted = _batchNumber; } l2LogsRootHashes[_batchNumber] = _l2LogsRootHash; // 直接覆盖该批次的日志根 emit SyncBatchRoot(_batchNumber, _l2LogsRootHash, _forwardEthAmount);}

    ZkLink 合约的 onlyGateway 函数要求调用者是它自己配置的网关,而转发这次调用的正是 EthereumGateway 合约,检查可以通过。于是攻击者用当时尚未执行的下一个批次号 116409,配上一个自己构造的日志根,把 l2LogsRootHashes[116409] 从零改写成了伪造值,并顺带把 totalBatchesExecuted 从 116408 推到了 116409。根被伪造之后,Merkle 证明必然通过,提现消息的内容因此可以被任意构造。至此,就能用伪造的证明进行提款。

    攻击步骤分析

    攻击交易:0x4f3c23e75b9134e8cae5a462550daad42fbbc19242567fcc34c0a035e297462c

    攻击者 EOA:0xaca0655a11200a80d33d0e48786317304cde5718

    攻击合约:0xd5f162b68a0281686e8dc76db8d67d2f3e6aff07

    1. 攻击合约首先调用 ZkLink 合约的 totalBatchesExecuted()函数来查询当时已经执行到的最后一个批次,再把这个值加一,就能作为本次要伪造的批次号 116409。这是因为后续的提现证明要求批次号不大于 totalBatchesExecuted

    ,所以伪造出来的批次必须先被登记为"已执行"。

    而 syncBatchRoot 函数接受任意批次号与任意根,又会顺手把 totalBatchesExecuted 推进到该批次。只要能让网关发起这次调用,哪个批次算"已执行"就由攻击者决定。

    2. 攻击者先执行一次真实的 Linea 跨链认领,通过 SyncL2TxHashRelayer 合约调用 Arbitrator.claimMessage,提交了一条真实的 Linea 消息证明,其中"源链消息服务地址"指定为 Linea Rollup,附上一条 claimMessageWithProof 的证明。这条消息是攻击者自己在 Linea 上发出的 Linea→Ethereum 跨链消息,接收方是 LineaL1Gateway,内容是让它把一条 syncL2TxHash(_l2TxHash,_primaryChainL2TxHash) 转发给目标网关。Linea Rollup 验证证明通过后会回调 LineaL1Gateway,后

    者再回调 Arbitrator 合约的 receiveMessage 函数,把这条消息的哈希写进 transient storage 的 finalizeMessageHash。claimMessage 函数随后正是拿这个值做比对,也就是说,攻击者用一次真实、无害的认领流程,把校验值 finalizeMessageHash 拿到了手,并让它一直留在这笔交易里。

    SyncL2TxHashRelayer 合约在 Arbitrator 合约中被登记为 relayer,且 claimPrimaryChainSyncL2TxHashMessage 函数没有任何访问控制,任何地址都能借它的身份调用 Arbitrator 合约的 claimMessage 函数;而 claimMessage 函数又允许 relayer 自行指定源链消息服务地址。此外,这次真实认领写入的校验值会一直留在这笔交易的 transient storage 中,直到交易结束。

    3. 紧接着,攻击合约再次调用同一个 relayer,只改了两处参数:源链消息服务从 Linea Rollup 换成 Arbitrator 合约自己注册的 EthereumGateway,_sourceChainClaimCallData 换成 sendMessage(0, syncBatchRoot(116409, 0x1934a1...a8, 0), "")。

    其余参数——源链网关、接收金额、声明的接收 calldata、转发参数——与上一步逐字节相同,而这些参数反而正是用来和 finalizeMessageHash 进行检查的值,因为finalizeMessageHash 的值是保存在瞬态存储中的,在同一交易内从不清零,也没有绑定"本次调用是否真的认领过消息",同时外部调用的目标地址和 calldata 并不会参与检查,上一次认领留下的值因而可以被第二次调用直接复用;而 Address.functionCall 又先让 Arbitrator 合约以自身身份执行了攻击者指定的任意调用。这是本次攻击的核心缺陷。

    也就是说,这次调用根本没有认领任何消息,但 claimMessage 函数的检查依然通过了,并且攻击者外部调用 EthereumGateway 合约的 sendMessage 函数已经在检查之前执行完毕。

    4. 当 Arbitrator 调用 EthereumGateway 合约的 sendMessage 函数时,由于 calldata 正是攻击者填入的 syncBatchRoot(116409, 伪造根, 0),于是 ZkLink 把伪造根当作本批次的日志根记录了下来。

    从链上结果可以看到:ZkLink 发出 SyncBatchRoot(116409, 0x1934a1c3…) 事件,存储里的 l2LogsRootHashes[116409] 从零变成攻击者构造的根,totalBatchesExecuted 也从 116408 变成 116409。到此,伪造的根已经"合法"地进入了协议账本。两次调用都会转发一次 syncL2TxHash,把 l2TxHashMap[0x2575…] 写成 0xe9ec…,这一步本身无害,只是复用校验值带来的副产物。

    因为 EthereumGateway.sendMessage 只校验调用者是否是 Arbitrator 合约,就把 calldata 原样转给 ZkLink;ZkLink.syncBatchRoot 又只校验调用者是否是网关合约,不校验任何批次证明。两层"只认调用者、不认内容"的检查都通过,让伪造的根被当成合法根写入。

    5. 根被改写之后,只要提现消息能对着这个根算出证明,就能通过验证。攻击合约连续发起 12 次最终提款调用,全部指向批次 116409:先是一条 ETH 提现,随后是 11 条 ERC-20 提现。每条消息都带 4 个 Merkle 节点,这些节点是攻击者按自己伪造的根构造的,所以证明必然成立通过。

    ETH 提现由 ZkLink 合约直接处理,11 条 ERC-20 提现由 L1ERC20Bridge 处理。证明通过后,代币直接从桥合约转给攻击者 EOA。

    两个合约虽然都记录了"这条消息已提现",但这个标记只能防止重复提交,无法识别消息本身从未在 L2 发生过。

    提现合约只验证"这条消息是否包含在存储的批次根对应的树中",不验证根本身是否来自真实的 L2 执行。根一旦被伪造,任意消息都能算出合法的证明。

    总结

    本次攻击的核心问题在于 Arbitrator 合约的任意外部调用加上未清理的瞬态存储可被直接复用验证。Arbitrator.claimMessage 一方面允许 relayer 指定任意目标与 calldata,并以自身身份进行任意外部调用;另一方面,它唯一的最终性校验只跟 transient storage 中保存的残留值比较,既不清零,也不检查本次调用是否真的完成了认领。攻击者先用一次真实认领写下校验值,再用第二次 claim 复用该值,让 Arbitrator 替自己调用了网关的 onlyArbitrator 接口,最终把伪造的批次根写入 ZkLink,桥内资产随之被"证明"为可提取。

    修复应从三个层次入手。第一,claimMessage 必须在 Address.functionCall 之前把 finalizeMessageHash 清零,调用后要求它非零且等于本次声明,或使用每次调用递增的 nonce 加以绑定,确保校验值只能由本次调用中的 receiveMessage 写入。第二,对 _sourceChainCanonicalMessageService 增加白名单,禁止把 Arbitrator 合约自身注册的网关当作"源链消息服务";同时不要将 dev 辅助合约登记为 relayer,或至少为其补上访问控制,避免任意地址间接获得调用 claimMessage 的能力。第三,ZkLink.syncBatchRoot 应拒绝覆盖已经存在的批次根,并在网关侧或消息层增加独立证明,使批次根的更新不能仅凭"调用者是网关"生效。

    慢雾安全团队建议在审查同类跨链中继项目的代码时,建议重点检查四点:transient storage 是否在每次调用前重置、校验值是否与实际执行的参数来自同一来源、"受信任调用者"是否可能被任意用户间接借用(例如通过公开的 helper 或 relayer)以及更新批次根时是否做了独立校验。这类问题往往不在最显眼的资产转移函数里,而在中间那层"看起来只是转发"的合约中。

    jinse.com.cn 0
    好文章,需要你的鼓励
    jinse.com.cn 0
    好文章,需要你的鼓励
    参与评论
    0/140
    提交评论
    文章作者: / 责任编辑:

    声明:本文由入驻金色财经的作者撰写,观点仅代表作者本人,绝不代表金色财经赞同其观点或证实其描述。

    提示:投资有风险,入市须谨慎。本资讯不作为投资理财建议。

    金色财经 > 慢雾科技 > 一鱼多吃 —— zkLink 被盗 78 万美元事件解析
    • 寻求报道
    • 金色财经中国版App下载
      金色财经APP
      iOS & Android
    • 加入社群
      Telegram
    • 意见反馈
    • 返回顶部
    • 返回底部