Наконец таки статья о том как я облажался. Точнее — как облажалась команда, но ответственность все равно моя.
TL;DR: Relayer для TON-проекта писался с помощью LLM. Без документации. Без тестов. Без понимания модели угроз. В результате — потеря ~$5 000 из пула ликвидности на STON.fi. Блокчейн не взломан, DEX работает как надо. Проблема была в нашей архитектуре.
Это разбор конкретной ошибки, которая стоила реальных денег. И пояснение, почему скептики с Хабра всё равно не правы — но по другой причине, чем они думают.
1. Что вообще за проект
Делали сервис для крипто-трейдеров. Суть простая:
-
Пользователь покупает подписку за TON
-
Получает возможность создавать триггеры на валютные пары
-
Когда триггер срабатывает — получает уведомление
Интересная особенность – уведомления приходят в виде… звонка на телефон! пользователь при регистрации указывает свой номер, а при триггере сервис Twilio – из США поступает звонок. У проекта был свой jetton (токен), пул ликвидности на STON.fi, подписочная модель через смарт-контракт.
Сначала мы делали просто приложение — без ончейн-логики. Работало отлично. Потом заказчик решил: “Давайте сделаем по-настоящему, со смарт-контрактами, чтобы всё было прозрачно и децентрализованно”. Идея была такая, что за стоимость подписки покупался токен и тут же сжигался. Таким образом каждая покупка подписки увеличивала ликвидность.
Звучало круто. Мы согласились.
2. Архитектура (упрощённо)
Для понимания — как это всё было устроено:
┌─────────────────┐
│ Пользователь │
│ (Tonkeeper) │
└────────┬────────┘
│ платит TON
▼
┌─────────────────┐
│ Subscription │
│ Smart Contract │
└────────┬────────┘
│ делегирует swap/burn
▼
┌─────────────────┐
│ Relayer │ ← вот тут была дыра
│ (off-chain) │
└────────┬────────┘
│ выполняет on-chain операции
▼
┌─────────────────┐
│ STON.fi │
│ DEX │
└─────────────────┘
Relayer — это off-chain сервис, который:
-
Принимал HTTP-запросы
-
Выполнял on-chain операции (swap, burn)
-
Подписывал транзакции hot-wallet ключом
-
Работал как “мост” между контрактами и DEX
По сути — доверенный посредник с правами на критичные операции.
3. Что вообще произошло
День 0: Что-то пошло не так (мы ещё не знаем)
В какой-то момент злоумышленник получил возможность инициировать burn LP-токенов. LP-токены — это токены ликвидности, которые подтверждают нашу долю в пуле на STON.fi.
Что произошло технически:
-
LP-токены сожжены через стандартный
StonfiBurnNotification -
STON.fi вернул активы (USD₮ и наш jetton) — ровно по правилам протокола
-
Активы выведены
Мы это не заметили, вообще!
День 10: Обнаружение
Спустя примерно 10 дней кто-то заметил, что ликвидность в пуле нулевая – необходимо было часть токенов обменять обратно на TON для тестирования контракта в другом сервисе. Начали разбираться.
Первые мысли (типичные):
-
Утек seed?
-
STON.fi взломали? (пффф, и только нашу ликвидность вывели))
-
Баг в LP-контракте?
Спойлер: нет, нет и нет конечно же.
День ~11-14: Восстановление (и ошибка)
Заказчик попросил сделать то, что кажется логичным:
-
Перевести управляющие токены на его новый кошелек
-
Он решил сразу пополнить ликвидность заново
-
Решили, что это был разовый инцидент
Это была ошибка.
День ~15: Второй удар
Буквально в тот же день или на следующий — злоумышленник увидел, что ликвидность снова появилась.
Но теперь у него не было доступа к LP-токенам (мы же перевели всё на новые кошельки). Поэтому он сделал другое:
-
Наминтил токены напрямую через MINT-контракт
-
Продал их в пул за свежую ликвидность
-
Вывел
Ну красавчег, если честно, молодец.
4. Что точно НЕ было причиной
Прежде чем копать дальше — зафиксирую, что мы исключили:
|
Версия |
Почему не она |
|---|---|
|
STON.fi взломан |
DEX работает как задокументировано, никаких аномалий |
|
Баг в LP-контракте |
Стандартный контракт, используется тысячами проектов |
|
Утечка seed-фразы |
Не было тотального вывода всех активов со всех кошельков |
|
approve/allowance (как в EVM) |
В TON другая модель, тут это не применимо |
То есть это не классический взлом. Блокчейн сделал ровно то, что должен был сделать.
5. Где начали копать по-настоящему
На меня нахлынуло просветление: relayer.
Почему:
-
У relayer были права на burn
-
Relayer работал со STON.fi
-
Действия в инциденте — именно те, которые relayer умел делать
Я выгрузил всю историю инцидента в ChatGPT — последовательность событий, что обнаружили, когда, какие транзакции. Запротоколировал. Потом передал эту информацию вместе с кодовой базой в Codex.
Пара часов делов и ясность пришла полная.
6. Ключевая ошибка: relayer доверял HTTP, а не блокчейну
Вот как была устроена логика:
1. Приходит HTTP-запрос /process-subscription
2. Relayer НЕ проверяет:
- что реально был ончейн-платёж
- что txHash существует
- что сумма совпадает
- что вызов одноразовый
3. Relayer СРАЗУ делает:
- swap
- burn
- другие on-chain действия
Любой, кто мог дёрнуть relayer API, мог инициировать критичные on-chain операции.
Да, API был защищён(?…) токеном. в .env, всё как положено. Но этого оказалось недостаточно.
7. Конкретные дыры в коде
7.1. Доверие HTTP-запросу вместо ончейн-события
// relayer/src/controllers/relayer.controller.ts
@Post("process-subscription")
async processSubscription(@Body() data: ProcessSubscriptionDto) {
return this.relayerService.processSubscription(data);
}
// relayer/src/services/relayer.service.ts
const transaction = this.transactionRepository.create({
lt: Date.now().toString(), // ← вот это проблема
hash: data.txHash, // ← и это
userAddress: data.userAddress,
fromAddress: data.subscriptionContractAddress,
toAddress: this.config.relayerWalletAddress,
amountNanotons: (parseFloat(data.amount) * 1_000_000_000).toString(),
// ...
});
// Далее сразу swap+burn без проверки txHash/платежа
Relayer запускает критичные операции на основании входного HTTP-запроса. Без проверки, что платёж реально был на блокчейне.
7.2. Фейковая идемпотентность
// relayer/src/entities/transaction.entity.ts
@Index(["lt", "hash"], { unique: true })
// relayer/src/services/relayer.service.ts
lt: Date.now().toString(),
hash: data.txHash,
Уникальность транзакций строилась на lt + hash, где lt = Date.now().
Это не привязано к реальному ончейн-идентификатору. Один и тот же txHash можно обработать многократно — просто в разное время.
7.3. Нет связи user → intent → действие
Контракт подписки отправлял на relayer тело сообщения:
// blockchain/contracts/caller.tact
message(MessageParameters{
to: self.stonRouter,
value: tonToSwap,
body: beginCell()
.storeUint(0x7361_6d70, 32) // op
.storeAddress(sender()) // user
.storeCoins(incoming) // amount
.endCell()
});
Но relayer эту информацию не читал и не верифицировал. Он просто выполнял то, что пришло по HTTP.
По сути relayer превратился в универсальный прокси выполнения on-chain действий доверенным ключом.
7.4. Hot-wallet с избыточными правами
// relayer/src/config/relayer.config.ts
relayerPrivateKey: process.env.RELAYER_PRIV_KEY!,
relayerWalletAddress: process.env.RELAYER_WALLET_ADDR!,
// relayer/src/modules/ton/ton.service.ts
await walletContract.sendTransfer({
seqno,
secretKey: this.keyPair.secretKey,
messages: [ internal({ to: destination, value, body }) ],
});
Все операции подписывались одним ключом из env. Этот ключ имел права на:
-
swap
-
burn LP
-
mint токенов
-
owner-level операции
Компрометация relayer-сервиса = компрометация всей trust-модели проекта.
8. Как могла выглядеть атака
Вариант A: Доступ к API
-
Хацкер получает возможность вызвать relayer HTTP API (утечка токена, открытый порт, SSRF)
-
Отправляет
process-subscriptionс произвольными данными -
Relayer запускает on-chain swap + burn
-
Профит
Вариант Б: Компрометация сервера/ключей
-
Доступ к relayer-серверу или env-переменным
-
Использует hot-wallet ключи для owner/minter операций
-
Mint токенов, burn LP напрямую
-
Профит
Оба сценария полностью согласуются с тем, что:
-
LP burn мог быть инициирован только владельцем LP (а relayer имел эти права!)
-
Mint возможен только при наличии owner/minter прав (а relayer имел и их!)
-
Компрометация STON.fi или блокчейна не требуется
Мы до конца не выяснили, какой именно вектор был использован, ноо… это и не так важно — важно, что архитектура это позволяла)
9. Почему это произошло
Теперь самое интересное — почему relayer был написан с такими дырами.
Relayer писался с помощью LLM. Без документации. Без тестов. Без понимания модели угроз.
Человек, который его писал, торопился. Не заказчик торопил — сам торопился. Хотел быстро, срочно и почти офигенно.
И он положился на нейронку полностью. Без нашей обычной методики (о которой писал в прошлых статьях подробно):
-
Не было детального ТЗ на компонент
-
Не было документации архитектуры
-
Не было TDD
-
Не было threat-model
-
Не было даже базовых тестов(!?)
LLM отлично справился с задачей. Код работал. Swap работал. Burn работал. Всё функционировало.
Но LLM не сделал того, о чём его не просили:
-
Не подумал о модели угроз
-
Не проверил trust boundaries
-
Не задавался вопросами (ну а зачем?)
-
Не предложил разделение ключей по ролям
Потому что это должен был сделать разработчик.
10. Главный вывод (и он не про “LLM плохие”)
Ожидаю скептиков: “Вот видите! LLM для серьёзных вещей не годится!”
Увы! Они не правы. Но не потому, что LLM идеальны.
LLM — это усилитель.
Это ключевая мысль, которую я повторяю в каждой статье. И этот инцидент ее только подтверждает.
-
Если LLM усиливает понимающего архитектора — получается boost
-
Если LLM усиливает того, кто не понимает предметную область — усиливаются дыры
В нашем случае:
-
Разработчик не понимал модель угроз для блокчейн-приложений
-
Разработчик не применил нашу методичку (документация + TDD)
-
Разработчик торопился
LLM сделал ровно то, что его просили. Написал работающий код. Код работал — до тех пор, пока кто-то не нашёл дыру.
Проблема была не в инструменте. Проблема была в процессе.
11. Что нужно было сделать
Для истории — как должен был быть устроен безопасный relayer:
11.1. Proof of payment
// Вместо доверия HTTP-запросу
async processSubscription(data: ProcessSubscriptionDto) {
// 1. Проверить, что txHash существует на блокчейне
const tx = await this.tonClient.getTransaction(data.txHash);
if (!tx) throw new Error('Transaction not found');
// 2. Проверить, что транзакция от subscription contract
if (tx.from !== SUBSCRIPTION_CONTRACT_ADDRESS) {
throw new Error('Invalid source');
}
// 3. Проверить сумму
if (tx.value !== expectedAmount) {
throw new Error('Invalid amount');
}
// 4. Только после этого — выполнять действия
await this.executeSwap(data);
}
11.2. Идемпотентность по реальному tx
// Уникальность по ончейн-идентификатору, а не по Date.now()
@Index(["txHash", "lt"], { unique: true }) // lt из блокчейна, не из Date.now()
11.3. Разделение ключей по ролям
relayer-swap-key → только swap операции
relayer-burn-key → только burn (если вообще нужно)
owner-key → owner операции, НИКОГДА не на сервере
minter-key → mint операции, НИКОГДА не на сервере
11.4. Domain separation
// Whitelist разрешённых операций
const ALLOWED_OPERATIONS = ['swap', 'callback'];
// burn, mint, owner-calls — НИКОГДА через relayer
12. Как это изменило наш процесс
После инцидента я формализовал несколько правил:
Для любого компонента с доступом к критичным ресурсам:
-
Threat-model ДО написания кода
-
Кто может вызвать этот компонент?
-
Что произойдёт, если вызов будет поддельным?
-
Какие права минимально необходимы?
-
-
Документация как у всех остальных компонентов
-
Детальное ТЗ
-
Архитектура
-
API
-
Ограничения
-
-
TDD обязательно
-
Тесты на happy path
-
Тесты на edge cases
-
Тесты на атаки (replay, forge, overflow)
-
Для блокчейн-проектов конкретно:
-
Минимальные права для каждого компонента
-
Relayer не должен иметь owner/minter права
-
Hot-wallet только для операционных нужд
-
Критичные ключи — только в cold storage
-
-
Verify on-chain, not off-chain
-
Любое действие — проверяем на блокчейне
-
HTTP-запросу доверяем только после верификации
-
13. Про реакцию заказчика
Отдельно хочу отметить: заказчик отреагировал удивительно спокойно.
$5000 — для него это оказалось “не много”. Он не требовал компенсации, не устраивал скандал. Просто сказал:

