DoS через газ и неограниченные циклы
Иногда атака — не кража, а заморозка: контракт работает, но никто не может забрать деньги.
DoS (Denial of Service) в смарт-контракте — приведение его в состояние, когда нужные операции перестают исполняться (например, из-за нехватки газа или намеренной блокировки одним участником).
Газ как ресурс
Каждая операция в EVM стоит газа, и у транзакции/блока есть лимит. Если функция выполняет действий больше, чем влезает в лимит, она всегда откатывается — её нельзя исполнить в принципе. Атакующему не нужно красть: достаточно сделать так, чтобы ключевая функция превысила лимит газа и навсегда «зависла».
Неограниченные циклы
Классическая ловушка — цикл по массиву, который растёт без предела (например, по всем участникам). Пока участников мало, всё работает; когда их станет много (в том числе намеренно — атакующий добавляет тысячи адресов), цикл перестаёт укладываться в газ, и функция, например раздача наград, ломается для всех.
// УЯЗВИМО: раздача в цикле по растущему массиву
function payAll() external {
for (uint i = 0; i < users.length; i++) { // массив может вырасти безгранично
payable(users[i]).transfer(amounts[i]); // газ кончится -> всё откатится
}
}Зависимость от внешнего получателя
Вторая ловушка — push-выплаты: контракт сам пытается отправить деньги каждому. Если один получатель — это контракт, который намеренно отвергает приём средств (его receive делает revert), весь цикл выплат падает, и страдают все остальные. Один злонамеренный участник блокирует выплаты всем.
Решение: pull-over-push
Pull-over-push переворачивает логику: контракт не рассылает деньги, а лишь записывает, кому сколько причитается; каждый сам приходит и забирает свою долю отдельной транзакцией. Тогда сбой выплаты одному не влияет на других, а тяжёлый цикл исчезает: каждый платит газ только за свой вывод.
// БЕЗОПАСНО: pull — каждый забирает сам
mapping(address => uint256) public credits;
function allocate(address u, uint256 amt) internal {
credits[u] += amt; // только запись, без внешнего вызова
}
function withdraw() external {
uint256 amt = credits[msg.sender];
require(amt > 0, "nothing");
credits[msg.sender] = 0; // effect до interaction (CEI!)
(bool ok, ) = msg.sender.call{value: amt}("");
require(ok, "failed");
}Как работает под капотом
Pull-модель изолирует отказ: проблема одного получателя локализована в его собственной транзакции вывода. Дополнительно избегают неограниченных циклов вовсе — либо ограничивают размер пакета, либо обрабатывают по частям (батчами с курсором). Заодно это естественно сочетается с CEI против реентранси.
Частые ошибки
- Цикл по неограниченному массиву в критической функции.
- Push-выплаты в цикле: один отказавшийся получатель ломает всех.
- Действие, цена газа которого растёт с числом пользователей, — мина замедленного действия.
Итоги
- Превышение лимита газа делает функцию неисполнимой — это форма DoS.
- Неограниченные циклы и push-выплаты позволяют одному участнику заблокировать всех.
- Паттерн pull-over-push: контракт записывает долги, пользователи забирают сами.
- Избегайте циклов по растущим массивам; изолируйте отказ отдельной транзакцией.