*Часть 3 из 12 в серии «Безопасность Web3: с нуля». В части 1 мы сказали, что код контракта фиксируется при развёртывании. Многие контракты, которыми вы пользуетесь каждый день, — исключение. Здесь объясняем, как оно устроено.*
У неизменяемого кода есть цена: ошибку, найденную через месяц, не исправить. Поэтому большие протоколы разворачивают не один контракт, а два: прокси, с которым общаются пользователи, и реализацию (implementation) с логикой. Адрес прокси не меняется. Реализация за ним — может1.
Для пользователя это значит, что «проверенный аудитом контракт по этому адресу» верно только до следующего обновления. Для атакующего функция обновления — главный приз во всей системе: кто может её вызвать, тот заменит все правила.
delegatecall: логика в одном месте, состояние в другом
Обычный вызов выполняет код цели на хранилище цели. delegatecall иной: он выполняет код цели на хранилище вызывающего2. Минимальный прокси — это fallback, который пересылает любой вызов:
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:
// адрес реализации
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:
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-функций | Состояние | Сохранило ли новое обновление инварианты |
Upgraded(implementation)AdminChanged(...)Initialized(n)view-функцийПервые три — события. Четвёртое — урок Ronin: обновление объявляет о себе, а потом результат нужно проверить.
Как поймать
Что SmartAlert делает сейчас6:
- Обновление и смена админа. Добавляйте адрес прокси, а не реализации. Для прокси ERC-1967 продукт предлагает шаблоны
proxy-upgradedиproxy-admin-changed, оба critical, с новым адресом реализации или админа в сообщении. Рецепт: Алерт на обновление прокси. - Кто отправил. Правило «разрешённые отправители» по событию
Upgradedс адресами вашего Safe и деплойера. Срабатывает, если обновление пришло откуда-то ещё, в том числе через другой контракт. Это правило для «обновления, которого никто в команде не ждал». - Что оно изменило. До следующего обновления создайте правила «Состояние контракта» на значения, без которых протокол не живёт: порог голосов, потолок комиссии, число подписантов,
paused(). Первая проверка после обновления покажет, уцелели ли числа. Это ровно правило из разбора Ronin: вызовminimumVoteWeight()раз в минуту с условием «ниже 1». Правила по состоянию доступны на Pro и выше. - На паузе ли контракт. Правило
paused()покажет, включён ли собственный тормоз команды.
Чего мы не умеем. SmartAlert пока не читает слот ERC-1967 напрямую, поэтому не может сравнить живую реализацию с адресом или хэшем кода, которым вы доверяете. Он видит событие Upgraded, а не слот. Правило по состоянию вызывает только функцию из ABI контракта, так что инвариант без view-функции проверить нельзя. И когда обновление и кража происходят в одной транзакции, алерт приходит вместе с потерей: в Ethereum он идёт через 2,5–4 минуты после блока.
Дальше, часть 4: Мультисиги и Safe: владельцы, порог, модули, delegatecall.
Источники
- 1.OpenZeppelin, Proxy Upgrade Pattern. ↩
- 2.Документация Solidity, Delegatecall and libraries. ↩
- 3.EIP-1967, Proxy Storage Slots. ↩
- 4.ERC-1822, Universal Upgradeable Proxy Standard (UUPS). ↩
- 5.OpenZeppelin, Writing Upgradeable Contracts. ↩
- 6.SmartAlert, шаблоны для прокси ERC-1967 и правило «разрешённые отправители»; полный разбор и проверки в сети — в разборе Ronin. ↩