Безопасность Web3: с нуляЧасть 6 из 12SmartAlert6 мин чтения

Почему мосты — главная цель: как потеряли деньги мосты Ronin и Orbit

Мост хранит токены всех пользователей в одном контракте и верит подписям вне сети. Ronin и Orbit по шагам: какие алерты сработали бы, а какие нет.

*Часть 6 из 12 серии «Безопасность Web3: с нуля».*

Один блокчейн не умеет читать другой. Ethereum не может сам проверить, что пользователь сжёг токены в какой-то другой сети. Мост — обходной путь, и этот обходной путь заодно становится слабым местом. В этой части: как устроен мост, почему в нём сконцентрирован риск и как выглядели на цепи два очень разных взлома мостов.

Как это устроено

Обычная схема — заблокировать и выпустить или заблокировать и выдать:

  1. Вы отправляете токены в контракт-хранилище (vault) в сети A. Хранилище их держит.
  2. В сети B вы получаете обёрнутый токен, выпущенный под этот залог.
  3. Чтобы вернуться, вы сжигаете обёрнутый токен в сети B, и хранилище в сети A отдаёт исходные.

Проблема в шаге 3. Хранилище в сети A не видит сеть B. Кто-то должен сообщить ему «сжигание было на самом деле», и этот кто-то — набор подписантов вне сети: валидаторы, операторы, свидетели, у каждого моста называется по-своему. Они следят за обеими сетями, подписывают сообщение о выводе, и хранилище платит, когда приложено достаточно валидных подписей.

Из этого у моста три свойства, которые делают его мишенью:

  • Всё лежит в одном месте. Все токены, которые когда-либо пересекали мост, сидят в одном хранилище или в нескольких контрактах. В хранилище Orbit в Ethereum в момент атаки было около $81,5 млн в DAI, WBTC, ETH, USDC и USDT12. В Ethereum-мосте Ronin, по данным The Block, было больше $850 млн3.
  • Хранилище верит подписям, а не фактам. Настоящее сжигание от выдуманного оно отличить не может. Подписи валидны — деньги уходят.
  • Сломаться может любое из трёх, независимо. Ключи подписантов, ключ, который меняет контракт (владелец, админ прокси), и числа внутри контракта, которые определяют, что такое «достаточно подписей». Атакующему хватает одного.

Второе мы видели в части 5 на примере IoTeX. Вот остальные два.

Ronin, август 2024: число, которое стало нулём

Ronin — сеть за игрой Axie Infinity. Его шлюз в Ethereum платит, когда подписи операторов набирают порог, и view-функция minimumVoteWeight() возвращает этот порог. До обновления она возвращала 1540, это 70% суммарного веса операторов45.

6 августа 2024 в 08:48:47 UTC команда обновила шлюз. Обновляемый контракт заполняет новое состояние пронумерованными инициализаторами. Обновление выполнило версию 4 и пропустило версию 3, ту, что задаёт _totalOperatorWeight. Сумма осталась 0, а 70% от 0 — это 0. С этого блока minimumVoteWeight() возвращала 0, и для вывода подписи не требовались вообще56.

Никто не взламывал. Обновление было собственным обновлением команды, и событие Upgraded ожидалось. В 09:37:23, через 48 минут, MEV-бот вывел около 4 000 ETH. В 10:11:47 другой адрес вывел 1 998 046,88 USDC. Всего ушло около $12 млн. Бот принадлежал белому хакеру, и средства вернулись. Команда поставила мост на паузу в 10:15:2326. Шлюз ограничивал каждый вывод примерно 4 000 ETH или 2 млн USDC, поэтому больше за одну транзакцию бот забрать не мог2.

Правило, которое раз в минуту вызывает minimumVoteWeight() и срабатывает при значении ниже 1, сработало бы около 08:51–08:53: примерно за 45 минут до первого вывода. В нашем разборе Ronin — транзакции и чтения архивных блоков, подтверждающие значение.

Orbit Bridge, декабрь 2023: валидные подписи, чужие руки

Orbit Bridge держит все токены в Ethereum в одном хранилище. Хранилище — мультисиг: 10 владельцев, нужно 7 подписей. Мы прочитали getOwners() и required() за блок до первой транзакции атакующего7.

31 декабря 2023 в 20:40:35 UTC атакующий вывел 0,012 WBTC, это проверка. В 20:52:47 он вывел 30 ETH. Потом, с 21:07:59 до 21:25:35, пять крупных выводов: $10 млн DAI, 230,88 WBTC, 9 500 ETH, $10 млн USDC и $30 млн USDT. Всего около $81,5 млн. Хранилище выключили в 22:21:35, через 74 минуты после первого крупного вывода, когда оно почти опустело127.

