6 августа 2024 года команда Ronin обновила свой мост в Ethereum. Через 48 минут MEV-бот вывел из него около 4 000 ETH, а ещё через полчаса другой адрес забрал 2 млн USDC. Всего ушло около $12 млн. Бот принадлежал белому хакеру, и средства вернулись в тот же день123.
Большинство взломов мостов начинается с украденного ключа. Этот — нет. Никто ничего не взламывал, и ничего необычного контракт не выпустил. Обновление было собственным обновлением команды, и событие Upgraded ожидалось. Сломалось число внутри контракта, и увидеть его можно было только вопросом к контракту.
Как мост Ronin одобряет вывод
Ronin — блокчейн, на котором работает Axie Infinity. У его моста в Ethereum две важные здесь части23:
- Шлюз
0x6419…AF08в Ethereum хранит перенесённые ETH, USDC и другие токены. Это прокси: адрес остаётся, код за ним можно заменить. - Операторы моста следят за Ronin. Когда пользователь выводит средства в Ethereum, операторы подписывают вывод. У каждого оператора есть вес голоса.
Шлюз платит, только если суммарный вес подписавших дотягивает до порога. Его возвращает view-функция minimumVoteWeight(). До 6 августа она возвращала 1540 — 70% суммарного веса операторов34.
Ещё шлюз ограничивает, сколько может забрать один вывод: около 4 000 ETH или 2 млн USDC2. Запомните это. Он ограничивал размер каждого вывода.
Изменения шлюза проходят через MainchainBridgeManager 0xa714…fab0: предложению нужны подписи операторов, затем его может отправить в сеть кто угодно. Для аварий есть отдельный PauseEnforcer 0xe514…bcd4, который останавливает шлюз одним вызовом.
Что пошло не так при обновлении
Обновляемый контракт хранит состояние в прокси. Новому коду часто нужно новое состояние, поэтому к нему прилагается инициализатор, который его заполняет. У шлюза их было несколько, по номерам версий. Версия 3 задавала _totalOperatorWeight — суммарный вес, от которого считается порог. Версия 4 задавала новый вспомогательный контракт для WETH23.
Обновление 6 августа перескочило сразу к версии 4. Версию 3 прокси никогда не запускал. Поэтому _totalOperatorWeight остался равен 0, а 70% от 0 — это 03.
Это видно в логах обновления. В них есть Upgraded и Initialized(4), но нет Initialized(3)4.
Атака по шагам
Время — UTC, время блоков Ethereum. Каждую транзакцию ниже мы прочитали в сети сами4.
08:48:47 — плановое обновление
Адрес 0xe880…f059 отправил в MainchainBridgeManager предложение раунда 5 с 16 подписями операторов (0x855d…767e, блок 20468438). Предложение сделало шесть вызовов к шлюзу. Среди них: добавить WBTC, переключить код на 0xfc27…20Ee и запустить initializeV4, затем сменить админа прокси. Последние два вызова упали, первые четыре прошли4.
Мы прочитали minimumVoteWeight() через eth_call на архивных блоках4:
| Блок | Когда | minimumVoteWeight() |
|---|---|---|
| 20468437 | блок перед обновлением | 1540 |
| 20468438 | 08:48:47, обновление | 0 |
| 20468678 | блок перед первым выводом | 0 |
С этого блока выводу из шлюза не нужны были подписи операторов вообще. Любой, кто заметил, мог забирать до лимита одного вывода сколько угодно раз.
09:37:23 — 3 996 ETH
Адрес 0x1a56…3cc1 вызвал свой MEV-контракт 0x4Ab1…3c0B, а тот вызвал submitWithdrawal у шлюза. Шлюз выплатил 3 996,09 ETH (0x2619…a6cb, блок 20468679)4. Это через 48 минут после обновления.
10:11:47 — 2 млн USDC
Адрес 0xFDe0…455A вывел 1 998 046,88 USDC на 0x6980…BDD0 (0xbce5…0ad8, блок 20468848)4.
10:15:23 — пауза
Адрес 0x8B35…8f57 вызвал triggerPause() у PauseEnforcer (0x90a1…c090, блок 20468866)4. Это через 38 минут после первого вывода и через 86 минут после обновления. Ronin сообщила, что поставила мост на паузу примерно через 40 минут после первого вывода1.
Потом — деньги вернулись
Белый хакер за MEV-ботом вернул ETH, а примерно через два часа и USDC. Ronin выплатила награду $500 тыс. По данным The Block, в мосту в тот момент было больше $850 млн16. Лимит на один вывод ограничивал каждую транзакцию примерно 4 000 ETH или 2 млн USDC2. С реагированием помог SEAL 9113.
Что не изменило бы ни одно правило
Как поставить это правило в SmartAlert
- Добавьте контракт и откройте его. Правила состояния контракта — на тарифе Pro и выше.
- Создайте правило «Состояние контракта». Выберите функцию, например
minimumVoteWeight(), и нажмите «Проверить функцию», чтобы увидеть текущий ответ. - Выберите условие «Меньше порога» с порогом ниже обычного значения, например 1. Для моста — вызывать каждую минуту.
- Создайте его до следующего обновления. Тогда первый вызов после обновления покажет, пережило ли его число.
- Добавьте и правила на события.
Upgradedговорит, когда сменился код, правило состояния — что он изменил. Для нативного ETH добавьте шлюз в «Адреса» и создайте правило «Общий баланс адреса в $ ниже порога». - Выберите, куда идут алерты. Telegram бесплатный.
Все рецепты алертов.
Переезжаете с OpenZeppelin Defender? Как каждый монитор Defender переносится в правило SmartAlert — в руководстве по переезду, а сравнение с Defender — отдельной страницей.
Источники
- 1.Ronin Network в X, заявление об инциденте: пауза, около 40 минут, белый хакер. ↩
- 2.Rekt, Ronin Network — Rekt II: пропущенный инициализатор, лимит на один вывод, суммы. ↩
- 3.Three Sigma, разбор атаки на Ronin Network:
initializeV3и_totalOperatorWeight, порог 70%, SEAL 911. ↩ - 4.Проверено SmartAlert в сети:
minimumVoteWeight()через архивныйeth_callна блоках 20468437, 20468438 и 20468678; транзакция обновления, её шесть вызовов, их результаты и логи (Upgraded,Initialized(4), нетInitialized(3)); два вывода и пауза — время блоков, отправители и суммы — через Blockscout API. ↩ - 5.SmartAlert, задержка алерта в Ethereum: 12 подтверждений (около 144 с), опрос головы сети до 12 с, запись до 15 с (P95), проверка правила раз в 60 с на Pro. ↩
- 6.The Block, материал о паузе моста Ronin: возврат средств, награда $500 тыс., больше $850 млн в мосту. ↩
- 7.CertiK, Bybit incident technical analysis. ↩
- 8.KiloEx, Security incident root cause analysis. ↩