Перейти к содержанию

Безопасность Solidity: полномочия выполняемого кода

Оглавление курса «Безопасность: разбор собственного контракта» · Урок 28 из 30.

В предыдущем уроке мы защищали первоначальное назначение publisher. Но правильное назначение не ограничивает произвольный код, который выполняется в контексте реестра. Теперь разберём delegatecall на отдельной локальной модели и покажем, почему неизвестная цель не является безопасным расширением интерфейса. Результатом станет точное описание полномочий такого кода и альтернативная граница для обычного информационного плагина.

Контрпример относится к собственным учебным контрактам, не к чужому адресу. Он не содержит денежных переводов и не запускался. Основной CourseCatalog и обновляемый V1 сохраняются отдельно; опасная функция run не добавляется к ним как новый рекомендуемый метод. Профиль остаётся Solidity 0.8.30/Cancun.

Чей storage изменяется

delegatecall выполняет код другой цели в контексте вызывающего контракта. Это отличается от обычного внешнего call. В частности, записи состояния относятся к вызывающему хранилищу, а контекст отправителя сохраняется по правилам делегирования. Документация Solidity о delegatecall и библиотеках.

Поэтому «модуль не знает имя переменной publisher» не является защитой. Код работает с размещением, а не с человеческими названиями. Если поле модуля соответствует месту издателя хоста, присваивание этому полю меняет административное значение хоста. Интерфейс модуля с невинным названием не ограничивает возможные записи.

Рассмотрим информационную задачу: издатель хочет получить красивое название курса 17 от внешнего модуля. Если задача требует лишь вернуть строку, предоставлять модулю всю власть над storage реестра избыточно. Обычная граница вызова и проверка результата подходят по смыслу лучше, хотя тоже требуют учёта отказов и недостоверных данных.

Отдельный опасный хост

В первой модели publisher занимает обычный slot 0. Вызов run разрешён только ему, что позволяет отдельно увидеть ограничение: авторизация вызывающего не делает выбранный код безопасным. Издатель может выполнить опасную цель по ошибочному совету интерфейса.

pragma solidity 0.8.30;

contract UnsafeDelegateHost {
    address public publisher;
    error Unauthorized();
    error ModuleFailed();

    constructor(address publisher_) {
        require(publisher_ != address(0), "publisher required");
        publisher = publisher_;
    }

    function run(address target, bytes calldata data) external returns (bytes memory) {
        if (msg.sender != publisher) revert Unauthorized();
        (bool ok, bytes memory result) = target.delegatecall(data);
        if (!ok) revert ModuleFailed();
        return result;
    }
}

contract LocalModuleCounterexample {
    address public slotZero;

    function overwrite(address nextPublisher) external {
        slotZero = nextPublisher;
    }
}

При обычном вызове overwrite меняет slotZero самого модуля. При делегированном вызове через UnsafeDelegateHost тот же код записывает slot 0 хоста, где находится publisher. Называть переменную slotZero или labelCache не имеет значения. Важно совпадение области и размещения.

Предполагаемый сценарий: текущий издатель вызывает run с адресом собственного LocalModuleCounterexample и данными overwrite выбранного другого адреса. После успешного исполнения getter publisher хоста должен вернуть выбранный адрес. Сам slotZero модуля от этого делегированного пути не меняется. Это ожидаемый разбор исходника, а не выполненная операция или совет воздействовать на чужую систему.

Почему onlyPublisher недостаточно

Модификатор проверил, кто передал управление, но не установил границу получателя этого управления. Код модуля может содержать не только показанное присваивание. Он способен выполнять другие разрешённые EVM-действия в контексте хоста. Отсутствие денег в нашей модели ограничивает последствия примера, но не превращает delegatecall в универсальную песочницу.

Если разрешить run любому адресату, появится дополнительная проблема: посторонний сам выберет цель. В этом листинге такой путь закрыт, однако основной вывод остаётся: даже административный выбор цели требует полного доверия к её коду и совместимости состояния. Простое target.code.length больше нуля не даёт этих свойств.

Не обещаем, что allowlist адресов навсегда фиксирует поведение. Цель может быть сама обновляемой, зависеть от внешних компонентов или иметь другой механизм изменения. Если проект использует такое разрешение, его договор описывает не только адрес, но и фактическую модель контроля кода. Проверка имени функции тоже не ограничивает скрытые эффекты её реализации.

Отдельный интерфейс для названия

Если расширение только сообщает строку, можно задать обычный информационный интерфейс. Ниже приведено проектирование границы, а не полностью защищённый механизм плагинов:

pragma solidity 0.8.30;

interface ICourseLabelProvider {
    function label(uint256 courseId) external view returns (string memory);
}

Реестр или веб-приложение читает результат отдельным вызовом и решает, можно ли его использовать. View-интерфейс не позволяет произвольно менять storage хоста через делегирование, потому что такой путь вообще не предоставлен. Но источник строки остаётся недоверенным: он может вернуть неверное название, большой ответ или отказать. Нужно определить пределы, обработку ошибки и происхождение материала.

Для ProfessorWeb название лучше получать из манифеста, чей SHA-256 соответствует contentHash точных сохранённых байтов. Это прикладной договор материала, не keccak от строки и не обрезанный CID. Даже проверенный манифест не получает право менять publisher: проверка содержимого и административные полномочия относятся к разным границам.

Proxy является осознанным делегированием

Обновляемая модель из уроков 26–27 использует делегирование выбранной реализации, но не считает его безопасным само по себе. Реализация допускается по отдельной процедуре: совместимость состояния, полномочие обновления, инициализация и предметные свойства. Владелец upgrade в итоге способен выбрать код с широкими последствиями.

Поэтому отчёт должен раскрыть доверие к управлению обновлениями. Фраза «используем OpenZeppelin» подтверждает выбранный механизм, а не безусловную безопасность собственной реализации или администратора. Утилита не анализирует за нас каждую логику курса, внешнее взаимодействие и изменение структуры.

Не используем старый пример selfdestruct как утверждение, что в профиле Cancun любой код всегда удалит чужой контракт. Семантика изменилась и зависит от условий создания. Здесь контрпример опирается на обычную запись storage, поэтому не нуждается в таком неверном объяснении. Изменение поведения SELFDESTRUCT в EIP-6780.

Ожидаемый вывод

Для обычного overwrite состояние меняется у модуля. Для delegatecall оно меняется у хоста. Проверка роли инициатора не сдерживает полномочия выбранного кода. Информационный источник не требует делегированного выполнения, а proxy-обновление требует отдельного договора доверия.

Мы получили точную причину опасности и собственный небольшой контрпример для будущего регрессионного сценария. Дальше рассмотрим реагирование, если такое или другое нарушение обнаружено: остановка действий должна ограничивать ущерб, сохраняя достоверную историю и понятное сообщение читателям.

Возвращённые данные не оправдывают эффект

Модуль может вернуть ожидаемую строку после нежелательной записи. Поэтому корректный return не является доказательством отсутствия иных изменений. У контрпримера результат run вообще может быть пустым, а административное состояние уже изменено. При делегированном выполнении анализируется весь разрешённый код и его эффекты, а не только декодированный ответ.

Оглавление серии