Aztec Connect потерял $2,19 млн из-за уязвимости в ZKRollup

14 июня 2026 года злоумышленник эксплуатировал устаревший контракт RollupProcessor Aztec Connect (0xff1f2b4adb9df6fc8eafecdcbf96a2b351680455) и вывел около $2,19 млн из пула L1 в рамках одной атомарной транзакции. Aztec Connect был выведен из эксплуатации в марте 2024 года, но неизменяемый контракт продолжал удерживать остатки пользовательских активов, что и сохранило поверхность атаки. Ниже приведена реконструкция инцидента на основе исходного кода и onchain calldata. Техническая причина Ключевая проблема — несовпадение границ: диапазон циклов L1-расчётов, который фактически проходит RollupProcessorV3, не совпадает с диапазоном слотов, которые фиксируются хэшем публичных входов ZK. Это создало "зазор" между numRealTxs и decoded_slots, который можно заполнить произвольными данными. В Decoder.sol параметр numRealTxs полностью задаётся атакующим: он считывается из calldata по смещению 4516 и не ограничен onchain-проверками. При этом decoded_slots округляется вверх до ближайшего кратного numTxsPerRollup (из-за требований к раскладке данных для прекомпиляции SHA256). Округление формирует область разрыва между numRealTxs и decoded_slots. В RollupProcessorV3.sol цикл settlement обрабатывает только numRealTxs слотов. В результате слоты, попавшие в "зазор", могут быть зафиксированы в коммитменте и подтверждены ZK-доказательством, не проходя L1-валидацию. Сломанные допущения безопасности В штатной модели каждый публичный входной слот либо проверяется на уровне L1-контракта (например, уменьшением pendingDepositBalance при депозите), либо ограничивается ZK-цепью так, что publicValue == 0. В данном сценарии: - SHA256-прекомпиляция охватывала все 32 слота (использовался ввод 8192 байта = 32 × 256 байт), и содержимое слотов разрыва было зафиксировано через ZK proof. - L1 settlement обрабатывал только первый слот; слоты разрыва [2..32] не проходили проверку на уровне L1. - Ограничение ZK-цепи для publicValue в слотах разрыва (ожидаемо 0) оказалось обойдённым или фактически не обеспеченным, что позволило "нарисовать" депозиты. Модель расхождения двух путей Одни и те же calldata интерпретировались двумя путями с разными верхними границами: ZK-часть учитывала 32 слота, а L1-часть — только 1. Это расхождение в определении "какие слоты считаются" и привело к минтингу "из воздуха". Ход атаки Атакующая транзакция 0x074ec931…aee1 включала 14 вызовов processRollup() в двухфазной схеме: "сначала 7 минтов, затем 7 выводов", всё в одной атомарной транзакции. Фаза 1. Minting: получение активов на L2 без обеспечения на L1 (Rollup #13277–13283, 7 раз) 1) EOA атакующего 0x0f18d8b44a740272f0be4d08338d2b165b7edd17 вызвал мастер-контракт управления 0x06f585f74e0da633ae813a0f23fb9900b61d0fcd, сработал селектор 0x6f3ce701. 2) Мастер-контракт последовательно задействовал три relay-контракта с жёстко прошитыми вредоносными calldata для роллапов. Параметры calldata: - numRealTxs = 1, rollupSize = 1024, numInnerRollups = 32 - Слот 1 (видим L1): proofId = 0 (noop), publicValue = 0 - Слоты 2–32 (31 слот разрыва, невидимы L1): proofId = 1 (deposit), publicValue = N, publicOwner = L2-адрес атакующего - Приложено соответствующее ZK-доказательство (цепь не ограничивала publicValue в слотах разрыва значением 0) 3) Relay Contract A последовательно вызвал RollupProcessor.processRollup() (Rollup #13277–13281, 5 раз): - Verifier подтвердил корректность ZK proof; SHA256-коммитмент покрывал все 32 слота. - L1 settlement остановился на 1 × TX_PUBLIC_INPUT_LENGTH = 1 слоте и обработал только noop. - Ложные "депозиты" в слотах [2..32] были зафиксированы ZK в новом корне Меркла, баланс атакующего на L2 вырос на 5 × 31N. 4) Relay Contract B повторил ту же схему для Rollup #13282–13283 (2 раза), добавив ещё 2 × 31N. Итог фазы: на L2 у атакующего накопилось 7 × 31N необеспеченных депозитов, хранилище на L1 не изменилось. Фаза 2. Withdrawal: обмен раздутого баланса L2 на реальные активы L1 (Rollup #13284–13290, 7 раз) Атакующий конвертировал весь искусственно созданный баланс в активы L1 через 7 роллапов вывода: - Rollup #13284 (DAI): withdraw() → RollupProcessor перевёл 270,513.054 DAI на 0x0f18…edd17 - Rollup #13285 (wstETH): переведено 167.890 wstETH атакующему - Rollup #13286 (yvDAI): переведено 4,873.857 yvDAI атакующему - Rollup #13287 (yvWETH, управление перешло relay contract C): переведено 16.570 yvWETH атакующему - Rollup #13288 (LUSD): переведено 9,273.734 LUSD атакующему - Rollup #13289 (yvLUSD): переведено 359.047 yvLUSD атакующему - Rollup #13290 (ETH): RollupProcessor отправил 908.987 ETH через внутренний CALL атакующему Транзакция завершилась успешно (gasUsed = 4,513,539). Частичный откат на уровне контракта невозможен. Общая добыча оценивается примерно в $2,19 млн и была полностью взята из легитимного пула пользовательских активов RollupProcessor. Отслеживание средств По данным onchain-аналитики (на 15 июня 2026 года, примерно через сутки после инцидента): - Все активы были выведены одной транзакцией из RollupProcessor через промежуточный атакующий контракт 0x06f585…d0fcD напрямую на EOA 0x0F18D8b44a740272f0be4d08338d2b165b7EdD17. - Промежуточный контракт не удерживал остатки средств. - Похищенные средства остаются на EOA атакующего в неизменном объёме; признаков отмывания на момент наблюдения нет. Выводы и рекомендации Ключевой урок: верхняя граница settlement-цикла в контракте ZKRollup должна строго совпадать с диапазоном слотов публичных входов, зафиксированных в коммитменте ZK. Если между numRealTxs на уровне L1 и decoded_slots в SHA256-коммитменте существует разрыв, допущение "пусть ограничения обеспечит ZK-цепь" становится уязвимым. L1 обязан независимо проверять каждый слот публичных входов, коммитнутый ZK proof, а не делегировать эту ответственность слою цепи. Команда SlowMist рекомендует проводить комплексный внешний аудит перед развёртыванием Rollup-систем, с фокусом на логической согласованности границы L1/L2, доверенных границах декодирования calldata и вторичной onchain-верификации публичных входов ZK. Для выведенных из эксплуатации контрактов, которые продолжают хранить "наследные" активы, рекомендуется организованная миграция или уничтожение для устранения постоянного риска экспозиции. Материал подготовлен Threat Intelligence Team SlowMist с использованием MistEye Threat Intelligence System, платформы MistTrack и анализа SlowMist Agent на базе ИИ. Вопросы и обратная связь приветствуются.