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