Contexto
El protocolo zkLink zk-rollup multichain sufrió una pérdida de aproximadamente $783,000 el 29 de octubre de 2026, a través de un exploit que manipuló la verificación de mensajes cross-chain. El análisis forense de SlowMist reveló que el incidente se debió a fallos de diseño en el mecanismo de reclamación de mensajes del contrato Arbitrator, que permitió a un atacante falsificar batch roots y ejecutar retiros no autorizados.
Arquitectura del protocolo
En Ethereum, zkLink opera a través del contrato ZkLink en 0x5fd9f73286b7e8683bab45019c94553b93e015cf, que funciona como el hub de mensajería L1 y tesorería de ETH. El L1ERC20Bridge en 0xad16edcf7deb7e90096a259c81269d811544b6b6 mantiene los activos ERC-20.
La autorización de retiros depende de batch roots: cada retiro en L2 genera un log L2→L1, y los logs de un batch forman un árbol de Merkle. Una vez que la ejecución en L2 termina para un batch, su log root—almacenado como l2LogsRootHashes[batchNumber]—se sincroniza con el contrato ZkLink en L1. Los usuarios presentan mensajes de retiro junto con pruebas de Merkle durante el retiro en L1; ZkLink valida las pruebas contra el root almacenado antes de liberar fondos. Un root falsificado vuelve inútil esta verificación, ya que cualquier mensaje fabricado puede ir acompañado de una prueba válida. El contador totalBatchesExecuted rastrea el número más alto de batch ejecutado, requiriendo la verificación _batchNumber ≤ totalBatchesExecuted.
La mensajería cross-chain se enruta a través de gateways intermediarios en lugar de transmisión directa de cadena a cadena. EthereumGateway (0x83bc7394738a7a084081af22eec0051908c0055c) gestiona las operaciones del lado de Ethereum, mientras que LineaL1Gateway (0x803460416c2682ac54fccf03ef77b10a12f2809b) administra Linea. El contrato Arbitrator en 0x1ee09a2caa0813a5183f90f5a6d0e4871f4c6002 coordina estos gateways de forma centralizada.
La función claimMessage de Arbitrator procesa la reclamación y el reenvío de mensajes cuando es invocada por relayers de confianza. Utiliza el servicio de mensajería de la cadena de origen designado por el relayer para reclamar mensajes cross-chain, y luego los reenvía al gateway de destino. Para garantizar que el contenido reenviado coincide con el mensaje reclamado, Arbitrator escribe el hash del mensaje en almacenamiento transitorio a través de un callback receiveMessage durante la reclamación, que luego claimMessage verifica.
El almacenamiento transitorio, introducido en Solidity 0.8.24 mediante la EIP-1153, persiste a lo largo de toda la transacción, en lugar de llamadas individuales: los valores escritos en una llamada permanecen accesibles en las siguientes llamadas dentro de la misma transacción, y se limpian solo al finalizar la transacción.
El vector de ataque se originó en el contrato SyncL2TxHashRelayer (0x0fdc28e64772a70109d10fd068f859cfa7dea2c6). Su función claimPrimaryChainSyncL2TxHashMessage carece de restricciones de acceso y opera como un passthrough, reenviando los parámetros proporcionados por el llamador directamente a Arbitrator.claimMessage. Su invocación exitosa de claimMessage confirma su registro como relayer autorizado dentro de Arbitrator.
Mecánica de la vulnerabilidad
El exploit se centró en dos debilidades interconectadas en la función claimMessage del contrato Arbitrator, que gestiona tanto la reclamación como el reenvío de mensajes pero contenía suposiciones erróneas de confianza en ambas operaciones.
La primera debilidad: la función otorga a los relayers control total sobre los objetivos de ejecución externos. Tanto _sourceChainCanonicalMessageService como _sourceChainClaimCallData son parámetros proporcionados por el relayer, y Arbitrator ejecuta la llamada bajo su propia autoridad. El uso legítimo apunta a interfaces de reclamación de servicios de mensajería de la cadena de origen, pero un atacante puede redirigir esto a cualquier gateway registrado en Arbitrator, invocando efectivamente funciones de gateway protegidas solo por modificadores onlyArbitrator.
La segunda debilidad: la verificación de consistencia puede ser eludida debido al comportamiento del almacenamiento transitorio. La verificación lee finalizeMessageHash del almacenamiento transitorio, pero esta ranura nunca se limpia dentro de una transacción y persiste más allá de los límites de llamadas individuales. Una reclamación genuina realizada antes en la transacción escribe un valor en esta ranura, que una invocación posterior de claimMessage puede reutilizar para pasar la verificación—aunque la segunda llamada no reclame ningún mensaje. El atacante solo debe asegurar que los parámetros (gateway, valor, callData) sean idénticos en ambas llamadas. Por lo tanto, la verificación solo confirma que "una reclamación coincidente ocurrió en algún momento de esta transacción," no que "esta invocación reclamó este mensaje."
La falsificación de batch root se vuelve posible al redirigir la llamada externa a EthereumGateway, haciendo que Arbitrator invoque su función sendMessage. Esta función solo verifica que el llamador sea el contrato Arbitrator antes de reenviar el calldata directamente al contrato ZkLink. Como la llamada proviene de Arbitrator, la verificación es exitosa. El atacante llena _callData con syncBatchRoot(116409, forgedRoot, 0).
La función syncBatchRoot de ZkLink emplea un modificador onlyGateway que exige que el llamador sea un gateway configurado. Como EthereumGateway reenvía la llamada, este requisito se cumple. La función acepta cualquier número de batch y valor de root sin verificar pruebas de batch, sobrescribiendo directamente l2LogsRootHashes[_batchNumber] con el root proporcionado. Si _batchNumber excede totalBatchesExecuted, el contador avanza a _batchNumber. El atacante eligió 116409—el siguiente batch no ejecutado en ese momento—junto con un root de logs construido por él mismo, sobrescribiendo l2LogsRootHashes[116409] de cero al valor falsificado y aumentando totalBatchesExecuted de 116408 a 116409. Con el root falsificado, las pruebas de Merkle inevitablemente se validan, permitiendo la construcción arbitraria de mensajes de retiro y la extracción de fondos mediante pruebas fabricadas.
Secuencia de ejecución
Transacción de ataque: 0x4f3c23e75b9134e8cae5a462550daad42fbbc19242567fcc34c0a035e297462c
EOA del atacante: 0xaca0655a11200a80d33d0e48786317304cde5718
Contrato de ataque: 0xd5f162b68a0281686e8dc76db8d67d2f3e6aff07
- El contrato de ataque consultó totalBatchesExecuted() de ZkLink para obtener el último número de batch ejecutado, luego lo incrementó en uno para obtener 116409 como objetivo de falsificación. Las pruebas de retiro posteriores requieren números de batch que no excedan totalBatchesExecuted, por lo que es necesario registrar el batch falsificado como "ejecutado." La función syncBatchRoot acepta números de batch y roots arbitrarios mientras avanza totalBatchesExecuted al batch especificado, lo que significa que el control del atacante sobre la invocación del gateway determina qué batch cuenta como ejecutado.
- El atacante inició una reclamación genuina de mensaje cross-chain desde Linea llamando Arbitrator.claimMessage a través de SyncL2TxHashRelayer. La presentación incluyó prueba válida de un mensaje genuino de Linea, especificando la dirección del servicio de mensajería de Linea Rollup como cadena de origen y proporcionando evidencia claimMessageWithProof. Este mensaje Linea→Ethereum, originado por el atacante en Linea, designó a LineaL1Gateway como destinatario con contenido que le instruía reenviar una llamada syncL2TxHash(_l2TxHash,_primaryChainL2TxHash) al gateway de destino. Tras la validación de la prueba por parte de Linea Rollup, este invocó el callback de LineaL1Gateway, que llamó a la función receiveMessage de Arbitrator, escribiendo el hash de este mensaje en la ranura de almacenamiento transitorio finalizeMessageHash. La función claimMessage luego comparó contra este valor. Así, el atacante obtuvo el valor de verificación finalizeMessageHash mediante una operación genuina y benigna, dejándolo almacenado por la duración de la transacción. El registro de SyncL2TxHashRelayer como relayer de Arbitrator, combinado con la falta de controles de acceso de su función claimPrimaryChainSyncL2TxHashMessage, permite que cualquier dirección la use para llamar claimMessage de Arbitrator. Mientras tanto, claimMessage permite a los relayers especificar direcciones de servicios de mensajería de cadena de origen, y el valor de verificación escrito durante la reclamación legítima persiste en almacenamiento transitorio hasta completar la transacción.
- El contrato de ataque llamó inmediatamente al mismo relayer de nuevo, modificando solo dos parámetros: sustituyendo EthereumGateway (registrado en Arbitrator) por Linea Rollup como servicio de mensajería de la cadena de origen, y reemplazando _sourceChainClaimCallData con sendMessage(0, syncBatchRoot(116409, 0x1934a1...a8, 0), ""). Todos los demás parámetros—gateway de cadena de origen, monto recibido, calldata declarado de recepción y parámetros de reenvío—coincidieron byte a byte con la invocación anterior. Estos parámetros forman la base para la verificación finalizeMessageHash. Debido a que finalizeMessageHash reside en almacenamiento transitorio, nunca se limpia dentro de una transacción y es independiente de si la invocación actual realmente reclamó un mensaje. Ni la dirección objetivo de la llamada externa ni su calldata participan en la verificación, permitiendo la reutilización directa del valor dejado por la reclamación anterior. Además, Address.functionCall primero permite a Arbitrator ejecutar cualquier llamada especificada por el atacante bajo su propia identidad—la vulnerabilidad principal del exploit. Esta segunda llamada no reclamó ningún mensaje, pero pasó la verificación de claimMessage mientras la llamada externa del atacante a sendMessage de EthereumGateway ya se había ejecutado antes de la verificación.
- Cuando Arbitrator invocó la función sendMessage de EthereumGateway, el calldata contenía exactamente la llamada syncBatchRoot(116409, forgedRoot, 0) proporcionada por el atacante. En consecuencia, ZkLink registró el root falsificado como log root de este batch. La evidencia on-chain muestra que ZkLink emitió el evento SyncBatchRoot(116409, 0x1934a1c3…), l2LogsRootHashes[116409] cambió de cero al root construido por el atacante, y totalBatchesExecuted incrementó de 116408 a 116409. El root falsificado ahora estaba registrado "legítimamente" en el libro mayor del protocolo. Ambas llamadas también reenviaron una llamada syncL2TxHash, estableciendo l2TxHashMap[0x2575…] a 0xe9ec…—un efecto secundario inofensivo de la reutilización del valor de verificación. Debido a que EthereumGateway.sendMessage solo verifica que el llamador sea Arbitrator antes de reenviar el calldata a ZkLink, y ZkLink.syncBatchRoot solo verifica que el llamador sea un gateway sin comprobar pruebas de batch, ambas capas de "verificación de llamador sin validación de contenido" pasaron, permitiendo que el root falsificado fuera registrado como legítimo.
- Tras la sobrescritura del root, cualquier mensaje de retiro que produjera una prueba válida contra este root pasaría la verificación. El contrato de ataque ejecutó 12 llamadas consecutivas de retiro final, todas dirigidas al batch 116409: un retiro de ETH seguido de 11 retiros de ERC-20. Cada mensaje incluyó cuatro nodos de Merkle construidos por el atacante basados en el root falsificado, garantizando la validación de la prueba. El contrato ZkLink procesó directamente el retiro de ETH, mientras que L1ERC20Bridge manejó los 11 retiros de ERC-20. Tras la validación de la prueba, los tokens se transfirieron directamente del contrato bridge al EOA del atacante. Ambos contratos registraron los mensajes como retirados, pero este marcador solo previene envíos duplicados y no puede verificar si los mensajes realmente ocurrieron en L2. Los contratos de retiro solo verifican la "inclusión del mensaje en el árbol correspondiente al batch root almacenado," no si el root en sí provino de una ejecución real en L2. Una vez falsificado el root, cualquier mensaje puede acompañarse de una prueba válida.
Requisitos de remediación
Este incidente combinó la capacidad de llamadas externas arbitrarias en el contrato Arbitrator con la reutilización de valores de almacenamiento transitorio no limpiados para la verificación. Arbitrator.claimMessage permite a los relayers designar objetivos y calldata arbitrarios, ejecutando llamadas externas bajo su propia autoridad. Su única comprobación de finalidad compara contra un valor residual de almacenamiento transitorio sin limpiarlo ni confirmar que la invocación actual completó una reclamación. El atacante utilizó una reclamación legítima para escribir el valor de verificación, luego lo reutilizó en una segunda llamada claimMessage, causando que Arbitrator invocara la interfaz onlyArbitrator del gateway en nombre del atacante, finalmente escribiendo el batch root falsificado en ZkLink y haciendo los activos mantenidos por el bridge "probablemente" retirables.
La remediación debe abordar tres capas. Primero, claimMessage debe limpiar finalizeMessageHash antes de invocar Address.functionCall, luego exigir que sea distinto de cero y coincida con la declaración de la llamada actual, o emplear un nonce incremental por llamada para vincular el valor, asegurando que solo pueda ser escrito por receiveMessage durante la invocación actual. Segundo, implementar una lista blanca para _sourceChainCanonicalMessageService para evitar que gateways registrados en Arbitrator sirvan como "servicios de mensajería de cadena de origen." Los contratos de ayuda para desarrollo no deben estar registrados como relayers, o deben implementar controles de acceso que impidan que direcciones arbitrarias obtengan indirectamente la capacidad de invocar claimMessage. Tercero, ZkLink.syncBatchRoot debe rechazar intentos de sobrescribir batch roots existentes, y se debe añadir una capa de prueba independiente a nivel de gateway o mensajería para asegurar que las actualizaciones de batch root no puedan tener éxito únicamente porque "el llamador es un gateway."
Populares
- Noticias El FBI vincula el robo a Binance con Corea del Norte
- Noticias Chainalysis revela acceso a datos de clientes
- Noticias Binance dice haber reservado US$6.340 millones tras el hackeo
- Investigaciones Chainalysis utiliza IA para rastrear $387M robados de Bitget
- Noticias Binance estima un incidente de seguridad de US$6.300 millones
- Investigaciones El hacker del puente Cysic envió $744K a Tornado Cash tras recibir una recompensa del 10%
- Investigaciones Hackers del exploit de Drift movieron 109 millones de dólares en ETH al mezclador Tornado Cash
- Noticias Incidentes de criptomonedas costaron $1,18 mil millones en el tercer trimestre de 2026, principalmente por fallos de proveedores
- Investigaciones Robo de monederos de hardware de CryptoBilis estimado entre $93M y $94,5M
- Investigaciones Hombre de Virginia entrega $101M en bitcoin tras condena de 40 años
