Риски DELEGATECALL: прокси, подмена логики и контракты-библиотеки

DELEGATECALL — опкод EVM, который позволяет контракту выполнять код другого адреса, но в своём контексте: с собственной памятью, storage и балансом, при этом msg.sender и msg.value остаются исходными. Это мощный инструмент для прокси-паттернов и библиотек, но неверное использование DELEGATECALL часто приводит к критическим уязвимостям и краже средств.

Риски DELEGATECALL: прокси, подмена логики и контракты-библиотеки

Как работает DELEGATECALL

На уровне EVM разница между CALL и DELEGATECALL:

  • CALL:
    • выполняет код другого контракта в его собственном хранилище;
    • у вызываемого контракта свои address, storage, balance;
    • msg.sender — адрес вызывающего контракта.
  • DELEGATECALL:
    • берёт код *целевого* контракта, но исполняет его так, как будто это код вызывающего контракта;
    • storage, адрес и баланс — у вызывающего;
    • msg.sender и msg.value наследуются от исходного вызова.

Результат: чужой код получает полный доступ к хранилищу вызывающего контракта и видит того же msg.sender, что и оригинальный вызов.

В Solidity DELEGATECALL часто скрыт внутри:

  • прокси-контрактов (Transparent, UUPS, Beacon, Diamond);
  • «ручных» вызовов вида:
    • (bool ok, bytes memory res) = target.delegatecall(data);

Где используется DELEGATECALL

  • Прокси и апгрейдируемые контракты
    • Прокси хранит состояние (балансы, параметры протокола) в своём storage.
    • Логика вынесена в «implementation»/«logic»-контракт.
    • Все внешние вызовы через fallback делегируются в текущую реализацию через DELEGATECALL.
    • Апгрейд = смена адреса реализации в storage прокси.
  • Контракты-библиотеки
    • До появления ключевого слова library в Solidity логика иногда выносилась в отдельные «библиотеки», вызываемые через DELEGATECALL.
    • Код библиотеки при этом работал с хранилищем вызывающего контракта.
  • Модульные/плагинные архитектуры
    • Система разбита на модули: каждый селектор функции (4 байта — см. function selector) делегируется в свой модуль через DELEGATECALL.
    • Удобно, но сильно повышает требования к безопасности.

Основные риски DELEGATECALL

Чужой код в вашем storage

Главный риск: любой код, на который вы делегируете, получает полный доступ к вашему хранилищу.

Злоумышленник может:

  • переписать балансы и лимиты;
  • сменить роли администратора;
  • перенаправить токены и ETH на свои адреса;
  • отключить функции протокола или «заблокировать» контракт.

Для атаки достаточно:

  • подменить адрес реализации (implementation) в прокси;
  • или заставить контракт вызвать delegatecall на злоумышленный адрес (если он контролируется пользователем).

Коллизии и несовместимость storage layout

При DELEGATECALL логика и данные физически хранятся в одном storage (у прокси), но с разными компиляциями:

  • если обновить реализацию и изменить порядок/тип переменных, те же слоты storage начнут означать другую структуру;
  • записи в новые переменные могут случайно затирать критичные данные (админа, адрес реализации, баланс и т.п.).

Это проблема storage layout. Подробно она разбирается на страницах Storage layout в Solidity и Памятка по storage-layout.

Пользовательский контроль адреса для delegatecall

Классический антипаттерн:

  solidity
  function exec(address target, bytes calldata data) external {
  // Плохой пример — нельзя делегировать на произвольный target
  (bool ok, ) = target.delegatecall(data);
  require(ok, "delegatecall failed");
  }

Если злоумышленник может выбрать target, он получает:

  • Элемент ненумерованного спискавыполнение своего кода в storage контракта;
  • Элемент ненумерованного спискаполный контроль над состоянием контракта (как минимум — в рамках вызова).

Даже если target берётся из storage или реестра, важно, кто и как может его туда записать.

Reentrancy через DELEGATECALL

DELEGATECALL часто комбинируется с уязвимостями типа reentrancy:

  • прокси вызывает реализацию через DELEGATECALL;
  • реализация во время выполнения может:
  • снова вызвать прокси;
  • или вызвать другие функции, использующие то же хранилище;
  • если порядок «проверки → эффекты → взаимодействия» нарушен, возникает сложная многошаговая реэнтрантность.

Это особенно опасно в DeFi-протоколах с кредитами, залогом и ликвидациями.

SELFDESTRUCT и жизненный цикл реализации

Если реализация содержит SELFDESTRUCT:

  • она может удалить свой код с адреса (с учётом изменений после EIP-6780, поведение на L1 меняется, но паттерн всё ещё важен — см. SELFDESTRUCT после EIP-6780);
  • прокси будет продолжать пытаться делегировать на этот адрес, что может:
  • превратить все вызовы в no-op;
  • или открыть путь к повторному деплою по тому же адресу (например, через CREATE2 в других сетях/условиях).

Ошибки в админской логике апгрейдов

Upgradable-прокси часто имеют функции вроде:

  function upgradeTo(address newImplementation) external onlyAdmin {
    implementation = newImplementation;
  }

Риски:

  • уязвимость в onlyAdmin (ошибка в ролях, неправильно настроенный мультисиг);
  • social-engineering или ключи администратора украдены;
  • пропуск проверки, что newImplementation вообще контракт с ожидаемым интерфейсом.

В результате админ или атакующий админ может:

  • подменить реализацию на контракт-эксплойт;
  • через DELEGATECALL мгновенно вывести все активы.

Типичные уязвимые паттерны

Прокси с неконтролируемым implementation Функции вида setImplementation(address) доступны слишком широкому кругу лиц или обёрнуты слабой логикой голосования.

  • execute/exec, использующие delegatecall вместо call

Мультисиги и DAO-кошельки иногда реализуют «универсальный вызов» с помощью DELEGATECALL. Это даёт подписантам возможность менять storage самого кошелька, а не просто управлять внешними контрактами.

Плагинные системы без whitelist’а Если контракт позволяет добавлять новые «модули» с логикой без жёсткой проверки и аудита, любой такой модуль может стать входной точкой для атаки через DELEGATECALL.

Практики безопасного использования DELEGATECALL

Никогда не делегировать на адрес, контролируемый пользователем

  • target для DELEGATECALL должен быть жёстко зафиксирован или происходить из строго контролируемого хранилища.

Никаких «произвольных delegatecall по выбору пользователя».

Жёсткая модель апгрейдов

  • использовать проверенные паттерны (Transparent/UUPS, слоты EIP-1967 и т.п.);
  • ограничивать апгрейды мультисигом и timelock’ом;
  • иметь off-chain-процессы ревью и аудита каждой новой реализации.

Аккуратное управление storage layout

  • фиксировать и документировать порядок переменных;
  • добавлять новые переменные только в конец;
  • использовать отдельные слоты для критичных метаданных (адрес реализации, админ и т.д.).

Минимизировать поверхность DELEGATECALL

  • по возможности использовать call для взаимодействия с внешними протоколами (storage останется у них);
  • DELEGATECALL оставлять только для строго необходимых прокси-паттернов и внутренних библиотек.

Тесты и аудит

  • писать тесты на апгрейды и миграции;
  • проверять, какие функции в итоге могут вызвать DELEGATECALL и кто управляет их параметрами;
  • при аудите явно подсвечивать все места использования DELEGATECALL и анализировать каждое.

См. также

Task Runner