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

События и состояние: когда события не будет

Событие — то, что контракт решил объявить. Состояние — то, что он хранит. Почему порог Ronin стал 0 молча и как это ловит правило по view-функции.

Мониторинг обычно начинают с событий: контракт записал OwnershipTransferred, вам пришло сообщение. С этого и правильно начинать, на этом построена часть 9. Эта часть о том, чего события сказать не могут.

Что такое событие и чем оно не является

В части 1 мы выяснили, что у контракта есть состояние (значения, которые он хранит) и события (записи в журнале, которые он делает намеренно). Важное слово — «намеренно». Событие — строка, которую разработчик решил записать, когда выполнится определённая ветка кода. Отсюда три следствия.

  1. Нет ветки кода — нет события. Если значение меняется путём, который ничего не выпускает, журнал молчит.
  2. Событие говорит, что произошло, а не что верно сейчас. Upgraded сообщает, что код сменился. Оставил ли новый код контракт в безопасном состоянии, оно не говорит.
  3. Отсутствие чего-либо не событие. Значение, которое было неверным с самого начала, или то, что перестало происходить, ничего не выпускает.

Состояние устроено наоборот. Любую публичную переменную и любую view-функцию можно прочитать в любом блоке, бесплатно и кому угодно. Она всегда показывает, что верно сейчас.

Случай: Ronin, август 2024

Подробно мы разобрали его в отдельном посте про Ronin, здесь только механика.

Шлюз моста Ronin в Ethereum выплачивает вывод, только когда суммарный вес подписей операторов достигает порога. Порог возвращает minimumVoteWeight(). До апгрейда 6 августа он возвращал 15401.

Код считал порог как 70% от общего веса операторов, а общий вес задавал инициализатор версии 3. Апгрейд прыгнул сразу на версию 4, и этот инициализатор не выполнился. Общий вес остался 0, 70% от 0 — это 0, и порог стал 012. Упрощённый набросок такого паттерна (не точный код Ronin):

solidity
function initializeV3(...) external reinitializer(3) { _totalOperatorWeight = ...; }
function initializeV4(...) external reinitializer(4) { /* ... */ }
// прокси обновили до V4 и вызвали initializeV4, initializeV3 не вызывали никогда

Теперь посмотрите в логи этого апгрейда. В них есть Upgraded и Initialized(4). Нет Initialized(3) и нет ничего, что несло бы новое значение порога. Все события, которых ждала команда, на месте. Через 48 минут MEV-бот вывел около 4 000 ETH без подписей операторов, а следом ушли деньги ещё несколькими транзакциями1.

Правило на Upgraded здесь сработает, но это плановый апгрейд самой команды, и алерт прочитается как «да, это наш». Сигналом, что что-то не так, было число, и показать его мог только вопрос к самому контракту.

Четыре вида значений, которые меняются без события

Когда выбираете, за чем следить, проверьте этих подозреваемых.

  • Параметры, заданные в инициализаторах или обычным сеттером. Пороги, лимиты, комиссии, адрес стража. Часть сеттеров выпускает событие, многие нет, а инициализатор, который не выполнился, не выпускает ничего.
  • Вычисляемые и внешние значения. Цена от оракула, коэффициент обеспечения, общее предложение, курс обмена. Они двигаются потому, что сдвинулись входные данные, а не потому, что кто-то вызвал функцию.
  • Флаги, которые можно поставить окольным путём. К paused() обычно прилагается событие Paused. Если контракт ставит флаг иначе или пауза случилась до того, как вы начали следить, ловить нечего.
  • Кто сейчас главный. owner(), админ прокси, страж. Событие сообщает о смене. View-функция сообщает текущий ответ, даже если вы пропустили событие.

Проверка на хорошее правило по состоянию: *если бы это значение было неверным, продолжал бы контракт работать как обычно, пока не двинутся деньги?* Если да, запишите значение и читайте его.

Как работает правило по состоянию

