*Часть 2 из 12 в серии «Безопасность Web3: с нуля». В части 1 мы разобрали, что контракт — это код и состояние, а часть функций открыта всем. Здесь — функции, которые открыты не всем, и кто держит от них ключи.*
В большинстве контрактов есть функции, которые не должны быть публичными: поменять комиссию, поставить торги на паузу, выпустить токены, заменить код, вывести казну. Контракт защищает их проверкой доступа. Проверка сравнивает msg.sender с тем, что записано в состоянии. Чей адрес совпал с записанным, у того и дверь.
Поэтому безопасность контракта во многом сводится к одному вопросу: кто может менять состояние, которое говорит, кому можно? Большинство контрактов решает это одним из двух шаблонов OpenZeppelin12.
Ownable: один владелец
Ownable хранит один адрес и даёт модификатор:
import "@openzeppelin/contracts/access/Ownable.sol";
contract Pool is Ownable {
constructor(address initialOwner) Ownable(initialOwner) {}
function setFee(uint256 fee) external onlyOwner { /* ... */ }
}Владелец вызывает любые функции с onlyOwner. Кроме того, встроены ещё три1:
transferOwnership(newOwner)отдаёт всю дверь другому адресу. ВыпускаетOwnershipTransferred(previousOwner, newOwner).renounceOwnership()обнуляет владельца. Все функции сonlyOwnerпосле этого мертвы навсегда.Ownable2Step— безопаснее. Передача идёт в два шага: текущий владелец предлагает (OwnershipTransferStarted), новый принимает (OwnershipTransferred). Опечатка в адресе уже не оставит контракт без владельца.
AccessControl: много ролей
AccessControl заменяет одного владельца именованными ролями, у каждой свой набор адресов2:
import "@openzeppelin/contracts/access/AccessControl.sol";
contract Token is AccessControl {
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
constructor(address admin) {
_grantRole(DEFAULT_ADMIN_ROLE, admin);
}
function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) { /* ... */ }
}У каждой роли есть админская роль, которая может её выдавать и отзывать. По умолчанию это DEFAULT_ADMIN_ROLE, значение 0x00. Кто её держит, тот выдаст MINTER_ROLE любому адресу. На практике DEFAULT_ADMIN_ROLE — мастер-ключ, и все остальные роли защищены не лучше, чем его владелец.
События: RoleGranted(role, account, sender), RoleRevoked(...) и RoleAdminChanged(...).
Что такое «админский ключ» на деле
Владелец или админ — это адрес. Адресом управляет одно из трёх:
- Обычный аккаунт (EOA) с одним приватным ключом. Ключ утёк — дверь у атакующего. На старте так чаще всего и бывает: аккаунт, развернувший контракт, становится первым владельцем.
- Мультисиг, например Safe, где подписывать должны несколько человек. Это часть 4.
- Контракт, например таймлок: он задерживает любое админское действие на заданный срок, чтобы остальные успели отреагировать.
Рискованный вариант — первый. Один ключ где-то хранится: ноутбук, аппаратный кошелёк, секрет в CI. Хватит фишинга, вредоносной программы или утёкшей резервной копии. Проверка onlyOwner атакующего не остановит: теперь он и есть владелец.
Три случая из наших разборов
Во всех трёх атакующий не искал уязвимость в коде. Он воспользовался дверью.
- IoTeX ioTube, февраль 2026. За 14 минут до первого вывода из моста появился сигнал о смене владельца. Разбор.
- Wasabi Protocol, апрель 2026. Ключ деплойера выдал
ADMIN_ROLEза 108 секунд до опустошения хранилищ. Потом атакующий обновил прокси, и за 28 минут всё ушло. Разбор. - DxSale, май 2026. Три локера передали новому владельцу. Затем 45 часов тишины. Затем ликвидность ушла из более чем 1 400 пулов. Разбор.
Общее: опасный шаг — маленький тихий административный вызов, и он случается раньше потери. У DxSale зазор был почти двое суток, у Wasabi меньше двух минут. Сколько даёт алерт, зависит от этого зазора и от того, остался ли у команды рычаг к моменту, когда она прочла сообщение, например pause().
Вопросы к своему контракту
- Кто сегодня владелец или держатель
DEFAULT_ADMIN_ROLE? Прочитайте это из сети, а не по памяти. - Это EOA, мультисиг или таймлок?
- Можно ли отозвать роль? В токенах эпохи OpenZeppelin 2.x (
MinterRole) минтер может добавлять других минтеров, а отказаться от роли может только сам минтер. Отозвать её не может никто, и пути назад нет. - Есть ли пауза и кто может её вызвать?
- Используете ли вы
Ownable2Step, чтобы опечатка не оставила контракт без хозяина?
Как поймать
Всё ниже строится по ABI и событиям контракта, как SmartAlert работает сейчас3.
- Владелец. Добавьте контракт. Если в ABI есть
OwnershipTransferred, SmartAlert предложит шаблонownership-transferredс важностью critical, а если контракт выпускаетOwnershipTransferStarted— ещё иownership-transfer-started. Рецепт: Алерт при передаче владения. - Роли. Для AccessControl — шаблоны
role-grantedиrole-revoked, а при наличии события в ABI ещёrole-admin-changed. Рецепт: Алерт при выдаче или отзыве роли. Команды меняют роли несколько раз в год, поэтому каждый такой алерт стоит прочитать. Для старых токенов на OpenZeppelin 2.x есть шаблоныminter-added,minter-removedиpauser-added. - Паузы.
pausedиunpaused. Рецепт: Алерт при паузе контракта. Вариант по состоянию,paused()вернула true, — второй сигнал на тарифе Pro и выше. - Разрешённые отправители. Правило по
OwnershipTransferredилиRoleGrantedсо списком адресов, которым это позволено. Срабатывает, если отправитель или получатель транзакции не из списка. Ловит и прямой вызов, и вызов через другой контракт, а ещё учитывает упавшие попытки: чужой отклонённый вызов админской функции тоже виден4.
Чего эти правила не умеют. Они сообщают, что дверью воспользовались, но не законно ли: плановая передача и кража выглядят одинаково, поэтому алерт должен приходить тому, кто знает план. И приходят они после подтверждения блока: в Ethereum это примерно 2,5–4 минуты. Если ключ уже у атакующего и он действует в одном блоке, алерт будет уведомлением, а не остановкой.
Дальше, часть 3: Прокси с обновлением: почему код за адресом может смениться.
Источники
- 1.OpenZeppelin Contracts, Access: Ownable, Ownable2Step. ↩
- 2.OpenZeppelin Contracts, Access Control. ↩
- 3.SmartAlert, шаблоны правил по типу контракта: Ownable, AccessControl, Pausable; см. рецепты. ↩
- 4.SmartAlert, правило «разрешённые отправители»: транзакция разрешена, если её отправитель или получатель есть в списке; упавшие транзакции учитываются. ↩