Перейти к содержанию
Learning Platform
Глоссарий Troubleshooting
Урок 11.03 · 20 мин
Продвинутый
SecurityInteger SafetyOverflowTactTON

Целочисленные ловушки

Целочисленные ошибки — одна из самых коварных категорий уязвимостей, потому что они часто проходят тестирование на «нормальных» значениях и проявляются только на граничных случаях. В TVM используются 257-битные знаковые целые числа, и переполнение, деление на ноль или потеря точности при делении могут привести к катастрофическим последствиям.

В Tact тип Int по умолчанию — знаковое 257-битное целое число. Это означает, что Int может быть отрицательным. Для разработчиков, привыкших к беззнаковым типам в других блокчейн-платформах, это неочевидный источник критических уязвимостей.

WARNING

Int в Tact может быть отрицательным!

Если вы пишете balance: Int, переменная balance может принять значение -100, -1000 или любое другое отрицательное число. Без явной проверки это приводит к эксплойтам.


Уязвимость: отрицательный баланс

Рассмотрим контракт голосования, где пользователи могут передавать голоса друг другу:

// УЯЗВИМЫЙ КОД -- НЕ ИСПОЛЬЗОВАТЬ В ПРОДАКШЕНЕ
contract VulnerableVoting {
    votes: map<Address, Int>;  // Int допускает отрицательные значения!

    receive(msg: TransferVotes) {
        let fromVotes: Int = self.votes.get(sender())!!;
        // БАГ: нет проверки, что amount <= fromVotes
        // Атакующий отправляет amount > fromVotes
        // fromVotes - amount = отрицательное число
        self.votes.set(sender(), fromVotes - msg.amount);
        let toVotes: Int = self.votes.get(msg.to) ?? 0;
        self.votes.set(msg.to, toVotes + msg.amount);
    }
}

Сценарий атаки

  1. У атакующего 10 голосов
  2. Атакующий вызывает TransferVotes{amount: 1000, to: accomplice}
  3. Баланс атакующего: 10 - 1000 = -990 (отрицательный!)
  4. Баланс сообщника: 0 + 1000 = 1000
  5. В системе “появились” 990 голосов из ниоткуда
DANGER

Создание ресурсов из ниоткуда

Отрицательный баланс позволяет атакующему создать произвольное количество ресурсов (токенов, голосов, очков). Это эквивалент уязвимости бесконечного минтинга.


Исправленный контракт

contract SecureVoting {
    votes: map<Address, Int>;

    receive(msg: TransferVotes) {
        let fromVotes: Int = self.votes.get(sender())!!;

        // Проверяем ДО арифметики
        require(msg.amount > 0, "Amount must be positive");
        require(fromVotes >= msg.amount, "Insufficient votes");

        self.votes.set(sender(), fromVotes - msg.amount);
        let toVotes: Int = self.votes.get(msg.to) ?? 0;
        self.votes.set(msg.to, toVotes + msg.amount);
    }
}

Две проверки решают проблему:

  • msg.amount > 0 — предотвращает отправку отрицательного значения (которое бы увеличило баланс отправителя)
  • fromVotes >= msg.amount — гарантирует, что баланс не станет отрицательным

Типы с ограничением размера

В Tact можно ограничить диапазон числа через аннотации as:

contract TypeSafeContract {
    balance: Int as coins;       // 120 бит, беззнаковое (0 .. 2^120-1)
    counter: Int as uint32;      // 32 бита, беззнаковое (0 .. 4,294,967,295)
    flags: Int as uint8;         // 8 бит, беззнаковое (0 .. 255)
    timestamp: Int as uint64;    // 64 бита, беззнаковое
}
TIP

Используйте as coins для балансов

Аннотация as coins ограничивает значение 120 битами без знака — это стандартный размер для хранения количества TON/Jetton. Tact автоматически проверит, что значение неотрицательно при сериализации.

Таблица типов

АннотацияБитыДиапазонКогда использовать
Int (без аннотации)257От -2^256 до 2^256-1Избегайте для балансов
as coins1200 .. 2^120-1Балансы TON, Jetton
as uint32320 .. ~4.29 млрдСчётчики, seqno, timestamp
as uint880 .. 255Флаги, малые значения
as int3232-2^31 .. 2^31-1Знаковые значения (если нужны)

Важно: аннотации ограничивают сериализацию (сколько бит записывается в ячейку), но в рантайме Tact всё равно работает с полным Int. Поэтому require() проверки остаются обязательными.

Частые ошибки

  1. Не проверяют деление на ноль: в TVM деление на ноль вызывает исключение и откат транзакции; если это происходит в критическом обработчике, контракт перестаёт функционировать.
  2. Забывают о потере точности при целочисленном делении: 5 / 3 = 1 (не 1.66), и накопление таких ошибок при расчёте комиссий приводит к рассогласованию балансов.
  3. Не проверяют входные значения на допустимый диапазон: пользователь может отправить отрицательное число или число, превышающее реальные объёмы.
  4. Умножают перед делением в неправильном порядке: (a * b) / c может переполниться, хотя конечный результат помещается в допустимый диапазон.

Проверка знанийKnowledge check
A Tact contract declares `balance: Int as coins` and an attacker sends a transfer with `amount = -500`. The contract subtracts: `self.balance -= msg.amount`. What is the net effect, and how would you prevent it?
ОтветAnswer
Subtracting a negative number is equivalent to addition: `self.balance -= (-500)` becomes `self.balance += 500`, increasing the attacker's balance by 500 out of thin air. The fix requires two checks before arithmetic: `require(msg.amount > 0, 'Amount must be positive')` and `require(self.balance >= msg.amount, 'Insufficient balance')`. The `as coins` annotation only constrains serialization width, not runtime arithmetic.

Паттерн: require() перед арифметикой

Золотое правило безопасности целых чисел — всегда проверяйте перед вычислениями:

receive(msg: Transfer) {
    // 1. Проверяем, что amount положительный
    require(msg.amount > 0, "Amount must be positive");

    // 2. Проверяем, что баланса достаточно
    require(self.balance >= msg.amount, "Insufficient balance");

    // 3. Только теперь выполняем арифметику
    self.balance -= msg.amount;
}

Этот паттерн применяется везде, где происходит вычитание из баланса:

  • Jetton Wallet: перевод токенов
  • NFT Sale: вычет комиссий
  • DEX: обмен токенов
  • Голосование: передача голосов

Переполнение: когда число не помещается

Хотя 257-битный Int огромен (больше количества атомов во Вселенной), при использовании ограниченных типов переполнение возможно:

// uint32 переполняется после 4,294,967,295
contract CounterExample {
    count: Int as uint32;

    receive("increment") {
        // Если count == 4,294,967,295, прибавление 1
        // вызовет ошибку при записи в storage (uint32 overflow)
        self.count += 1;
    }
}

Tact выбросит ошибку при попытке записать значение, не помещающееся в указанный тип. Это безопаснее, чем “тихое” переполнение, но может привести к DOS (контракт перестаёт работать, когда счётчик достигает максимума).


Ключевые правила

  1. Никогда не вычитайте без require() — проверяйте balance >= amount до арифметики
  2. Проверяйте положительность amountrequire(amount > 0) предотвращает инверсию операции
  3. Используйте as coins для балансов — стандартный 120-битный беззнаковый тип
  4. Используйте as uint32 для счётчиков — ограничивает размер и предотвращает отрицательные значения
  5. Помните: аннотации не заменяют require() — рантайм работает с полным Int, ограничения применяются при записи

В следующем уроке мы разберём проблемы, возникающие из-за асинхронности TON — race conditions и TOCTOU.

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 6