*Часть 1 из 12 в серии «Безопасность Web3: с нуля». Серия объясняет, как устроены контракты, как их ломают и за чем можно следить. Знать Solidity не нужно.*
Мы пишем разборы взломов, и почти в каждом одни и те же два абзаца пояснений: «шлюз — это прокси», «порог — число в хранилище». Здесь мы собрали эти пояснения в одном месте. Часть 1 — основа: что лежит по адресу контракта и что оттуда видит мониторинг, а что нет.
Код и состояние по одному адресу
Смарт-контракт — программа, записанная в блокчейн по определённому адресу. У неё две части1:
- Код. Скомпилированные инструкции, зафиксированные при развёртывании. Потом их никто не правит. (В части 3 разберём случай, когда адрес остаётся, а код за ним всё же меняется.)
- Состояние. Собственное хранилище контракта: балансы, адрес владельца, настройки, счётчики. Меняется именно оно.
Маленький пример: контракт с комиссией и владельцем.
contract Vault {
address public owner;
uint256 public feeBps; // комиссия в базисных пунктах, 100 = 1%
event FeeChanged(uint256 oldFee, uint256 newFee);
constructor() { owner = msg.sender; }
function setFee(uint256 newFee) external {
require(msg.sender == owner, "not owner");
emit FeeChanged(feeBps, newFee);
feeBps = newFee;
}
}owner и feeBps — состояние. setFee — код, который его меняет. Всё остальное в статье — вариации на эти строки.
Вызовы: кто что может вызвать
Любое действие в сети — это транзакция: её подписывает аккаунт и отправляет на адрес. Если транзакция вызывает функцию, контракт видит вызывающего как msg.sender2.
Функции бывают двух видов:
- Читающие (
view) возвращают данные и ничего не меняют. Их может вызвать кто угодно, бесплатно и без транзакции.feeBps()как раз такая: Solidity сам создаёт геттер для каждойpublic-переменной. - Пишущие меняют состояние. Нужны транзакция и газ, и выполнятся они, только если контракт пустит вызывающего.
«Пустит» — это код, который написал разработчик. В setFee это одна строка: require(msg.sender == owner). Нет проверки — комиссию поставит кто угодно. Проверка есть, но в owner не тот адрес — комиссию поставит не тот человек. Этому посвящена вся часть 2.
Транзакция может и откатиться (revert). Если require не прошёл, все изменения вызова отменяются, но сама транзакция попадает в блок со статусом «неуспешна», и газ платит отправитель. Неудачная попытка вызвать админскую функцию — всё равно видимый факт: кто-то пробовал.
События: журнал, который пишет контракт
Состояние хранится внутри контракта. События — другое. emit FeeChanged(...) добавляет запись в лог транзакции3. Что важно для мониторинга:
- Контракт события не читает, только пишет. Они нужны внешнему миру: приложениям, эксплорерам, мониторингу.
- События дешёвые. Разработчики объявляют ими важные изменения:
Transferна каждое движение токена,OwnershipTransferred,Upgraded,Paused. - Событие — это лишь обещание разработчика. Компилятор не заставит
setFeeвызватьemit FeeChanged. Забылиemit— состояние изменилось, а лог молчит.
Бывает и наоборот: событие выпущено без реального изменения, или контракт выпускает вводящее в заблуждение. События — удобный сигнал, но не доказательство.
События и состояние — два разных вопроса
Эта мысль проходит через всю серию.
| Событие | Состояние | |
|---|---|---|
| Вопрос | «Что только что произошло?» | «Что верно прямо сейчас?» |
| Где лежит | Логи транзакций | Хранилище контракта |
| Как увидеть | Читать логи каждого блока | Вызвать view-функцию |
| Слабое место | Никто не выпустил событие | Никто не спросил |
view-функциюЧистый пример — наш разбор Ronin. Команда моста обновила шлюз, событие Upgraded было ожидаемым и верным. А внутри контракта число, от которого зависит, сколько подписей операторов нужно для вывода, стало равно 0. Событием об этом никто не сообщил. Показал это только вызов minimumVoteWeight(), а первый вывод случился через 48 минут.
Подробнее — в части 10. Пока запомните разделение: события рассказывают о действиях, вызовы — о результате.
У нативной монеты событий нет
Ещё одна дыра. Перевод токена выпускает Transfer, и за ним можно следить. Нативный ETH (или BNB), входящий на адрес и уходящий с него, не выпускает ничего. Увидеть его можно только по балансу. Это важно для мостов и казначейств, которые держат собственную монету сети.
Что видит внешний наблюдатель
Итого, у наблюдателя за контрактом четыре источника:
- События из логов контракта.
- Состояние через вызовы
view. - Балансы адреса: нативной монеты и токенов.
- Сами транзакции: кто отправил, прошла ли, какую функцию вызвала.
Публичные сети отдают всё это бесплатно и по каждому контракту. Не хватает того, кто читает это в три часа ночи.
Как поймать
Какие правила SmartAlert закрывают какой источник, как продукт работает сейчас4:
- События. При добавлении контракта SmartAlert читает его ABI и предлагает шаблоны правил по найденным событиям:
OwnershipTransferred,Upgraded,Paused,RoleGrantedи другим. Рецепты: смена владельца, обновление прокси, пауза. - Состояние. Правило «Состояние контракта» по расписанию вызывает
view-функцию и срабатывает по условию: например,paused()вернула true (рецепт) или число упало ниже нижней границы. Это правило поймало бы порог Ronin. Доступно на Pro и выше. - Балансы. Правило по балансу в долларах следит и за нативной монетой, и за токенами (рецепт).
- Упавшие транзакции. Правило по доле упавших транзакций видит откаты, в том числе чужой неудачный вызов админской функции.
Два ограничения, прямо. Первое: если критичное значение меняется без события и у него нет view-функции, ни одно правило за ним не уследит. Добавьте для него геттер. Второе: алерт не мгновенный. В Ethereum SmartAlert читает блоки после 12 подтверждений, поэтому уведомление приходит примерно через 2,5–4 минуты после транзакции.
Дальше, часть 2: Владельцы, роли и админские ключи: где в контракте дверь.
Источники
- 1.ethereum.org, Introduction to smart contracts. ↩
- 2.Документация Solidity, Block and transaction properties (
msg.sender). ↩ - 3.Документация Solidity, Events. ↩
- 4.SmartAlert, задержка алерта в Ethereum: 12 подтверждений (около 144 с) плюс опрос и интервал проверки правила; см. разбор Ronin, сноска 8. ↩