Безопасность Solidity: путь первоначального назначения прав
Оглавление курса «Безопасность: разбор собственного контракта» · Урок 27 из 30.
В прошлой главе появился самостоятельный UpgradeableCourseCatalogV1 с обычным publisher в storage. Теперь разберём, как этому полю впервые назначается правильный владелец. Результатом урока станет атомарный путь создания прокси и отдельный договор второй инициализации. Он устраняет окно, в котором новый экземпляр существует, но любой участник ещё может назначить себе административное право.
В статье сохраняется профиль Solidity 0.8.30, Cancun и OpenZeppelin Contracts 5.4.0. Исходники не компилировались, зависимости не устанавливались, контракты не создавались. Адреса не выдумываются. Базовый CourseCatalog из начала серии остаётся неизменяемой отдельной моделью и не получает initialize задним числом.
Два хранилища, два результата
Конструктор реализации выполняется при создании реализации и относится к её собственному состоянию. Позднее прокси вызывает код в своём контексте. Поэтому присваивание publisher в обычном конструкторе реализации не заполняет соответствующее поле прокси. Сайт должен читать нужный адрес и понимать, какой экземпляр он наблюдает.
В нашем V1 конструктор вызывает _disableInitializers. Это закрывает путь первоначального назначения прав для самой реализации. Но такое действие не инициализирует прокси: у него отдельная область состояния Initializable. Прокси получает publisher через свой вызов initialize в делегированном контексте. Эти обязанности дополняют друг друга и не заменяются одной общей проверкой «контракт настроен».
Поведение modifiers initializer и reinitializer сверено по исходнику Initializable версии 5.4.0. Здесь используем штатный путь с одним назначением при создании, а не строим произвольные вложенные сценарии инициализации.
Опасное окно между транзакциями
Рассмотрим ошибочный план: сначала создать прокси с пустыми данными, затем отдельной транзакцией вызвать initialize с адресом издателя. Между этими действиями состояние ещё допускает первоначальный вызов. Другой участник может успеть первым. Правильность предполагаемой второй транзакции не устраняет существование окна.
Добавление проверки publisher равного нулю не решает проблему первого вызывающего. Оно лишь говорит, что назначение ещё не произошло. Если внешняя функция позволяет любому участнику выбрать publisher_ в этот момент, атакующий получает то же допустимое действие. Нужно связать создание и требуемое назначение в одном атомарном пути.
Не полагаемся на невидимость адреса или скорость интерфейса. Техническая операция должна быть корректна независимо от того, насколько быстро браузер отправил следующий запрос. Атомарность в этом случае является свойством маршрута создания, а не обещанием пользователя «сразу нажать вторую кнопку».
Фабрика с явным издателем
Создадим собственную минимальную фабрику, которая формирует данные initialize заранее. Она создаёт отдельную реализацию и прокси в одном вызове. Это учебный исходник для анализа; расходы и события действительного развёртывания не измерены.
pragma solidity 0.8.30;
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import "./UpgradeableCourseCatalogV1.sol";
contract TeachingCatalogFactory {
event CatalogCreated(address indexed proxy, address implementation, address publisher);
function create(address publisher_) external returns (address proxyAddress) {
require(publisher_ != address(0), "publisher required");
UpgradeableCourseCatalogV1 implementation = new UpgradeableCourseCatalogV1();
bytes memory initData = abi.encodeCall(
UpgradeableCourseCatalogV1.initialize, (publisher_)
);
ERC1967Proxy proxy = new ERC1967Proxy(address(implementation), initData);
proxyAddress = address(proxy);
emit CatalogCreated(proxyAddress, address(implementation), publisher_);
}
}
ERC1967Proxy принимает исходные данные настройки при создании. Точная реализация конструктора находится в исходнике OpenZeppelin 5.4.0. Если инициализация завершается ошибкой, создание в этом маршруте не оставляет успешный частично настроенный прокси.
Издатель передан явным аргументом. Если initialize вместо этого использовал бы msg.sender без понимания контекста, при фабричном создании получилось бы право фабрики. Это не обязательно право читателя или преподавателя, который отправил исходную транзакцию. Выбор publisher_ должен соответствовать предметному решению проекта и быть показан перед подписанием.
Фабрика здесь не хранит глобальный указатель «официальный реестр библиотеки». Создание собственного прокси любым участником не делает его реестром ProfessorWeb. Для назначения действующего экземпляра сайт использует отдельный проверенный файл окружения. Событие произвольной фабрики не заменяет эту редакционную связь.
Второй шаг версии
Новая версия V2 добавляет retentionEpoch и initializeV2 с reinitializer(2) и onlyPublisher. Одного version modifier недостаточно для права вызова: он ограничивает повтор, но не выбирает субъект. Поэтому условие publisher сохраняется и проверяется в доверенном состоянии прокси.
Для совместимого профиля UUPS настройка может сопровождать сам переход через upgradeToAndCall. Фрагмент ниже относится к будущему исходнику сценария издателя, не к выполненной операции:
UpgradeableCourseCatalogV2 next = new UpgradeableCourseCatalogV2();
bytes memory setup = abi.encodeCall(UpgradeableCourseCatalogV2.initializeV2, (7));
UpgradeableCourseCatalogV1(proxyAddress).upgradeToAndCall(address(next), setup);
proxyAddress должен поступить из действительного окружения; значение семь — учебная метка, не срок хранения. Если setup отклоняется, выбранный атомарный маршрут не должен считаться успешно завершённым обновлением. После будущего исполнения необходимо перечитать версию и поля, а не доверять одному исчезнувшему окну кошелька.
Первоначальные значения полей
Обычное присваивание storage-поля при объявлении в исходнике не является гарантией заполнения proxy storage. Важные начальные значения ставим в разрешённом initializer. Если появятся stateful родительские модули, их требуемые шаги тоже выполняются согласно выбранному профилю и порядку. Случайное добавление наследования не вызывает нужные parent initializers автоматически.
Также первый вызов не должен делать неизвестные внешние взаимодействия без отдельного анализа. Наш initialize устанавливает только publisher после проверки ненулевого адреса. Это позволяет ясно описать ожидаемое состояние. Добавление сложной регистрации во внешнем сервисе потребует нового договора, который не скрывается внутри привычного имени функции.
Ожидаемые наблюдения
После атомарного создания прокси возвращает заданный publisher, releaseCount равен нулю, courses(17) отсутствует. Прямой initialize реализации отклоняется её заблокированным состоянием. Повтор initialize уже созданного прокси отклоняется по правилам выбранного modifier. Посторонний вызов initializeV2 отклоняется по роли.
В успешном переходе V2 прежний хеш курса 17 и active сохраняются, новый retentionEpoch получает согласованную метку. Эти свойства здесь не проверялись исполнением: они составляют программу будущей приёмки. Полученный маршрут устраняет ошибку первоначального назначения, но не доказывает безопасность любой будущей реализации. В следующем уроке посмотрим, насколько широкие полномочия получает делегированно выполняемый код.
Ошибка аргумента остаётся ошибкой
Атомарность гарантирует отсутствие выбранного промежутка, но не исправляет неверный publisher_, переданный самой фабрике. Если издатель подписал создание с чужим адресом, результат может быть полностью согласован с байтами и одновременно не соответствовать его намерению. Поэтому форма подготовки показывает адрес и окружение до подтверждения.
В будущем отчёте отдельно проверяются источник аргумента и маршрут инициализации. Успешное создание не доказывает правильность редакционного выбора официального экземпляра сайта.