*Часть 6 из 12 серии «Безопасность Web3: с нуля».*
Один блокчейн не умеет читать другой. Ethereum не может сам проверить, что пользователь сжёг токены в какой-то другой сети. Мост — обходной путь, и этот обходной путь заодно становится слабым местом. В этой части: как устроен мост, почему в нём сконцентрирован риск и как выглядели на цепи два очень разных взлома мостов.
Как это устроено
Обычная схема — заблокировать и выпустить или заблокировать и выдать:
- Вы отправляете токены в контракт-хранилище (vault) в сети A. Хранилище их держит.
- В сети B вы получаете обёрнутый токен, выпущенный под этот залог.
- Чтобы вернуться, вы сжигаете обёрнутый токен в сети 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 параметры контракта были в порядке, проблемой стали подписанты, и первым полезным сигналом оказался размер уходящих денег.
Поэтому за мостом нужно следить двумя способами: что контракт говорит о себе (состояние) и что из него уходит (потоки). Один не заменяет другой.
Как поймать
- Следите за числами безопасности правилом «Состояние контракта». Для моста, как у Ronin:
minimumVoteWeight(), вызов раз в минуту, срабатывание при значении ниже 1. Идея та же, что у рецепта paused(), только с числом вместо true/false. Создайте правило до следующего обновления: оно записывает ответы с момента создания и прошлое не читает. Тариф Pro и выше. Для хранилища-мультисига так же работаетrequired(). - Следите за обновлениями и сменой владельца. Апгрейд прокси и передача владения на шлюзе и на контракте, который им управляет. Эти алерты говорят, когда сменился код. Правило состояния говорит, что именно в нём изменилось.
- Следите за выводом в долларах. Перевод больше суммы в $ на каждом токене хранилища с фильтром
from= хранилище. Порог берите выше вашего самого крупного обычного вывода пользователя и заметно ниже общей суммы в хранилище. Это правило оценивает сумму по ценам с момента создания и на старых переводах не проверяется, поэтому порог сначала подберите правилом «крупный перевод» в токенах: оно на истории показывает, как часто сработало бы. - Закройте нативный ETH. ETH уходит из хранилища без события
Transfer. Добавьте хранилище в «Адреса» и создайте правило «суммарный баланс адреса в $ ниже порога» с нижней границей чуть ниже обычной суммы. - Отправляйте эти алерты тому, у кого ключ паузы, напрямую. У Ronin был PauseEnforcer, у Orbit — выключатель. Оба сработали через час.
Две вещи мы не умеем. Мы не следим за списком подписантов: getOwners() возвращает массив, а правила состояния понимают только bool, адрес и число. Если хранилище выпускает события смены владельцев, их закроет событийное правило; если нет, правила для этого сегодня нет. И мы не отличаем настоящий вывод от подделки: ниже порога вывод по украденным подписям выглядит так же, как вывод обычного пользователя.
Дальше: часть 7, манипуляция оракулом и ценой, флэш-займы и KiloEx.
Источники
- 1.Orbit Chain, Official statement regarding Orbit Bridge exploit: суммы и украденные токены. ↩
- 2.Rekt, Orbit Bridge — Rekt и Ronin Network — Rekt II: хронологии, пропущенный инициализатор и лимит на вывод у Ronin. ↩
- 3.The Block, отчёт о паузе моста Ronin: возврат средств, больше $850 млн в мосте. ↩
- 4.Three Sigma, разбор атаки на Ronin Network:
initializeV3,_totalOperatorWeight, порог 70%. ↩ - 5.SmartAlert, Взлом Ronin Bridge, август 2024:
minimumVoteWeight()на архивных блоках, логи обновления. ↩ - 6.Ronin Network в X, заявление об инциденте. Время транзакций: SmartAlert, on-chain. ↩
- 7.SmartAlert, Взлом Orbit Bridge, декабрь 2023:
getOwners()иrequired()на блоке 18907899, все транзакции и время блоков сверены on-chain. ↩ - 8.The Block, отчёт об Ozys и бывшем главе безопасности: ошибки в контрактах нет, смена файрвола, иск. ↩