Небезопасные внешние вызовы: call и delegatecall
Чем call отличается от delegatecall — и почему путаница здесь стоит всего контракта.
delegatecall — низкоуровневый вызов, который исполняет код другого контракта, но в контексте хранилища вызывающего: чужой код пишет в ваш storage, как будто это его собственный.
Три способа дотянуться до чужого кода
call— вызывает функцию другого контракта в его собственном контексте; возвращает управление (опасно для реентранси).staticcall— какcall, но запрещает менять состояние (безопасен для чтения).delegatecall— самый коварный: исполняет чужой код, но над вашими переменными хранилища, вашимmsg.senderи балансом.
Почему delegatecall так опасен
delegatecall — основа proxy-паттернов (об апгрейдах будет отдельный раздел): proxy хранит данные, а логика живёт в другом контракте, который proxy зовёт через delegatecall. Но это значит, что логический контракт пишет прямо в storage proxy. Если адрес логики можно подменить, атакующий подставит свой код и перепишет любое поле — например, переменную владельца — а затем выведет средства. Поэтому адрес цели delegatecall должен быть строго контролируемым и неизменяемым для посторонних.
// Концептуально: delegatecall исполняет код logic
// НО переменные меняются в storage самого proxy
function _delegate(address logic) internal {
// если адрес logic подконтролен атакующему -> катастрофа
(bool ok, ) = logic.delegatecall(msg.data);
require(ok);
}Проблема storage layout (раскладки хранилища)
EVM не знает имён переменных — только слоты хранилища по номерам (слот 0, 1, 2...). При delegatecall чужой код пишет в слоты вашего контракта по своей собственной нумерации. Если раскладки переменных в proxy и в логике не совпадают, запись «уходит не туда»: код думает, что обновляет balance, а на деле перетирает owner. Это storage collision — тихий и разрушительный класс багов.
Proxy storage Logic ожидает
slot0: owner slot0: totalSupply <-- коллизия!
slot1: implementation slot1: owner
...запись по slot0 перетрёт owner вместо totalSupplyКак работает под капотом
Безопасный proxy решает коллизии так: служебные поля (адрес реализации, админ) хранятся не в обычных слотах 0,1,2, а в псевдослучайных слотах, вычисленных как хэш (стандарт EIP-1967). Тогда они почти гарантированно не пересекутся с переменными логики. Кроме того, наследование общего «базового layout» гарантирует одинаковую нумерацию.
Частые ошибки
- delegatecall на неконтролируемый адрес. Эквивалент «выполни произвольный код над моими деньгами».
- Разная раскладка переменных в proxy и реализации. Молчаливая порча данных.
- Не проверять успех низкоуровневого вызова.
call/delegatecallне бросают исключение сами — нуженrequire(ok).
Итоги
callисполняет чужой код в его контексте;delegatecall— чужой код в ВАШЕМ хранилище.- Подменяемый адрес delegatecall = полный захват контракта.
- Storage collision возникает при несовпадении раскладки слотов; защита — EIP-1967 и общий layout.
- Всегда проверяйте успех низкоуровневого вызова через
require.