В каждом из этих выводов были семь валидных подписей. Владелец Orbit, компания Ozys, говорит, что ошибок в контрактах не было; позже она подала в суд на бывшего главу безопасности, утверждая, что он ослабил политику файрвола перед уходом. Как атакующий получил семь подписей, публично не объяснено, и мы не гадаем8. Для мониторинга это не важно: подписи выглядели правильно, неправильными были только суммы.

Первые два вывода, примерно $500 и примерно $69 тыс., лежат ниже любого порога, который можно поставить, не утонув в ложных тревогах. $10 млн DAI в 21:07:59 — нет. Правило «перевод больше $1 млн из хранилища» сработало бы около 21:10:30–21:12, когда около $20 млн уже ушло, а около $61,5 млн ещё лежало. 9 500 ETH ушли примерно через 5–6 минут после такого алерта, USDC и USDT — примерно через 13–14 минут. У Orbit был выключатель хранилища, и им воспользовались через 74 минуты после первого крупного вывода7. Подробности — в нашем разборе Orbit.

Что показывают два случая

Ronin и Orbit — противоположности. В Ronin подписанты вообще не участвовали: поменялся параметр самого контракта, подозрительных переводов не было 48 минут, и ранним сигналом могла быть только проверка состояния. В Orbit параметры контракта были в порядке, проблемой стали подписанты, и первым полезным сигналом оказался размер уходящих денег.

Поэтому за мостом нужно следить двумя способами: что контракт говорит о себе (состояние) и что из него уходит (потоки). Один не заменяет другой.

Как поймать

  1. Следите за числами безопасности правилом «Состояние контракта». Для моста, как у Ronin: minimumVoteWeight(), вызов раз в минуту, срабатывание при значении ниже 1. Идея та же, что у рецепта paused(), только с числом вместо true/false. Создайте правило до следующего обновления: оно записывает ответы с момента создания и прошлое не читает. Тариф Pro и выше. Для хранилища-мультисига так же работает required().
  2. Следите за обновлениями и сменой владельца. Апгрейд прокси и передача владения на шлюзе и на контракте, который им управляет. Эти алерты говорят, когда сменился код. Правило состояния говорит, что именно в нём изменилось.
  3. Следите за выводом в долларах. Перевод больше суммы в $ на каждом токене хранилища с фильтром from = хранилище. Порог берите выше вашего самого крупного обычного вывода пользователя и заметно ниже общей суммы в хранилище. Это правило оценивает сумму по ценам с момента создания и на старых переводах не проверяется, поэтому порог сначала подберите правилом «крупный перевод» в токенах: оно на истории показывает, как часто сработало бы.
  4. Закройте нативный ETH. ETH уходит из хранилища без события Transfer. Добавьте хранилище в «Адреса» и создайте правило «суммарный баланс адреса в $ ниже порога» с нижней границей чуть ниже обычной суммы.
  5. Отправляйте эти алерты тому, у кого ключ паузы, напрямую. У Ronin был PauseEnforcer, у Orbit — выключатель. Оба сработали через час.

Две вещи мы не умеем. Мы не следим за списком подписантов: getOwners() возвращает массив, а правила состояния понимают только bool, адрес и число. Если хранилище выпускает события смены владельцев, их закроет событийное правило; если нет, правила для этого сегодня нет. И мы не отличаем настоящий вывод от подделки: ниже порога вывод по украденным подписям выглядит так же, как вывод обычного пользователя.

Дальше: часть 7, манипуляция оракулом и ценой, флэш-займы и KiloEx.

Источники

  1. 1.Orbit Chain, Official statement regarding Orbit Bridge exploit: суммы и украденные токены. ↩
  2. 2.Rekt, Orbit Bridge — Rekt и Ronin Network — Rekt II: хронологии, пропущенный инициализатор и лимит на вывод у Ronin. ↩
  3. 3.The Block, отчёт о паузе моста Ronin: возврат средств, больше $850 млн в мосте. ↩
  4. 4.Three Sigma, разбор атаки на Ronin Network: initializeV3, _totalOperatorWeight, порог 70%. ↩
  5. 5.SmartAlert, Взлом Ronin Bridge, август 2024: minimumVoteWeight() на архивных блоках, логи обновления. ↩
  6. 6.Ronin Network в X, заявление об инциденте. Время транзакций: SmartAlert, on-chain. ↩
  7. 7.SmartAlert, Взлом Orbit Bridge, декабрь 2023: getOwners() и required() на блоке 18907899, все транзакции и время блоков сверены on-chain. ↩
  8. 8.The Block, отчёт об Ozys и бывшем главе безопасности: ошибки в контрактах нет, смена файрвола, иск. ↩

Попробуйте бесплатно

Достаточно адреса. Первые 14 дней действуют лимиты Pro, а правила проверяются на истории до включения.

Ещё статьи