Это редкость. И это позволило мне спокойно провести расследование, задокументировать всё, сделать выводы.
Не все заказчики такие. Нам повезло.
14. Про мою реакцию
Когда я понял, что это наша ошибка архитектуры, а не хацкеры — было немного грустно. Но больше было интересно.
Включился включился исследовательский режим. Хотелось понять:
-
Как именно это произошло?
-
Что мы упустили?
-
Как сделать так, чтобы не повторилось?
-
Что из этого можно вынести для будущих проектов?
Этот инцидент не заставил меня усомниться в LLM-разработке. Наоборот, он подтвердил то, что я говорю всегда:
-
Документация решает
-
Тесты решают
-
Понимание предметной области решает
-
LLM — усилитель, не замена мышлению
15. Про другие блокчейн-проекты
У нас есть другие проекты со смарт-контрактами. Там нет таких проблем.
Разница:
-
Там была полноценная документация
-
Там были тесты
-
Там контракты изолированы и защищены
-
Там не было компонента типа “relayer с правами на всё”
Этот инцидент — исключение, а не правило. Но исключение, которое стоило денег и из которого нужно было сделать выводы.
16. Мораль (без морализаторства)
Если вы используете LLM для разработки — особенно для:
-
Блокчейн-приложений
-
Компонентов с доступом к деньгам
-
Relayer’ов и bridge’ей
-
Чего угодно с owner/minter/admin правами
Сначала нарисуйте модель угроз.
Задайте себе вопросы:
-
Кто может вызвать этот код?
-
Что произойдёт, если входные данные поддельные?
-
Какие минимальные права нужны?
-
Что будет, если этот компонент скомпрометирован?
И только после этого — просите LLM написать код, с детальным ТЗ, с ограничениями и конечно же тестами.
Код LLM напишет. Ответственность за архитектуру — на вас.
17. Итог
Мы потеряли ~$5 000 не из-за:
-
Багов в STON.fi
-
Плохого блокчейна
-
Хацкеров
-
Ненадежных LLM
Мы потеряли их из-за:
-
Неправильной trust-модели
-
Слишком доверчивого relayer’а
-
Отсутствия документации и тестов
-
Торопливости
-
Иллюзии, что LLM может заменить архитектурное мышление
Я продолжаю использовать LLM для 100% кода. Но теперь ещё строже слежу за процессом — особенно когда дело касается денег и безопасности.
Если интересна методология LLM-разработки, которая работает (когда её применяют) — в прошлой статье разбирал документацию, TDD и управление контекстом.
Вопросы по TON/STON.fi/relayer — в комментарии. Расскажу подробнее, что смогу.
Готов к помидорам. Как показывает практика — это только добавляет просмотров.
Автор: okoloboga


