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

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

Как работают прокси ERC-1967, delegatecall и инициализаторы, почему проверенный аудитом контракт меняется после аудита и какие сигналы ловить.

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

У неизменяемого кода есть цена: ошибку, найденную через месяц, не исправить. Поэтому большие протоколы разворачивают не один контракт, а два: прокси, с которым общаются пользователи, и реализацию (implementation) с логикой. Адрес прокси не меняется. Реализация за ним — может1.

Для пользователя это значит, что «проверенный аудитом контракт по этому адресу» верно только до следующего обновления. Для атакующего функция обновления — главный приз во всей системе: кто может её вызвать, тот заменит все правила.

delegatecall: логика в одном месте, состояние в другом

Обычный вызов выполняет код цели на хранилище цели. delegatecall иной: он выполняет код цели на хранилище вызывающего2. Минимальный прокси — это fallback, который пересылает любой вызов:

solidity
fallback() external payable {
    address impl = _implementation();            // читается из фиксированного слота
    assembly {
        calldatacopy(0, 0, calldatasize())
        let ok := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
        returndatacopy(0, 0, returndatasize())
        switch ok case 0 { revert(0, returndatasize()) } default { return(0, returndatasize()) }
    }
}

Итог: балансы, владелец, настройки и всё остальное живут в прокси. Реализация — лишь набор инструкций. Обновление — одна запись: меняется адрес, сохранённый в прокси. Состояние остаётся, инструкции другие.

Где прокси хранит адрес реализации

Если бы прокси держал адрес в обычной переменной, она могла бы столкнуться с переменными реализации. ERC-1967 закрепляет слоты3:

solidity
// адрес реализации
bytes32 IMPL_SLOT  = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
// адрес админа
bytes32 ADMIN_SLOT = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;

Это keccak256("eip1967.proxy.implementation") - 1 и keccak256("eip1967.proxy.admin") - 1. По ним эксплореры показывают «Read as proxy». Тот же стандарт описывает события:

  • Upgraded(address indexed implementation)
  • AdminChanged(address previousAdmin, address newAdmin)
  • BeaconUpgraded(address indexed beacon) для варианта с beacon, где много прокси делят один указатель.

Кто может обновлять: два частых устройства

  • Transparent proxy. В прокси лежит адрес админа (обычно контракт ProxyAdmin), и обновлять может только он. Это вопрос «чья дверь» из части 2, только ставки максимальные.
  • UUPS (ERC-1822). Функция обновления лежит в реализации, и реализация сама решает, кому её вызывать, в хуке вроде _authorizeUpgrade4. Дешевле в работе, но новая реализация без этой функции или с неверной проверкой навсегда запирает прокси или открывает его всем.

В обоих случаях ответ на «кто может менять код» — адрес, за которым стоит ключ. Если там один ключ, вся система держится на нём. Для этого и существуют Safe и таймлоки (часть 4).

Инициализаторы: конструктор, которым нельзя пользоваться

Конструктор выполняется один раз в хранилище самой реализации, поэтому состояние прокси он настроить не может. В обновляемых контрактах его заменяет функция initialize, защищённая так, чтобы выполниться только раз на версию5:

solidity
function initialize(address admin) public initializer { ... }
function initializeV2(uint256 x)  public reinitializer(2) { ... }

Каждый запуск выпускает Initialized(version). Именно на инициализаторах обновления чаще всего ломаются без всякого атакующего:

  • Неинициализированную реализацию может инициализировать посторонний и стать её владельцем.
  • Обновление, добавляющее состояние, должно запустить инициализатор, который его заполняет. Пропустили — новая переменная остаётся нулём.
  • Новая реализация, переставившая переменные или поменявшая их типы, ломает раскладку хранилища (storage layout): новый код читает старые слоты и видит мусор.

Второй случай — наш разбор Ronin. Обновление перескочило сразу на версию 4 и не запустило инициализатор версии 3. Порог голосов считался от суммы, которая осталась нулём, поэтому порог стал 0, и для вывода подписи не требовались. Событие Upgraded было обычным. В логах есть Initialized(4) и нет Initialized(3). Примерно через 48 минут ушло $12 млн.

Как выглядят сигналы

Обновление оставляет следы, за которыми можно следить:

Upgraded(implementation)
ГдеЛоги прокси
Что говоритКод изменился, и на какой адрес
AdminChanged(...)
ГдеЛоги прокси
Что говоритПраво обновлять перешло к другому
Initialized(n)
ГдеЛоги прокси
Что говоритЗапустился инициализатор, и какой версии
Значения ключевых view-функций
ГдеСостояние
Что говоритСохранило ли новое обновление инварианты

Первые три — события. Четвёртое — урок Ronin: обновление объявляет о себе, а потом результат нужно проверить.

Как поймать

Что SmartAlert делает сейчас6:

  1. Обновление и смена админа. Добавляйте адрес прокси, а не реализации. Для прокси ERC-1967 продукт предлагает шаблоны proxy-upgraded и proxy-admin-changed, оба critical, с новым адресом реализации или админа в сообщении. Рецепт: Алерт на обновление прокси.
  2. Кто отправил. Правило «разрешённые отправители» по событию Upgraded с адресами вашего Safe и деплойера. Срабатывает, если обновление пришло откуда-то ещё, в том числе через другой контракт. Это правило для «обновления, которого никто в команде не ждал».
  3. Что оно изменило. До следующего обновления создайте правила «Состояние контракта» на значения, без которых протокол не живёт: порог голосов, потолок комиссии, число подписантов, paused(). Первая проверка после обновления покажет, уцелели ли числа. Это ровно правило из разбора Ronin: вызов minimumVoteWeight() раз в минуту с условием «ниже 1». Правила по состоянию доступны на Pro и выше.
  4. На паузе ли контракт. Правило paused() покажет, включён ли собственный тормоз команды.

Чего мы не умеем. SmartAlert пока не читает слот ERC-1967 напрямую, поэтому не может сравнить живую реализацию с адресом или хэшем кода, которым вы доверяете. Он видит событие Upgraded, а не слот. Правило по состоянию вызывает только функцию из ABI контракта, так что инвариант без view-функции проверить нельзя. И когда обновление и кража происходят в одной транзакции, алерт приходит вместе с потерей: в Ethereum он идёт через 2,5–4 минуты после блока.

Дальше, часть 4: Мультисиги и Safe: владельцы, порог, модули, delegatecall.

Источники

  1. 1.OpenZeppelin, Proxy Upgrade Pattern. ↩
  2. 2.Документация Solidity, Delegatecall and libraries. ↩
  3. 3.EIP-1967, Proxy Storage Slots. ↩
  4. 4.ERC-1822, Universal Upgradeable Proxy Standard (UUPS). ↩
  5. 5.OpenZeppelin, Writing Upgradeable Contracts. ↩
  6. 6.SmartAlert, шаблоны для прокси ERC-1967 и правило «разрешённые отправители»; полный разбор и проверки в сети — в разборе Ronin. ↩

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

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

Ещё статьи