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

Владельцы, роли и админские ключи: где в контракте дверь

Как Ownable и AccessControl из OpenZeppelin решают, кто может чеканить, ставить паузу и обновлять, чем плох один админский ключ и что отслеживать.

*Часть 2 из 12 в серии «Безопасность Web3: с нуля». В части 1 мы разобрали, что контракт — это код и состояние, а часть функций открыта всем. Здесь — функции, которые открыты не всем, и кто держит от них ключи.*

В большинстве контрактов есть функции, которые не должны быть публичными: поменять комиссию, поставить торги на паузу, выпустить токены, заменить код, вывести казну. Контракт защищает их проверкой доступа. Проверка сравнивает msg.sender с тем, что записано в состоянии. Чей адрес совпал с записанным, у того и дверь.

Поэтому безопасность контракта во многом сводится к одному вопросу: кто может менять состояние, которое говорит, кому можно? Большинство контрактов решает это одним из двух шаблонов OpenZeppelin12.

Ownable: один владелец

Ownable хранит один адрес и даёт модификатор:

solidity
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:

solidity
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(...).

Что такое «админский ключ» на деле

Владелец или админ — это адрес. Адресом управляет одно из трёх:

  1. Обычный аккаунт (EOA) с одним приватным ключом. Ключ утёк — дверь у атакующего. На старте так чаще всего и бывает: аккаунт, развернувший контракт, становится первым владельцем.
  2. Мультисиг, например Safe, где подписывать должны несколько человек. Это часть 4.
  3. Контракт, например таймлок: он задерживает любое админское действие на заданный срок, чтобы остальные успели отреагировать.

Рискованный вариант — первый. Один ключ где-то хранится: ноутбук, аппаратный кошелёк, секрет в 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.

  1. Владелец. Добавьте контракт. Если в ABI есть OwnershipTransferred, SmartAlert предложит шаблон ownership-transferred с важностью critical, а если контракт выпускает OwnershipTransferStarted — ещё и ownership-transfer-started. Рецепт: Алерт при передаче владения.
  2. Роли. Для AccessControl — шаблоны role-granted и role-revoked, а при наличии события в ABI ещё role-admin-changed. Рецепт: Алерт при выдаче или отзыве роли. Команды меняют роли несколько раз в год, поэтому каждый такой алерт стоит прочитать. Для старых токенов на OpenZeppelin 2.x есть шаблоны minter-added, minter-removed и pauser-added.
  3. Паузы. paused и unpaused. Рецепт: Алерт при паузе контракта. Вариант по состоянию, paused() вернула true, — второй сигнал на тарифе Pro и выше.
  4. Разрешённые отправители. Правило по OwnershipTransferred или RoleGranted со списком адресов, которым это позволено. Срабатывает, если отправитель или получатель транзакции не из списка. Ловит и прямой вызов, и вызов через другой контракт, а ещё учитывает упавшие попытки: чужой отклонённый вызов админской функции тоже виден4.

Чего эти правила не умеют. Они сообщают, что дверью воспользовались, но не законно ли: плановая передача и кража выглядят одинаково, поэтому алерт должен приходить тому, кто знает план. И приходят они после подтверждения блока: в Ethereum это примерно 2,5–4 минуты. Если ключ уже у атакующего и он действует в одном блоке, алерт будет уведомлением, а не остановкой.

Дальше, часть 3: Прокси с обновлением: почему код за адресом может смениться.

Источники

  1. 1.OpenZeppelin Contracts, Access: Ownable, Ownable2Step. ↩
  2. 2.OpenZeppelin Contracts, Access Control. ↩
  3. 3.SmartAlert, шаблоны правил по типу контракта: Ownable, AccessControl, Pausable; см. рецепты. ↩
  4. 4.SmartAlert, правило «разрешённые отправители»: транзакция разрешена, если её отправитель или получатель есть в списке; упавшие транзакции учитываются. ↩

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

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

Ещё статьи