Безопасность контрактов: reentrancy, переполнения, оракулы, аудит
В обычном бэкенде безопасность — дисциплина снижения вероятности: нашли дыру, выкатили патч, заплатили репутацией. В смарт-контрактах вероятность равна единице, а цена ошибки — всему балансу контракта, потому что:
- Код публичен и исполняем всеми. Байткод лежит в состоянии сети, любой декомпилирует его и вызовет любую внешнюю функцию. Нет фаервола, нет rate-limit, нет «внутренней сети».
- Атакующий — программа, а не человек с curl. Он пишет контракт на двадцать вызовов в одной транзакции, отлаживает атаку на форке мейннета, а в сеть отправляет только успешный вариант; неудачную попытку ревертит и платит лишь за газ.
- Капитал не барьер. Флеш-займ выдаёт сотни миллионов долларов без залога внутри одной транзакции, если они возвращаются к её концу. «У него не хватит денег сдвинуть цену» — неверная посылка.
- Откатить нельзя, а награда измеряется в TVL: любой прохожий имеет финансовый стимул читать ваш код внимательнее вас.
Мы уже построили контракт, покрыли тестами и вывели в сеть. Здесь — про то, что с ним сделают недобросовестные вызывающие. Почти каждая уязвимость ниже не «плохая практика», а прямое следствие того, как устроены вызовы, storage и газ в EVM. Речь только про инженерию: механизмы отказа и способы их закрыть, без оценок «надёжен ли протокол как вложение».
1. Модель угроз: что именно вы защищаете
Первый артефакт проекта — не код, а документ на две страницы: что является активом, кто и что может сделать, от чего защищаемся и от чего сознательно не защищаемся. Без него аудит превращается в поиск опечаток.
Собственный код (кольцо 1) — единственное, что покрывается юнит-тестами и статическим анализом. Кольца 2 и 3 закрываются не тестами, а архитектурой: валидацией входов, лимитами, заранее спроектированной деградацией при отказе зависимости.
Отраслевая память в датах: The DAO (2016, reentrancy, ~3.6 млн ETH, раскол сети на ETH и ETC); Parity multisig
(2017, незащищённый initWallet и selfdestruct библиотеки, ~513 тыс. ETH заморожены навсегда); bZx и Harvest
(2020, флеш-займ двигает спот-цену); Lendf.Me (2020, хук ERC-777); Beanstalk и Nomad (2022, флеш-займ
в голосовании и нулевой корень после апгрейда); Euler и Curve (2023, отсутствующая проверка инварианта и баг
замка в компиляторе Vyper). Последний пункт важнее прочих: Euler прошёл несколько независимых аудитов,
а пулы Curve пострадали не из-за своего кода. Безопасность — процесс из независимых слоёв, а не одноразовая
проверка.
2. Reentrancy: механика повторного входа
Отправка эфира в EVM — это не запись в таблицу балансов, а вызов кода получателя. Опкод CALL
с ненулевым value передаёт управление получателю, тот исполняет receive/fallback и может сделать что
угодно, включая обратный вызов к вам. Ваш фрейм никуда не делся: он ждёт возврата, а всё, что вы ещё
не записали в storage, остаётся старым.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
/// @title УЯЗВИМЫЙ контракт. Только для демонстрации, не копировать.
contract VulnerableVault {
mapping(address => uint256) public balanceOf;
function deposit() external payable { balanceOf[msg.sender] += msg.value; }
function withdraw() external {
uint256 amount = balanceOf[msg.sender]; // CHECK
require(amount > 0, "nothing to withdraw");
(bool ok, ) = msg.sender.call{value: amount}(""); // INTERACTION — управление уходит
require(ok, "transfer failed");
balanceOf[msg.sender] = 0; // EFFECT — слишком поздно
}
}
contract Reenter {
VulnerableVault private immutable vault;
constructor(VulnerableVault v) payable { vault = v; }
function attack() external { vault.deposit{value: 1 ether}(); vault.withdraw(); }
receive() external payable { // рекурсируем, пока есть что забирать
if (address(vault).balance >= 1 ether) vault.withdraw();
}
}
Тест в Foundry занимает двадцать строк: положить 10 ETH от честного вкладчика, вызвать attacker.attack(),
утверждать assertEq(address(vault).balance, 0). Ещё полезнее сформулировать это как инвариант
платёжеспособности (раздел 9) — тогда фаззер найдёт и сценарии, которые вы не придумали.
Разновидностей четыре, а не одна:
| Вид | Механика | Чем закрывается |
|---|---|---|
| Одиночная функция | повторный вход в ту же функцию | CEI + nonReentrant |
| Cross-function | вход в другую функцию с тем же состоянием (withdraw → transfer) |
общий замок на группу функций |
| Cross-contract | два ваших контракта делят состояние, замок стоит в одном | замок на уровне общего хранилища |
| Read-only | атакующий не пишет, а читает view при несогласованном состоянии |
не доверять чужим view внутри коллбэка |
Read-only reentrancy — самый недооценённый вариант, и ваш nonReentrant от него не спасает. Пул Curve при
выводе ликвидности сначала отправляет ETH и только потом правит резервы; в момент коллбэка
get_virtual_price() возвращает заниженное значение, а кредитный протокол, читающий его как цену залога,
выдаёт заём не по средствам — так обнесли Sentiment в 2023-м. В вашем коде ошибки при этом нет: вы прочитали
чужое состояние, пока оно несогласованно.
Reentrancy через токен. ERC-777 определяет хук tokensReceived, ERC-721 — onERC721Received, ERC-1155 —
onERC1155Received: любой safeTransfer вызывает код получателя, и _safeMint до обновления счётчика даёт
получателю намитить лишнего. Так упали Lendf.Me и Cream Finance: код писался под «обычный ERC-20», а токен
оказался с хуком.
Защита. Checks-Effects-Interactions — полное решение для одиночного и cross-function случая:
function withdraw() external {
uint256 amount = balanceOf[msg.sender]; // CHECKS
require(amount > 0, "nothing to withdraw");
balanceOf[msg.sender] = 0; // EFFECTS — запись до вызова
(bool ok, ) = msg.sender.call{value: amount}(""); // INTERACTIONS — последним
require(ok, "transfer failed");
}
Замок — вторая линия, на случай если CEI где-то нарушен: флаг ставится в начале функции, снимается в конце,
повторный вход ревертит. С EIP-1153 (Cancun, 2024) он подешевел — transient storage (tstore/tload) живёт
до конца транзакции и не платит за SSTORE; в Solidity 0.8.28+ это пишется как bool transient private _locked;,
а готовые реализации есть в OpenZeppelin (ReentrancyGuard, ReentrancyGuardTransient).
Совет «используйте transfer, 2300 газа не хватит на атаку» устарел и вреден: EIP-1884 поднял цену
SLOAD, и честные контракты-получатели (мультисиги) перестали влезать в стипендию — выплаты им сломались.
Правильный ответ — call с полным газом плюс корректный CEI. Ещё лучше — pull-паттерн: контракт
не отправляет деньги, а записывает право на вывод, получатель забирает сам, и внешний вызов исчезает
из бизнес-логики.
3. Арифметика: переполнение, обрезание, округление
До Solidity 0.8.0 uint256 переполнялся молча по модулю 2^256 — на этом строился класс атак вроде
batchOverflow в токенах 2018 года. С 0.8.0 компилятор вставляет проверки и ревертит с Panic(0x11);
SafeMath не нужен. Остались три живые дыры.
1. unchecked не по делу. Блок отключает проверки ради 30–40 газа на операцию и оправдан только там,
где переполнение невозможно доказуемо:
require(balance >= amount, "insufficient");
unchecked { balance -= amount; } // корректно: проверка прямо перед вычитанием
unchecked { shares = amount * totalSupply / totalAssets; } // ОПАСНО: значения приходят снаружи
2. Обрезание при явном приведении типа. Компилятор проверяет арифметику, но не проверяет downcast —
uint128(2**128) молча даёт 0, без реверта. Лечение — SafeCast.toUint128(). Ошибка выглядит безобидно
и живёт в коде, «упаковывающем поля в один слот ради газа», то есть ровно в коде учёта денег.
3. Порядок операций и округление. Целочисленное деление отбрасывает остаток, поэтому a / b * c
и a * c / b — разные числа. Умножать надо первым, а при риске переполнения промежуточного результата брать
Math.mulDiv. Направление округления — правило безопасности: каждое округление делается в пользу
протокола, иначе появляется цикл «внести 1 wei, снять 2 wei», который бот выполнит миллион раз.
uint256 shares = amount / totalAssets * totalSupply; // НЕВЕРНО: при amount < totalAssets это 0
shares = Math.mulDiv(assets, totalShares, totalAssets, Math.Rounding.Floor); // выпуск долей — вниз
debt = Math.mulDiv(shares, totalAssets, totalShares, Math.Rounding.Ceil); // погашение долга — вверх
Inflation attack: округление как оружие. Хрестоматийный сюжет для пустого хранилища ERC-4626. Атакующий
вносит 1 wei и получает 1 долю; напрямую переводит на адрес контракта 10 000 токенов — доли не выпускаются,
но totalAssets растёт; жертва вносит 10 000 токенов и получает 10000e18 * 1 / (10000e18 + 1) = 0 долей;
атакующий гасит единственную долю и забирает всё. Уязвимость не в арифметике, а в предположении «баланс
контракта равен учтённым активам». Защита: внутренний учёт вместо balanceOf(address(this)), виртуальные доли
с decimals offset (так делает OpenZeppelin ERC4626) либо «мёртвый» первый депозит. То же предположение ломает
насильная отправка эфира: selfdestruct (после EIP-6780 всё ещё пересылает баланс) и выплата блока
увеличивают address(this).balance без вызова вашего кода — не пишите require(address(this).balance == x).
И следите за единицами: USDC и USDT — 6 знаков, WBTC — 8, большинство ERC-20 — 18; смешение даёт ошибку
в 10^12 раз. Все внутренние расчёты — в одной нормализованной единице, конвертация строго на границе.
4. Контроль доступа: половина реальных потерь
- Забытый модификатор. Функция, меняющая критичное состояние, объявлена
externalбезonlyOwner. Slither ловит мгновенно, человек — не всегда. tx.originвместоmsg.sender.tx.origin— это EOA, начавший транзакцию, поэтомуrequire(tx.origin == owner)пройдёт для вредоносного контракта, который владелец просто вызвал. Для авторизации не используется никогда. Обратный приёмrequire(msg.sender == tx.origin)тоже плох: ломает смарт-кошельки и account abstraction, а атакующего останавливает лишь до вызова из конструктора.- Незащищённая инициализация. Прокси не исполняет конструктор реализации, поля настраивает
initialize(); если она не защищена и вызвана отдельной транзакцией — вас опередят. Так умер Parity multisig: библиотека была не инициализирована, кто угодно стал её владельцем и вызвалselfdestruct, заморозив 513 тыс. ETH. Лечение: модификаторinitializer,_disableInitializers()в конструкторе, атомарные деплой и инициализация. delegatecallв изменяемый адрес. Он исполняет чужой байткод в вашем storage, с вашим балансом и вашимmsg.sender. Адрес назначения —immutableлибо меняемый только строго охраняемым путём.- Захват управления флеш-займом. Если голос считается по балансу в текущем блоке — токены занимают,
голосуют и возвращают; так Beanstalk потерял 182 млн USD. Лечение: снапшот силы голоса на прошлом блоке
(
ERC20Votes.getPastVotes) плюс задержка между предложением и исполнением.
Здоровая схема прав держится на асимметрии: действия, ограничивающие протокол (pause, снижение лимитов),
исполняются мгновенно; расширяющие права или трогающие чужие деньги — только через таймлок, чтобы
пользователи успели выйти. Владелец — мультисиг, а не EOA; передача владения двухшаговая (Ownable2Step
не даст отправить контроль на адрес с опечаткой); на каждую административную функцию есть тест
«посторонний не может вызвать».
5. Внешние вызовы, DoS и токены, которые ведут себя не так
call исполняет чужой код с msg.sender = ваш контракт в чужом storage; delegatecall — чужой код
в вашем storage с исходным msg.sender; staticcall ревертит при любой попытке записи. Отсюда ловушки.
Вызов адреса без кода возвращает true. Самая коварная деталь EVM: address.call(data) для пустого
адреса «успешно выполнится» и вернёт success = true с пустыми данными. Вызов токена по адресу, которого
на этой сети нет, будет воспринят как успешный перевод. SafeERC20 проверяет наличие кода; в своих обёртках
пишите require(target.code.length > 0). Рядом — непроверенный результат: (bool ok, ) = target.call(...)
без require(ok) превращает провал в тихий успех, а предупреждение компилятора игнорировать нельзя.
Возврат-бомба. Вызываемый контракт может вернуть мегабайт данных; Solidity скопирует их в память,
и расширение памяти сожжёт весь газ — даже если ответ вам не нужен. Так делают DoS на релееров. Лечится
вызовом через ассемблер с outsize = 0: ok := call(gasLimit, target, 0, add(data, 0x20), mload(data), 0, 0) —
returndata просто не копируется.
Правило 63/64 (EIP-150). Внутреннему вызову передаётся не более 63/64 оставшегося газа, поэтому вызывающий
может подобрать газ так, чтобы ваш подвызов упал по out-of-gas, а внешний фрейм продолжил и записал
«всё хорошо». Если результат важен — проверяйте его и требуйте require(gasleft() > minGas).
| Токен ведёт себя так | Кто | Что ломает | Как жить |
|---|---|---|---|
transfer не возвращает bool |
USDT и старые токены | IERC20.transfer ревертит на декодировании |
SafeERC20.safeTransfer |
| Комиссия при переводе | fee-on-transfer | «отправил 100 — пришло 97» | мерить баланс до и после |
| Ребазинг баланса | stETH, AMPL | сохранённая сумма устаревает | хранить доли, а не суммы |
| Блэклист адресов | USDC, USDT | перевод внезапно ревертит | pull-выплаты, изоляция сбоя |
| Хуки при переводе | ERC-777, ERC-721/1155 | reentrancy | CEI, замок |
Отсюда идиома приёма средств: запомнить token.balanceOf(address(this)), вызвать safeTransferFrom, снова
прочитать баланс — реально полученной считается разность, а не запрошенная сумма. Учёт, построенный
на «сколько просили», у fee-on-transfer токена разъедется в первый же день.
Бесконечная аппрува (type(uint256).max) означает, что компрометация любого одобренного контракта стоит
пользователю всего баланса токена. Плюс историческая гонка ERC-20 при смене ненулевого allowance на другой —
поэтому в OpenZeppelin 5.x убрали increaseAllowance/decreaseAllowance и советуют forceApprove, а на
клиенте — permit (EIP-2612) с точной суммой и дедлайном. Про подписи — в статье
про криптографию, про стандарты — про токены.
Отказ в обслуживании. Деньги не украли, но и забрать нельзя — для протокола это тот же провал.
for (...) payable(users[i]).transfer(amount) падает целиком, если один получатель ревертит; массив,
в который пишет кто угодно, однажды перестанет обрабатываться за газовый лимит блока — это не «медленно»,
это «сломано навсегда». Ответ один: pull вместо push, пагинация, предел размера коллекций, агрегация
в офчейн-индексаторе (см. архитектуру dApp). И если liquidate()
дёргает отключённый оракул — ликвидации встают, протокол копит безнадёжный долг: деградацию проектируют заранее.
6. Оракулы: контракт слеп и глух
Контракт не может сделать HTTP-запрос — недетерминизм разрушил бы консенсус. Внешние данные попадают внутрь только транзакцией от кого-то. Оракул — это перенесённая внутрь граница доверия и обычно самое слабое место конструкции. Цена в пуле AMM — отношение резервов прямо сейчас, поэтому спот-цена ценой не является:
Так работали bZx, Harvest, Mango Markets и десятки других инцидентов: всё внутри одной транзакции, атомарно, без собственного капитала.
произвольный факт?"} B -->|Факт| C["Оптимистичный оракул:
утверждение с залогом, окно на спор"] B -->|Цена| D{"Допустима задержка
в минуты?"} D -->|Нет ликвидного рынка| E["Цену задаёт мультисиг —
признать честно, добавить лимиты"] D -->|Да| G["Push-фид агрегатора:
heartbeat и порог отклонения"] D -->|Нет| H["Pull-оракул: подписанное
обновление в той же транзакции"] C --> I["Обязательно: свежесть, границы значения,
uptime секвенсора на L2,
медиана нескольких источников"] G --> I H --> I
TWAP (усреднение по времени, как кумулятивные цены Uniswap) дороже для манипуляции: чтобы сдвинуть среднее за 30 минут, цену надо удерживать все 30 минут, теряя на арбитраже. Но он запаздывает, и на резком движении рынка ликвидации идут по устаревшей цене; а после перехода на PoS расписание предложенцев известно заранее и мультиблочная манипуляция подешевела. TWAP — не панацея.
Три четверти инцидентов с оракулами — не изощрённая манипуляция, а «прочитали число и поверили»:
function price() public view returns (uint256) {
(, int256 up, uint256 startedAt, , ) = sequencerUptime.latestRoundData(); // только на L2
if (up != 0) revert SequencerDown(); // 1 == секвенсор лежит
if (block.timestamp - startedAt < 1 hours) revert GracePeriod();
(, int256 answer, , uint256 updatedAt, ) = feed.latestRoundData();
if (answer <= 0) revert PriceOutOfBounds(0); // тип int256 не случаен
if (block.timestamp - updatedAt > maxStaleness) revert StalePrice(updatedAt);
uint256 p = uint256(answer);
if (p <= minPrice || p >= maxPrice) revert PriceOutOfBounds(p); // СВОИ границы разумности
return p;
}
Каждая проверка оплачена чьим-то инцидентом. Свежесть: фид обновляется по heartbeat (скажем, раз в час)
или при отклонении на 0.5%; молчание дольше heartbeat означает «данных нет», а не «последняя цена верна».
Границы: в агрегаторах исторически существовали minAnswer/maxAnswer, и при коллапсе LUNA в мае 2022 фид
упёрся в нижнюю границу, продолжая сообщать цену, которой на рынке уже не было, — кредитовавшие под LUNA
протоколы приняли её за правду. Uptime секвенсора: пока секвенсор L2 лежит, цены застывают, а после
перезапуска транзакции хлынут разом, и grace period спасает от массовых ликвидаций по устаревшей цене.
7. Порядок транзакций, MEV и случайность
Транзакция лежит в публичном мемпуле, а порядок в блоке выбирает предложенец. Отсюда три проблемы.
Сэндвич. Бот ставит свою покупку перед вашей, вашу — после, продаёт следом; пользователь получает худшую
цену. Защита на уровне контракта — обязательные minAmountOut и deadline: никогда не принимайте «сколько
получится». Без require(out >= minAmountOut) функция дарит деньги ботам, без
require(block.timestamp <= deadline) зависшая транзакция исполнится через сутки по чужой цене.
Фронтраннинг раскрытия. Действие, ценность которого зависит от неизвестности (ставка на аукционе,
регистрация имени, ход в игре), в открытом мемпуле не работает. Решение — commit-reveal: сначала
транзакция с keccak256(значение, соль, адрес), затем в следующем блоке раскрытие. Соль и адрес в хеше
обязательны, иначе значение подбирается перебором, а коммит копируется.
Слабая случайность. block.timestamp, blockhash, block.prevrandao и любые их хеши — не случайность:
предложенец видит эти значения и может не публиковать невыгодный блок, а timestamp выбирает в пределах
допуска. Если от случайности зависят деньги — Chainlink VRF или commit-reveal с несколькими участниками.
Для собственных операций, критичных к порядку (апгрейд, ребалансировка), отправка через приватный
ретранслятор вместо публичного мемпула — разумная гигиена.
8. Подписи и прокси: два коротких чеклиста
Подписи оффчейн (permit, мета-транзакции, ордера, вайтлисты) дают отличный UX и очень плодовиты
на уязвимости. Обязательны: nonce (иначе подпись переиспользуется бесконечно); chainId в домене
EIP-712 (иначе подпись с тестовой сети работает в мейннете); address(this) в домене (иначе подпись
для одного контракта примет другой); дедлайн (подпись без срока — вечное право); проверка на нулевой
адрес — сырой ecrecover возвращает address(0) на некорректной подписи, поэтому берите ECDSA.recover
из OpenZeppelin: он ревертит и отбраковывает подписи с «высоким s» (у одной подписи есть валидный близнец —
это ломает дедупликацию); ERC-1271 — у смарт-кошельков нет приватного ключа, без isValidSignature
половина пользователей не сможет работать с протоколом.
function execute(Order calldata o, bytes calldata sig) external {
require(block.timestamp <= o.deadline, "expired");
require(!nonceUsed[o.maker][o.nonce], "nonce used");
nonceUsed[o.maker][o.nonce] = true; // EFFECT до внешних действий
bytes32 digest = _hashTypedDataV4( // chainId и address(this) внутри
keccak256(abi.encode(ORDER_TYPEHASH, o.maker, o.amount, o.nonce, o.deadline))
);
require(SignatureChecker.isValidSignatureNow(o.maker, digest, sig), "bad signature");
_settle(o);
}
Отдельно: abi.encodePacked склеивает динамические типы без разделителей, поэтому ("aa","b") и ("a","ab")
дают один хеш — в подписываемых структурах только abi.encode.
Прокси и delegatecall мы разбирали в предыдущей статье. Характерные
способы выстрелить себе в ногу: коллизия слотов (новая версия переставила поля, owner лёг поверх
totalSupply — поля добавляются только в конец, __gap, плагин OZ Upgrades в CI); неинициализированная
реализация (initialize() зовут прямо на ней, затем selfdestruct — лечится _disableInitializers());
UUPS без _authorizeUpgrade (обязателен тест «чужой не может обновить»); столкновение селекторов
между прокси и реализацией. И главное: право апгрейда — это право забрать все деньги. Прокси с одним
EOA-владельцем эквивалентен кастодиальному сервису, как бы ни выглядела «децентрализация» в описании.
9. Инструменты: что чем ловится
Ни один инструмент не покрывает всё; работает конвейер из непересекающихся слоёв — компилятор и линтер,
статический анализ (Slither, Aderyn), фаззинг свойств (forge, Echidna, Medusa), символьное исполнение
(Halmos, Kontrol, Mythril), формальная верификация (Certora), внешний аудит и, наконец, мониторинг в проде,
находки которого возвращаются в тесты.
| Слой | Что находит | Чего не найдёт |
|---|---|---|
| Компилятор и линтер | затенение переменных, непроверенный возврат | любую логику |
| Статический анализ | reentrancy по шаблону, tx.origin, неинициализированное состояние, опасный delegatecall |
ошибки бизнес-логики и экономики |
| Фаззинг и инварианты | нарушения свойств на входах, которые вы не придумали | свойства, которые вы не сформулировали |
| Символьное исполнение, ФВ | доказательство свойства на всех входах внутри модели | всё вне модели: оракулы, MEV, людей |
Наибольший возврат на вложенное время дают инварианты — утверждения, истинные после любой последовательности любых вызовов:
contract VaultInvariants is Test {
Vault vault;
Handler handler; // ограничивает фаззер осмысленными действиями
function setUp() public {
vault = new Vault();
handler = new Handler(vault);
targetContract(address(handler)); // фаззер дёргает только методы handler
}
/// Платёжеспособность: активов не меньше обязательств
function invariant_solvency() public view {
assertGe(vault.totalAssets(), vault.totalLiabilities());
assertEq(handler.sumOfUserShares(), vault.totalShares()); // целостность учёта
}
}
Три-семь таких свойств ловят больше настоящих багов, чем сто примерных тестов, а каждый найденный на аудите
баг стоит превращать в новый инвариант. Общий подход к тестам как дисциплине — в треке
Тестирование и
Тестирование безопасности. И про цепочку поставки:
pragma solidity 0.8.24; без ^, зафиксированные коммиты зависимостей, воспроизводимая сборка — инцидент
с пулами Curve вызвал баг в компиляторе Vyper, компилятор тоже ваша зависимость.
10. Аудит: что это и чего он не даёт
Порядок работ: заморозка кода и документации → письменно зафиксированный скоуп (адреса, файлы, что вне скоупа) → чтение спецификации и модели угроз аудиторами → ручной ревью (обычно 2–4 инженера, 1–3 недели) → собственный фаззинг и символьное исполнение → черновик отчёта с severity и PoC → исправления с тестом на каждую находку → ретест → публичный отчёт → постоянная программа вознаграждений.
Подготовка определяет результат. Аудитор без документации потратит половину оплаченного времени на реверс-инжиниринг вашей бизнес-логики. К старту нужны: описание инвариантов, список ролей и прав, список внешних зависимостей, проходящий набор тестов, чистый вывод статического анализа либо объяснение каждого срабатывания. Правки во время аудита обесценивают его. Находки классифицируются по матрице «ущерб × вероятность»:
Честные ограничения. Аудит — выборка, а не доказательство: три недели чтения людьми не эквивалентны отсутствию ошибок, а Euler, Nomad и Cream все его проходили. Аудит проверяет код, а не операционную безопасность: скомпрометированный ключ разработчика, фишинг подписантов мультисига, подмена фронтенда почти всегда вне скоупа. Аудит устаревает при первом изменении: байткод в сети обязан соответствовать проаудированному коммиту, сверяйте хеш. И «проаудировано фирмой X» — не свойство протокола: читайте отчёт — скоуп, число находок, что исправлено, что помечено «риск принят» (агрегатор — Solodit). Соревновательные аудиты (Code4rena, Sherlock, Cantina) дают охват и много шума — дополняют, но не заменяют глубокий ручной ревью архитектуры. Постоянная программа вознаграждений, например на Immunefi, экономически самая эффективная мера: исследователю должно быть выгоднее сообщить, чем воспользоваться, — потолок в 10 000 USD на протоколе с сотнями миллионов TVL не защита, а сигнал.
11. После деплоя: мониторинг и реагирование
Половина потерь происходит на протоколах, которые давно работали и однажды столкнулись с новым рыночным условием.
скачок цены или вызов админ-функции Alert --> Triage: дежурный подтверждает за минуты Triage --> Normal: ложное срабатывание, порог уточнён Triage --> Paused: инцидент подтверждён — pause Paused --> Analysis: разбор на форке, оценка потерь Analysis --> Patch: правка и тест, воспроизводящий атаку Patch --> Timelock: апгрейд через задержку Timelock --> Normal: возобновление Analysis --> Postmortem: публичный разбор и переговоры о возврате Postmortem --> [*]
Что должно существовать до инцидента: события на все значимые действия (нет события — нет мониторинга);
автоматические мониторы на вывод больше N% TVL за блок, вызов административной функции, расхождение оракула
с резервным источником (OpenZeppelin Defender, Forta, собственный индексатор логов); circuit breaker —
мгновенный pause плюс лимит скорости вывода (не более X% ликвидности в час), превращающий мгновенную потерю
всего в частичную с временем на реакцию; отрепетированный план — кто дежурит, у кого ключи, за сколько
минут собирается кворум мультисига (неотрепетированный pause занимает часы вместо минут); канал
для whitehat с объявленными условиями возврата — заметная часть похищенного возвращалась после переговоров.
Итог
Безопасность смарт-контрактов — не список запретов, а следствие четырёх свойств среды: код исполняет кто угодно, вызовы передают управление чужому коду, капитал не ограничивает атакующего, откатить нельзя.
- Порядок операций — это защита. Checks-Effects-Interactions закрывает целый класс уязвимостей бесплатно; замок — вторая линия, и от read-only reentrancy он не спасает.
- Не доверяйте балансу, времени, спот-цене и чужому
view. Всё, что можно сдвинуть внутри одной транзакции, будет сдвинуто флеш-займом. - Округление и приведение типов — код учёта денег. Выглядят как микрооптимизация, работают как дыра.
- Права важнее логики. Больше половины крупных потерь — не изощрённые баги, а привилегия не там и ключ не у того: мультисиг, таймлок на расширение прав, мгновенная пауза.
- Инварианты дают наибольший возврат на вложенное время, потому что находят то, о чём вы не подумали.
- Аудит — слой, а не гарантия. Работают несколько независимых слоёв плюс мониторинг, лимит скорости вывода и отрепетированная возможность остановиться.
И самое неудобное: по-настоящему безопасен только контракт без лишних возможностей. Каждая функция, роль и внешняя интеграция — поверхность атаки, которую придётся защищать вечно.
Источники
- Smart Contract Best Practices, ConsenSys Diligence — сводка практик и антипаттернов.
- Solidity Docs: Security Considerations — официальный раздел о ловушках языка.
- OpenZeppelin Contracts 5.x —
ReentrancyGuard,SafeERC20,SafeCast,ECDSA,Math.mulDiv. - Trail of Bits: Building Secure Contracts — фаззинг, Echidna, Slither.
- Foundry Book: Invariant Testing — инварианты и handler-паттерн.
- EIP-1153: Transient Storage и EIP-1884 — почему замки подешевели, а
transferустарел. - Chainlink Data Feeds: Best Practices — свежесть, heartbeat, L2 Sequencer Uptime Feed.
- Solodit — агрегатор публичных отчётов аудита с поиском по типам уязвимостей.
- Rekt и DefiLlama Hacks — хроника инцидентов с разборами.
- Damn Vulnerable DeFi и Ethernaut — тренажёры: лучший способ понять атаку — провести её.
Что дальше
Мы закрыли контракт со стороны кода. Но у любого протокола остаётся уязвимость, которую не чинят ни аудит, ни фаззинг: приватный ключ пользователя. Потерянная seed-фраза, подписанная не глядя транзакция, поддельный сайт кошелька — крупнейший канал потерь в отрасли по числу пострадавших. Разберём, как устроены кошельки изнутри и что технически означает «потерять доступ».
Кошельки и ключи: seed-фразы, HD-кошельки, подпись транзакций, потеря доступа