Небезопасные внешние вызовы: 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.
Проверьте себя
1. Чем delegatecall принципиально отличается от call?
AОн быстрее
BИсполняет чужой код, но в контексте хранилища вызывающего
CОн не тратит газ
DОн запрещает менять состояние
2. Что такое storage collision в proxy-паттерне?
AНехватка места на диске
BНесовпадение раскладки слотов: запись уходит не в ту переменную
CКонфликт имён функций
DПереполнение газа
3. Как безопасные proxy избегают коллизии служебных полей?
AХранят их в слотах 0,1,2
BХранят их в псевдослучайных слотах по стандарту EIP-1967
CШифруют storage
DЗапрещают delegatecall