Контекст
Мультисетевой протокол zk-rollup zkLink понёс убытки примерно на $783 000 29 октября 2026 года в результате эксплойта, основанного на манипуляции верификацией межсетевых сообщений. Ончейн-расследование SlowMist установило, что инцидент возник из-за изъянов в механизме получения сообщений контракта Arbitrator, который позволил злоумышленнику подделывать корни батчей и осуществлять несанкционированные выводы.
Архитектура протокола
В сети Ethereum zkLink функционирует через контракт ZkLink по адресу 0x5fd9f73286b7e8683bab45019c94553b93e015cf, выступающий в роли L1-хаба сообщений и казначейства ETH. Контракт L1ERC20Bridge по адресу 0xad16edcf7deb7e90096a259c81269d811544b6b6 хранит активы ERC-20.
Авторизация на вывод средств базируется на корнях батчей: каждый вывод с L2 создает лог L2→L1, а логи одного батча формируют дерево Меркла. После завершения исполнения L2 для батча, его корень логов — l2LogsRootHashes[batchNumber] — синхронизируется с L1-контрактом ZkLink. Для вывода на L1 пользователи отправляют сообщения на вывод с доказательством Меркла; ZkLink проверяет доказательство относительно сохранённого корня перед отправкой средств. В случае подделки корня эта проверка теряет смысл, поскольку любое фейковое сообщение может сопровождаться подходящим доказательством. Счетчик totalBatchesExecuted отслеживает наибольший номер исполненного батча, и для верификации требуется _batchNumber ≤ totalBatchesExecuted.
Маршрутизация межсетевых сообщений происходит через промежуточные гейтвеи, а не напрямую между сетями. EthereumGateway (0x83bc7394738a7a084081af22eec0051908c0055c) отвечает за операции на стороне Ethereum, а LineaL1Gateway (0x803460416c2682ac54fccf03ef77b10a12f2809b) управляет Linea. Контракт Arbitrator по адресу 0x1ee09a2caa0813a5183f90f5a6d0e4871f4c6002 централизованно координирует работу этих гейтвеев.
Функция claimMessage в Arbitrator обрабатывает получение и форвардинг сообщений при вызове доверенными реллеерами. Для этого она использует сервис межсетевых сообщений исходной сети, указанный реллеером, для получения сообщений, после чего пересылает их на целевой гейтвей. Чтобы убедиться, что содержимое форвардится корректно, Arbitrator записывает хеш сообщения во временное хранилище (transient storage) через callback receiveMessage в процессе получения, и затем claimMessage сверяет его.
Временное хранилище (transient storage), введённое в Solidity 0.8.24 с EIP-1153, сохраняется в течение всей транзакции, а не отдельного вызова функции: значения, записанные в одном вызове, доступны в последующих вызовах в рамках одной транзакции и очищаются только после её завершения.
Вектор атаки начинался с контракта SyncL2TxHashRelayer (0x0fdc28e64772a70109d10fd068f859cfa7dea2c6). Его функция claimPrimaryChainSyncL2TxHashMessage не имеет ограничений доступа и просто передаёт параметры, предоставленные вызывающим, напрямую в Arbitrator.claimMessage. Успешный вызов claimMessage подтверждает регистрацию этого контракта в качестве доверенного реллеера в Arbitrator.
Механика уязвимости
Эксплойт был основан на двух связанных слабостях функции claimMessage контракта Arbitrator, отвечающей за получение и форвардинг сообщений, но содержащей ошибочные доверительные предпосылки в обеих операциях.
Первая слабость: функция предоставляет реллеерам полный контроль над целями внешнего исполнения. Параметры _sourceChainCanonicalMessageService и _sourceChainClaimCallData задаются реллеером, а Arbitrator выполняет вызов от своего имени. В легитимном кейсе это интерфейсы получения сообщений исходной сети, но злоумышленник может перенаправить вызов на любой гейтвей, зарегистрированный в Arbitrator, фактически вызывая функции гейтвея, защищённые только модификатором onlyArbitrator.
Вторая слабость: проверка консистентности легко обходится из-за особенностей transient storage. Для проверки читается значение finalizeMessageHash из временного хранилища, но этот слот не очищается в рамках транзакции и сохраняется между вызовами функций. Если легитимное получение сообщения произошло ранее в транзакции и записало значение в этот слот, последующий вызов claimMessage может повторно использовать это значение для прохождения проверки — даже если второй вызов не получает никакого сообщения. Достаточно, чтобы параметры (gateway, value, callData) совпадали в обоих вызовах. Таким образом, проверка подтверждает лишь то, что «подходящее получение сообщения произошло где-то в этой транзакции», а не то, что «данный вызов получил это сообщение».
Подделка batch root становится возможной благодаря перенаправлению внешнего вызова на EthereumGateway, что приводит к вызову Arbitrator функции sendMessage этого гейтвея. Эта функция проверяет только, что вызывающий — контракт Arbitrator, после чего пересылает calldata напрямую в контракт ZkLink. Так как вызов идёт от Arbitrator, проверка проходит. Злоумышленник формирует _callData с syncBatchRoot(116409, forgedRoot, 0).
Функция syncBatchRoot в ZkLink использует модификатор onlyGateway, требующий, чтобы вызывающий был настроенным гейтвеем. Поскольку EthereumGateway делает форвардинг, требование выполняется. Функция принимает любые значения номера батча и корня без проверки доказательств, напрямую перезаписывая l2LogsRootHashes[_batchNumber] на переданный корень. Если _batchNumber больше totalBatchesExecuted, счетчик увеличивается до _batchNumber. Злоумышленник выбрал 116409 — следующий неисполненный батч — и сам сконструировал корень логов, заменив l2LogsRootHashes[116409] с нуля на поддельное значение, а totalBatchesExecuted увеличив с 116408 до 116409. После подделки корня доказательства Меркла проходят проверку, позволяя создавать любые сообщения на вывод и извлекать средства по сфабрикованным доказательствам.
Последовательность атаки
Атакующая транзакция: 0x4f3c23e75b9134e8cae5a462550daad42fbbc19242567fcc34c0a035e297462c
EOA злоумышленника: 0xaca0655a11200a80d33d0e48786317304cde5718
Атакующий контракт: 0xd5f162b68a0281686e8dc76db8d67d2f3e6aff07
- Атакующий контракт вызвал totalBatchesExecuted() в ZkLink, чтобы получить номер последнего исполненного батча, затем увеличил его на единицу и выбрал 116409 целью подделки. Для последующих доказательств вывода требуется, чтобы номер батча не превышал totalBatchesExecuted, поэтому поддельный батч должен быть зарегистрирован как «исполненный». Функция syncBatchRoot принимает любые номера батчей и корни, одновременно увеличивая totalBatchesExecuted до указанного значения, что означает: контроль злоумышленника над вызовом гейтвея определяет, какой батч считается исполненным.
- Злоумышленник инициировал легитимное получение межсетевого сообщения из сети Linea, вызвав Arbitrator.claimMessage через SyncL2TxHashRelayer. В отправке содержалось корректное доказательство подлинного сообщения Linea, с адресом сервиса сообщений исходной сети Linea Rollup и доказательством claimMessageWithProof. Это сообщение Linea→Ethereum, созданное злоумышленником в Linea, указывало LineaL1Gateway получателем, а содержимое инструктировало сделать форвардинг syncL2TxHash(_l2TxHash,_primaryChainL2TxHash) на целевой гейтвей. После проверки доказательства Linea Rollup вызвал callback в LineaL1Gateway, который вызвал функцию receiveMessage в Arbitrator, записав хеш этого сообщения во временное хранилище finalizeMessageHash. Затем функция claimMessage сверила значение. Таким образом, злоумышленник получил значение finalizeMessageHash через легитимную операцию получения, оставив его в хранилище до конца транзакции. Регистрация SyncL2TxHashRelayer как реллеера Arbitrator в сочетании с отсутствием контроля доступа у claimPrimaryChainSyncL2TxHashMessage позволяет любому адресу использовать его для вызова claimMessage. При этом claimMessage позволяет реллеерам самостоятельно указывать адреса сервисов сообщений исходной сети, а значение, записанное при легитимном получении, сохраняется во временном хранилище до конца транзакции.
- Атакующий контракт немедленно снова вызвал того же реллеера, изменив только два параметра: вместо Linea Rollup как сервиса сообщений исходной сети указал EthereumGateway (зарегистрирован в Arbitrator), а вместо _sourceChainClaimCallData — sendMessage(0, syncBatchRoot(116409, 0x1934a1...a8, 0), ""). Остальные параметры (гейтвей исходной сети, сумма, calldata, параметры форвардинга) полностью совпадали с предыдущим вызовом. Именно эти параметры участвуют в проверке finalizeMessageHash. Так как finalizeMessageHash хранится во временном хранилище, он не очищается и не зависит от того, выполнил ли текущий вызов получение сообщения. Ни целевой адрес внешнего вызова, ни его calldata не участвуют в проверке, позволяя напрямую переиспользовать значение, оставленное предыдущим вызовом. Кроме того, Address.functionCall позволяет Arbitrator выполнять любой внешний вызов от своего имени — это и есть суть уязвимости. Вторая операция не получала сообщение, но успешно прошла проверку claimMessage, тогда как внешний вызов sendMessage на EthereumGateway уже был исполнен до момента верификации.
- Когда Arbitrator вызвал функцию sendMessage на EthereumGateway, в calldata находился именно тот вызов syncBatchRoot(116409, forgedRoot, 0), который задал злоумышленник. ZkLink записал поддельный корень в качестве корня логов этого батча. Ончейн-данные показывают, что ZkLink эмитировал событие SyncBatchRoot(116409, 0x1934a1c3…), l2LogsRootHashes[116409] изменился с нуля на поддельный корень, а totalBatchesExecuted увеличился с 116408 до 116409. Теперь подделанный корень был «легитимно» отражён в реестре протокола. Оба вызова также форвардили syncL2TxHash, установив l2TxHashMap[0x2575…] в 0xe9ec… — это безвредный побочный эффект переиспользования верификационного значения. Поскольку EthereumGateway.sendMessage проверяет только, что вызывающий — Arbitrator, а ZkLink.syncBatchRoot — что вызывающий является гейтвеем (без проверки доказательств), оба слоя «верификации вызывающего без проверки содержимого» были пройдены, и поддельный корень был записан как легитимный.
- После подмены корня любой вывод с доказательством относительно этого корня проходил проверку. Атакующий контракт совершил 12 финальных вызовов на вывод — все по батчу 116409: один вывод ETH и 11 выводов ERC-20. В каждом сообщении содержались четыре сконструированных злоумышленником узла Меркла на основе поддельного корня, что гарантировало валидацию доказательства. Контракт ZkLink обработал вывод ETH напрямую, а L1ERC20Bridge — 11 выводов ERC-20. После успешной проверки токены были отправлены с бридж-контракта напрямую на EOA злоумышленника. Оба контракта записали сообщение как выведенное, но эта отметка лишь предотвращает повторные заявки и не подтверждает, что сообщение действительно было на L2. Контракты вывода проверяют только «включение сообщения в дерево для сохранённого корня батча», а не то, что сам корень действительно произошёл из исполнения на L2. После подделки корня любое сообщение может сопровождаться валидным доказательством.
Требования к устранению
Инцидент был вызван сочетанием возможности произвольных внешних вызовов в контракте Arbitrator и повторного использования неочищенных значений transient storage для верификации. Arbitrator.claimMessage позволяет реллеерам указывать любые цели и calldata, исполняя произвольные внешние вызовы от своего имени. Единственная финальная проверка — сравнение с остаточным значением из transient storage, без его очистки или проверки, что текущий вызов действительно завершил получение сообщения. Злоумышленник использовал легитимный claim для записи верификационного значения, а затем повторно использовал его во втором вызове claimMessage, заставив Arbitrator вызвать интерфейс onlyArbitrator у гейтвея от имени злоумышленника, что в итоге позволило записать поддельный корень батча в ZkLink и сделать активы на бридже «доказуемо» доступными для вывода.
Устранение должно охватить три уровня. Во-первых, claimMessage должен очищать finalizeMessageHash перед вызовом Address.functionCall, а затем требовать, чтобы оно было ненулевым и совпадало с текущей декларацией, либо использовать инкрементируемый nonce на вызов, чтобы привязать значение, гарантируя, что верификационное значение может быть записано только через receiveMessage в рамках текущего вызова. Во-вторых, реализовать белый список для _sourceChainCanonicalMessageService, чтобы предотвратить использование гейтвеев, зарегистрированных в Arbitrator, как «сервисов сообщений исходной сети». Вспомогательные контракты для разработки не должны регистрироваться как реллееры, либо должны иметь контроль доступа, предотвращающий возможность вызова claimMessage любым адресом. В-третьих, ZkLink.syncBatchRoot должен отклонять попытки перезаписи существующих корней батчей, а на уровне гейтвея или сообщений необходимо добавить независимый слой доказательства, чтобы обновления корней батчей не проходили только по принципу «вызывающий — гейтвей».
Популярное
- Новости ФБР связало хищение у Binance с КНДР
- Новости Chainalysis сообщила о доступе к данным клиентов
- Новости Binance заявила о резерве $6,34 млрд после взлома
- Расследования Chainalysis использует ИИ для отслеживания украденных с Bitget $387 млн
- Новости Binance сообщила об инциденте на $6,3 млрд
- Расследования Хакер Cysic Bridge перевёл $744K в Tornado Cash после предложения 10% награды
- Расследования Хакеры, эксплуатировавшие Drift, перевели 109 млн долларов в ETH через миксер Tornado Cash
- Новости Инциденты с криптовалютой обошлись в $1,18 млрд в III квартале 2026 года — в основном из-за уязвимостей поставщиков
- Расследования Кража с аппаратных кошельков CryptoBilis оценивается в $93–94,5 млн
- Расследования Житель Вирджинии лишился биткоинов на $101 млн после 40 лет тюрьмы
