Целочисленные ловушки
Целочисленные ошибки — одна из самых коварных категорий уязвимостей, потому что они часто проходят тестирование на «нормальных» значениях и проявляются только на граничных случаях. В TVM используются 257-битные знаковые целые числа, и переполнение, деление на ноль или потеря точности при делении могут привести к катастрофическим последствиям.
В Tact тип Int по умолчанию — знаковое 257-битное целое число. Это означает, что Int может быть отрицательным. Для разработчиков, привыкших к беззнаковым типам в других блокчейн-платформах, это неочевидный источник критических уязвимостей.
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);
}
}
Сценарий атаки
- У атакующего 10 голосов
- Атакующий вызывает
TransferVotes{amount: 1000, to: accomplice} - Баланс атакующего:
10 - 1000 = -990(отрицательный!) - Баланс сообщника:
0 + 1000 = 1000 - В системе “появились” 990 голосов из ниоткуда
Создание ресурсов из ниоткуда
Отрицательный баланс позволяет атакующему создать произвольное количество ресурсов (токенов, голосов, очков). Это эквивалент уязвимости бесконечного минтинга.
Исправленный контракт
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 бита, беззнаковое
}
Используйте as coins для балансов
Аннотация as coins ограничивает значение 120 битами без знака — это стандартный размер для хранения количества TON/Jetton. Tact автоматически проверит, что значение неотрицательно при сериализации.
Таблица типов
| Аннотация | Биты | Диапазон | Когда использовать |
|---|---|---|---|
Int (без аннотации) | 257 | От -2^256 до 2^256-1 | Избегайте для балансов |
as coins | 120 | 0 .. 2^120-1 | Балансы TON, Jetton |
as uint32 | 32 | 0 .. ~4.29 млрд | Счётчики, seqno, timestamp |
as uint8 | 8 | 0 .. 255 | Флаги, малые значения |
as int32 | 32 | -2^31 .. 2^31-1 | Знаковые значения (если нужны) |
Важно: аннотации ограничивают сериализацию (сколько бит записывается в ячейку), но в рантайме Tact всё равно работает с полным Int. Поэтому require() проверки остаются обязательными.
Частые ошибки
- Не проверяют деление на ноль: в TVM деление на ноль вызывает исключение и откат транзакции; если это происходит в критическом обработчике, контракт перестаёт функционировать.
- Забывают о потере точности при целочисленном делении: 5 / 3 = 1 (не 1.66), и накопление таких ошибок при расчёте комиссий приводит к рассогласованию балансов.
- Не проверяют входные значения на допустимый диапазон: пользователь может отправить отрицательное число или число, превышающее реальные объёмы.
- Умножают перед делением в неправильном порядке: (a * b) / c может переполниться, хотя конечный результат помещается в допустимый диапазон.
Проверка знаний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?
Паттерн: 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 (контракт перестаёт работать, когда счётчик достигает максимума).
Ключевые правила
- Никогда не вычитайте без require() — проверяйте
balance >= amountдо арифметики - Проверяйте положительность amount —
require(amount > 0)предотвращает инверсию операции - Используйте
as coinsдля балансов — стандартный 120-битный беззнаковый тип - Используйте
as uint32для счётчиков — ограничивает размер и предотвращает отрицательные значения - Помните: аннотации не заменяют require() — рантайм работает с полным Int, ограничения применяются при записи
В следующем уроке мы разберём проблемы, возникающие из-за асинхронности TON — race conditions и TOCTOU.
Check Your Understanding
Finished the lesson?
Mark it as complete to track your progress
Войдите чтобы оценить урок