Аудит обновляемых смарт-контрактов
Обновляемые (upgradeable) контракты — компромисс. С одной стороны, возможность исправить баги и добавить функциональность без миграции. С другой — полный отказ от immutability гарантий, которые делают blockchain безопасным. Любой апгрейд — это потенциальная точка атаки: если злоумышленник получил контроль над upgrade механизмом, он может заменить логику на произвольный код и вывести все средства. Именно поэтому аудит upgradeable контрактов сложнее и длиннее аудита обычных.
Compound, Aave, Uniswap V3 используют тот или иной вариант upgradeability. Но они же вложили сотни тысяч долларов в аудит именно upgrade механизмов.
Паттерны обновляемости и их уязвимости
Transparent Proxy (EIP-1967)
Самый распространённый паттерн от OpenZeppelin. Proxy хранит адрес implementation в deterministic storage slot. Вызовы делегируются в implementation через delegatecall.
User → Proxy (storage) → delegatecall → Implementation (logic)
Критическая уязвимость: storage collision. Proxy использует слот 0x360894... для адреса implementation. Если implementation случайно использует тот же слот (или слот admin, или initializer flag) — перезапись при upgrade может сломать весь контракт.
// Опасный паттерн: переменные в Implementation
contract VulnerableImpl {
// Слот 0 в storage
address public owner; // КОЛЛИЗИЯ с proxy admin слотом!
// Если Proxy хранит admin в слоте 0 — owner в implementation
// будет читать/писать адрес admin proxy
}
// Правильный паттерн: использовать namespaced storage (EIP-7201)
// или убедиться что impl начинается с правильного смещения
contract SafeImpl {
// При использовании OpenZeppelin TransparentUpgradeableProxy
// implementation НЕ должна иметь переменных в слотах,
// зарезервированных proxy (0x360894..., 0xb53127...)
// OpenZeppelin Upgrades Hardhat plugin проверяет это автоматически
}
UUPS (Universal Upgradeable Proxy Standard, EIP-1822)
Upgrade логика находится в implementation, не в proxy. Proxy проще и дешевле в деплое.
Уязвимость UUPS: selfdestruct в uninitialized implementation. Если implementation контракт задеплоен и не инициализирован, злоумышленник может вызвать initialize напрямую на implementation (не через proxy), стать owner-ом, и вызвать upgradeTo(maliciousContract) с selfdestruct. После selfdestruct implementation — proxy становится нефункциональным навсегда.
Именно это произошло с несколькими протоколами, пока OpenZeppelin не добавил _disableInitializers() в конструктор implementation:
contract MyContractV1 is UUPSUpgradeable, OwnableUpgradeable {
constructor() {
_disableInitializers(); // ОБЯЗАТЕЛЬНО в каждом implementation
}
function initialize(address initialOwner) public initializer {
__Ownable_init(initialOwner);
__UUPSUpgradeable_init();
}
function _authorizeUpgrade(address newImplementation)
internal override onlyOwner {}
}
Beacon Proxy
Один Beacon хранит адрес implementation, множество Proxy смотрят на этот Beacon. Один upgrade обновляет все proxy одновременно. Используется когда нужно задеплоить сотни одинаковых контрактов (например, vault per user).
Уязвимость: centralization Beacon. Кто контролирует Beacon — контролирует все proxy. Если Beacon owner — EOA или слабо защищённый мультисиг, это катастрофический single point of failure.
Что проверяем при аудите
Инициализация
Самая частая ошибка upgradeable контрактов — неправильная инициализация:
// НЕПРАВИЛЬНО: конструктор в upgradeable контракте не работает
contract BrokenUpgradeable {
address public owner;
constructor() {
owner = msg.sender; // НЕ будет вызван при деплое через proxy!
}
}
// ПРАВИЛЬНО: initializer функция
contract CorrectUpgradeable is Initializable, OwnableUpgradeable {
function initialize() public initializer {
__Ownable_init(msg.sender);
}
}
Checklist инициализации:
- Все parent contracts инициализированы через
__Parent_init()паттерн -
initializermodifier присутствует (предотвращает повторный вызов) -
_disableInitializers()в конструкторе - Все state переменные, которые должны быть инициализированы при деплое, покрыты
Storage layout compatibility
При upgrade новая implementation должна иметь точно такой же storage layout начиная со слота 0. Добавление новых переменных — только в конец. Переупорядочивание, удаление, изменение типов — storage collision и corruption данных.
// V1
contract MyContractV1 {
uint256 public value; // слот 0
address public owner; // слот 1
}
// V2 ПРАВИЛЬНО: добавляем в конец
contract MyContractV2 {
uint256 public value; // слот 0 — не изменён
address public owner; // слот 1 — не изменён
uint256 public newValue; // слот 2 — новый
}
// V2 НЕПРАВИЛЬНО: вставка в середину
contract MyContractV2_BROKEN {
uint256 public newValue; // слот 0 — КОЛЛИЗИЯ с value!
uint256 public value; // слот 1 — был в слоте 0!
address public owner; // слот 2 — был в слоте 1!
}
Инструменты: @openzeppelin/upgrades-core проверяет совместимость storage layout автоматически. В аудите — ручная сверка storage slots через forge inspect ContractName storage-layout.
Access control upgrade функций
Кто может вызвать upgrade? Это центральный вопрос аудита:
- EOA: недопустимо для production. Один скомпрометированный ключ = потеря всего.
- Gnosis Safe мультисиг: приемлемо, требует M-of-N подписей.
- Timelock: лучшая практика. Upgrade в очереди 48-72 часа, сообщество может отреагировать.
- Governor + Timelock: максимальная защита через on-chain голосование.
// Аудит проверяет: есть ли timelock перед upgrade?
function _authorizeUpgrade(address newImplementation)
internal override
{
// ПЛОХО: только onlyOwner без timelock
// require(msg.sender == owner, "Not owner");
// ХОРОШО: только через timelock (= через governance голосование)
require(msg.sender == address(timelock), "Only timelock");
}
Invariant preservation
После каждого upgrade проверяем: не нарушены ли инварианты контракта? Если до upgrade totalSupply == sum(balances), это должно быть истиной после upgrade.
// Тест совместимости V1 → V2
contract UpgradeTest is Test {
function testUpgradePreservesState() public {
// Деплоим V1 через proxy, устанавливаем state
MyContractV1 v1 = deployV1();
v1.setValue(42);
v1.deposit{value: 1 ether}();
// Делаем upgrade до V2
upgradeTo(address(new MyContractV2()));
// Проверяем что state сохранён
MyContractV2 v2 = MyContractV2(address(proxy));
assertEq(v2.value(), 42);
assertEq(address(proxy).balance, 1 ether);
// Проверяем новую функциональность V2
v2.newFunction();
}
}
Функциональные изменения при upgrade
Новая implementation может изменить поведение существующих функций. Аудит проверяет: нет ли изменений, которые ломают существующие пользовательские ожидания или обходят защитные механизмы.
Пример: V1 имеет limit на withdraw (не более 10 ETH за транзакцию). V2 убирает этот limit — возможно намеренно, но аудитор должен явно это подтвердить.
Специфические векторы атак
Upgrade + reentrancy
Во время выполнения upgrade транзакции (особенно если в upgrade логике есть внешние вызовы) возможна reentrancy. Контракт в промежуточном состоянии (между старой и новой логикой) уязвим.
Delegatecall injection
Если implementation содержит функцию с произвольным delegatecall к внешнему адресу — злоумышленник может использовать это для выполнения кода в контексте proxy (и его storage).
// КРИТИЧЕСКИ ОПАСНО в upgradeable контракте
function execute(address target, bytes calldata data) external onlyOwner {
target.delegatecall(data); // Атакующий owner = полный контроль над proxy storage
}
Selfdestruct в implementation (EIP-6780 частично смягчает)
EIP-6780 (Dencun upgrade, март 2024) изменил поведение selfdestruct: теперь selfdestruct в той же транзакции что и CREATE работает как раньше, но вызванный в отдельной транзакции — только переводит ETH, не удаляет код. Для UUPS уязвимости это частичное смягчение, но не полное решение.
Инструменты аудита upgradeable контрактов
| Инструмент | Назначение |
|---|---|
@openzeppelin/hardhat-upgrades |
Автоматическая проверка storage layout |
forge inspect ... storage-layout |
Ручной анализ слотов |
| Slither | Статический анализ, включая upgrade паттерны |
| Echidna / Medusa | Fuzzing на invariant preservation |
| Foundry fork tests | Тесты upgrade на mainnet fork |
| Tenderly | Симуляция upgrade транзакций |
Процесс аудита
Фаза 1: Статический анализ (3-5 дней).
Slither + ручной анализ proxy паттерна. Карта storage слотов V1, проверка _disableInitializers, access control upgrade функций.
Фаза 2: Storage compatibility (2-3 дня). Сравнение storage layouts V1 и V2. Для каждой переменной: слот, тип, offset. Особое внимание на mapping и dynamic arrays (их layout нетривиален).
Фаза 3: Функциональные изменения (3-5 дней). Построение diff между V1 и V2 на уровне функций. Для каждого изменения: намеренно ли, не нарушает ли инварианты, нет ли новых векторов атак.
Фаза 4: Upgrade механизм (2-3 дня). Полный lifecycle upgrade: кто инициирует → timelock → execution → rollback возможность (есть ли она?).
Фаза 5: Тестирование (5-7 дней). Fork tests на Foundry. Fuzzing критичных функций. Ручные тесты edge cases.
Отчёт и ремедиация (3-5 дней). Документирование findings, severity classification (Critical/High/Medium/Low/Informational), рекомендации.
Полный аудит upgradeable контракта среднего размера: 3-5 недель. Стоимость — после оценки объёма кода и сложности upgrade механизма.