Правило по состоянию вызывает view-функцию по расписанию, раз в минуту, 5 минут, 15 минут или час, и сравнивает ответ с условием. В SmartAlert оно называется «Состояние контракта» и доступно на Pro и выше.

  • Выберите функцию и нажмите «Проверить функцию». Вы увидите текущий ответ ещё до сохранения.
  • Выберите условие: например «равно true» для paused() или «ниже порога» для числа вроде minimumVoteWeight().
  • Severity и канал задаются так же, как у любого правила.

Что нужно знать, прежде чем на него полагаться.

  • Оно читает подтверждённые блоки. В Ethereum правило читает блок на 12 подтверждений позади головы, это около 144 секунд, плюс опрос и проверка. Первый вызов, который увидит плохое значение, приходится на 2,5–4 минуты после вызвавшей его транзакции3. Для Ronin это всё равно около 45 минут до первого вывода.
  • У него нет прошлого. История ответов начинается с создания правила, прошлые значения назад не читаются. Нельзя создать правило после инцидента и проиграть момент изменения. Чтобы поймать Ronin в 08:48, правило должно было существовать до 08:48. На практике: создавайте правила по состоянию до следующего апгрейда, а не после.
  • Откат — не значение. Если функция откатывается, правило показывает «нет данных» и само по себе не шлёт алерт, пока вы не выберете для «нет данных» реакцию «тревога». Решите, насколько громко это должно быть.
  • Оно не знает ваших инвариантов. SmartAlert проверяет числа, на которые вы его направили. Он не догадается, что minimumVoteWeight() никогда не должен быть 0. Это знаете вы, потому что читали код.

Что читать, по типам контрактов

Любой с паузой
paused()
Мост или шлюз
порог подписей или вес голосов, лимит вывода, страж
Кредитный протокол или хранилище
цена от оракула (latestAnswer()), параметры обеспечения, на которые вы опираетесь
Токен
owner(), адрес минтера или админа, если он доступен
После апгрейда
два-три значения, на которые опирается новый код

Функции с простыми аргументами тоже подходят, например balanceOf(vault) или hasRole(role, account). Для ответов bool и address есть условие «значение изменилось»: owner() или адрес минтера дадут алерт на любую смену, и новое значение заранее знать не нужно. Не подходят функции, которые возвращают массив или несколько значений сразу: getOwners() у Safe, getReserves() у пула, latestRoundData() у Chainlink. Для них следите за событиями или за соседней функцией с одним числом, например getThreshold() у Safe.

Где состояние не помогает

Если плохое значение и потеря происходят в одной транзакции, проверка состояния видит результат, а не предупреждение. Этот класс атак разобран в части 8. Правила по состоянию окупаются там, где между изменением и взломом есть промежуток, как у Ronin: 48 минут.

Как поймать

  1. Выпишите значения. По каждому контракту составьте список view-функций, неверный ответ которых ломает ваши допущения о безопасности. Начните с paused(), owner(), порогов и лимитов.
  2. Создайте правило «Состояние контракта» на каждую, с условием, которое означает «неверно». Для paused() шаблон — рецепт contract-state-paused. Для порога то же правило с числом вместо true/false, как в случае Ronin: вызывать minimumVoteWeight() раз в минуту, срабатывать ниже 1. Severity Critical.
  3. Создайте их до следующего апгрейда. Тогда первый вызов после апгрейда покажет, пережили ли числа обновление.
  4. Событийные правила оставьте. Upgraded скажет, когда сменился код, правило по состоянию — что изменилось. Для нативного ETH, уходящего с моста, добавьте treasury-below-usd, для токенов — large-transfer-usd.
  5. Знайте, что будете делать, когда алерт по состоянию придёт ночью. Это часть 12.

Источники

  1. 1.SmartAlert, разбор взлома моста Ronin, август 2024: minimumVoteWeight() прочитан архивным eth_call в блоках до и после апгрейда, логи апгрейда (Upgraded, Initialized(4), нет Initialized(3)), выводы с временем блоков. ↩
  2. 2.Three Sigma, analysis of the Ronin Network exploit; Rekt, Ronin Network, Rekt II. ↩
  3. 3.SmartAlert, задержка алерта в Ethereum: 12 подтверждений (около 144 с), опрос головы до 12 с, запись до 15 с (P95), проверка правила раз в 60 с на Pro. ↩

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

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

Ещё статьи