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: контракт записывает долги, пользователи забирают сами.
  • Избегайте циклов по растущим массивам; изолируйте отказ отдельной транзакцией.
Проверьте себя
1. Почему неограниченный цикл по массиву опасен?
AОн медленный, но безопасный
BПри большом массиве функция превышает лимит газа и становится неисполнимой
CОн шифрует данные
DОн всегда переполняет uint
2. Как push-выплаты в цикле позволяют устроить DoS?
AНикак
BОдин получатель-контракт, отвергающий приём средств, ломает весь цикл выплат
CОни слишком быстрые
DОни переполняют баланс
3. В чём суть паттерна pull-over-push?
AКонтракт рассылает деньги всем сразу
BКонтракт записывает причитающееся, а каждый забирает свою долю отдельной транзакцией
CЗапретить вывод средств
DПлатить только владельцу