Web3 и блокчейн Безопасность контрактов: reentrancy, переполнения, оракулы, аудит
0%

Безопасность контрактов: reentrancy, переполнения, оракулы, аудит

Безопасность контрактов: 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, остаётся старым.

Reentrancy: стек вызовов и состояние хранилища

// 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 вход в другую функцию с тем же состоянием (withdrawtransfer) общий замок на группу функций
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 и десятки других инцидентов: всё внутри одной транзакции, атомарно, без собственного капитала.

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. После деплоя: мониторинг и реагирование

Половина потерь происходит на протоколах, которые давно работали и однажды столкнулись с новым рыночным условием.

Что должно существовать до инцидента: события на все значимые действия (нет события — нет мониторинга); автоматические мониторы на вывод больше N% TVL за блок, вызов административной функции, расхождение оракула с резервным источником (OpenZeppelin Defender, Forta, собственный индексатор логов); circuit breaker — мгновенный pause плюс лимит скорости вывода (не более X% ликвидности в час), превращающий мгновенную потерю всего в частичную с временем на реакцию; отрепетированный план — кто дежурит, у кого ключи, за сколько минут собирается кворум мультисига (неотрепетированный pause занимает часы вместо минут); канал для whitehat с объявленными условиями возврата — заметная часть похищенного возвращалась после переговоров.

Итог

Безопасность смарт-контрактов — не список запретов, а следствие четырёх свойств среды: код исполняет кто угодно, вызовы передают управление чужому коду, капитал не ограничивает атакующего, откатить нельзя.

  • Порядок операций — это защита. Checks-Effects-Interactions закрывает целый класс уязвимостей бесплатно; замок — вторая линия, и от read-only reentrancy он не спасает.
  • Не доверяйте балансу, времени, спот-цене и чужому view. Всё, что можно сдвинуть внутри одной транзакции, будет сдвинуто флеш-займом.
  • Округление и приведение типов — код учёта денег. Выглядят как микрооптимизация, работают как дыра.
  • Права важнее логики. Больше половины крупных потерь — не изощрённые баги, а привилегия не там и ключ не у того: мультисиг, таймлок на расширение прав, мгновенная пауза.
  • Инварианты дают наибольший возврат на вложенное время, потому что находят то, о чём вы не подумали.
  • Аудит — слой, а не гарантия. Работают несколько независимых слоёв плюс мониторинг, лимит скорости вывода и отрепетированная возможность остановиться.

И самое неудобное: по-настоящему безопасен только контракт без лишних возможностей. Каждая функция, роль и внешняя интеграция — поверхность атаки, которую придётся защищать вечно.

Источники

Что дальше

Мы закрыли контракт со стороны кода. Но у любого протокола остаётся уязвимость, которую не чинят ни аудит, ни фаззинг: приватный ключ пользователя. Потерянная seed-фраза, подписанная не глядя транзакция, поддельный сайт кошелька — крупнейший канал потерь в отрасли по числу пострадавших. Разберём, как устроены кошельки изнутри и что технически означает «потерять доступ».

Кошельки и ключи: seed-фразы, HD-кошельки, подпись транзакций, потеря доступа

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов