- BrainTools - https://www.braintools.ru -
Наконец таки статья о том как я облажался. Точнее — как облажалась команда, но ответственность все равно моя.
TL;DR: Relayer для TON-проекта писался с помощью LLM. Без документации. Без тестов. Без понимания модели угроз. В результате — потеря ~$5 000 из пула ликвидности на STON.fi [1]. Блокчейн не взломан, DEX работает как надо. Проблема была в нашей архитектуре.
Это разбор конкретной ошибки [2], которая стоила реальных денег. И пояснение, почему скептики с Хабра всё равно не правы — но по другой причине, чем они думают.
Делали сервис для крипто-трейдеров. Суть простая:
Пользователь покупает подписку за TON
Получает возможность создавать триггеры на валютные пары
Когда триггер срабатывает — получает уведомление
Интересная особенность – уведомления приходят в виде… звонка на телефон! пользователь при регистрации указывает свой номер, а при триггере сервис Twilio – из США поступает звонок. У проекта был свой jetton (токен), пул ликвидности на STON.fi [1], подписочная модель через смарт-контракт.
Сначала мы делали просто приложение — без ончейн-логики. Работало отлично. Потом заказчик решил: “Давайте сделаем по-настоящему, со смарт-контрактами, чтобы всё было прозрачно и децентрализованно”. Идея была такая, что за стоимость подписки покупался токен и тут же сжигался. Таким образом каждая покупка подписки увеличивала ликвидность.
Звучало круто. Мы согласились.
Для понимания — как это всё было устроено:
┌─────────────────┐
│ Пользователь │
│ (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
По сути — доверенный посредник с правами на критичные операции.
В какой-то момент злоумышленник получил возможность инициировать burn LP-токенов. LP-токены — это токены ликвидности, которые подтверждают нашу долю в пуле на STON.fi [1].
Что произошло технически:
LP-токены сожжены через стандартный StonfiBurnNotification
STON.fi [1] вернул активы (USD₮ и наш jetton) — ровно по правилам протокола
Активы выведены
Мы это не заметили, вообще!
Спустя примерно 10 дней кто-то заметил, что ликвидность в пуле нулевая – необходимо было часть токенов обменять обратно на TON для тестирования контракта в другом сервисе. Начали разбираться.
Первые мысли (типичные):
Утек seed?
STON.fi [1] взломали? (пффф, и только нашу ликвидность вывели))
Баг в LP-контракте?
Спойлер: нет, нет и нет конечно же.
Заказчик попросил сделать то, что кажется логичным:
Перевести управляющие токены на его новый кошелек
Он решил сразу пополнить ликвидность заново
Решили, что это был разовый инцидент
Это была ошибка.
Буквально в тот же день или на следующий — злоумышленник увидел, что ликвидность снова появилась.
Но теперь у него не было доступа к LP-токенам (мы же перевели всё на новые кошельки). Поэтому он сделал другое:
Наминтил токены напрямую через MINT-контракт
Продал их в пул за свежую ликвидность
Вывел
Ну красавчег, если честно, молодец.
Прежде чем копать дальше — зафиксирую, что мы исключили:
|
Версия |
Почему не она |
|---|---|
|
STON.fi [1] взломан |
DEX работает как задокументировано, никаких аномалий |
|
Баг в LP-контракте |
Стандартный контракт, используется тысячами проектов |
|
Утечка seed-фразы |
Не было тотального вывода всех активов со всех кошельков |
|
approve/allowance (как в EVM) |
В TON другая модель, тут это не применимо |
То есть это не классический взлом. Блокчейн сделал ровно то, что должен был сделать.
На меня нахлынуло просветление: relayer.
Почему:
У relayer были права на burn
Relayer работал со STON.fi [1]
Действия в инциденте — именно те, которые relayer умел делать
Я выгрузил всю историю инцидента в ChatGPT — последовательность событий, что обнаружили, когда, какие транзакции. Запротоколировал. Потом передал эту информацию вместе с кодовой базой в Codex.
Пара часов делов и ясность пришла полная.
Вот как была устроена логика [4]:
1. Приходит HTTP-запрос /process-subscription
2. Relayer НЕ проверяет:
- что реально был ончейн-платёж
- что txHash существует
- что сумма совпадает
- что вызов одноразовый
3. Relayer СРАЗУ делает:
- swap
- burn
- другие on-chain действия
Любой, кто мог дёрнуть relayer API, мог инициировать критичные on-chain операции.
Да, API был защищён(?…) токеном. в .env, всё как положено. Но этого оказалось недостаточно.
// 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-запроса. Без проверки, что платёж реально был на блокчейне.
// 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 [5]().
Это не привязано к реальному ончейн-идентификатору. Один и тот же txHash можно обработать многократно — просто в разное время.
Контракт подписки отправлял на 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 действий доверенным ключом.
// 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-модели проекта.
Вариант 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 [1] или блокчейна не требуется
Мы до конца не выяснили, какой именно вектор был использован, ноо… это и не так важно — важно, что архитектура это позволяла)
Теперь самое интересное — почему relayer был написан с такими дырами.
Relayer писался с помощью LLM. Без документации. Без тестов. Без понимания модели угроз.
Человек, который его писал, торопился. Не заказчик торопил — сам торопился. Хотел быстро, срочно и почти офигенно.
И он положился на нейронку полностью. Без нашей обычной методики (о которой писал в прошлых статьях подробно):
Не было детального ТЗ на компонент
Не было документации архитектуры
Не было TDD
Не было threat-model
Не было даже базовых тестов(!?)
LLM отлично справился с задачей. Код работал. Swap работал. Burn работал. Всё функционировало.
Но LLM не сделал того, о чём его не просили:
Не подумал о модели угроз
Не проверил trust boundaries
Не задавался вопросами (ну а зачем?)
Не предложил разделение ключей по ролям
Потому что это должен был сделать разработчик.
Ожидаю скептиков: “Вот видите! LLM для серьёзных вещей не годится!”
Увы! Они не правы. Но не потому, что LLM идеальны.
LLM — это усилитель.
Это ключевая мысль, которую я повторяю в каждой статье. И этот инцидент ее только подтверждает.
Если LLM усиливает понимающего архитектора — получается boost
Если LLM усиливает того, кто не понимает предметную область — усиливаются дыры
В нашем случае:
Разработчик не понимал модель угроз для блокчейн-приложений
Разработчик не применил нашу методичку (документация + TDD)
Разработчик торопился
LLM сделал ровно то, что его просили. Написал работающий код. Код работал — до тех пор, пока кто-то не нашёл дыру.
Проблема была не в инструменте. Проблема была в процессе.
Для истории — как должен был быть устроен безопасный relayer:
// Вместо доверия 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);
}
// Уникальность по ончейн-идентификатору, а не по Date.now()
@Index(["txHash", "lt"], { unique: true }) // lt из блокчейна, не из Date.now()
relayer-swap-key → только swap операции
relayer-burn-key → только burn (если вообще нужно)
owner-key → owner операции, НИКОГДА не на сервере
minter-key → mint операции, НИКОГДА не на сервере
// Whitelist разрешённых операций
const ALLOWED_OPERATIONS = ['swap', 'callback'];
// burn, mint, owner-calls — НИКОГДА через relayer
После инцидента я формализовал несколько правил:
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-запросу доверяем только после верификации
Отдельно хочу отметить: заказчик отреагировал удивительно спокойно.
$5000 — для него это оказалось “не много”. Он не требовал компенсации, не устраивал скандал. Просто сказал:

Это редкость. И это позволило мне спокойно провести расследование, задокументировать всё, сделать выводы.
Не все заказчики такие. Нам повезло.
Когда я понял, что это наша ошибка архитектуры, а не хацкеры — было немного грустно. Но больше было интересно.
Включился включился исследовательский режим. Хотелось понять:
Как именно это произошло?
Что мы упустили?
Как сделать так, чтобы не повторилось?
Что из этого можно вынести для будущих проектов?
Этот инцидент не заставил меня усомниться в LLM-разработке. Наоборот, он подтвердил то, что я говорю всегда:
Документация решает
Тесты решают
Понимание предметной области решает
LLM — усилитель, не замена мышлению [6]
У нас есть другие проекты со смарт-контрактами. Там нет таких проблем.
Разница:
Там была полноценная документация
Там были тесты
Там контракты изолированы и защищены
Там не было компонента типа “relayer с правами на всё”
Этот инцидент — исключение, а не правило. Но исключение, которое стоило денег и из которого нужно было сделать выводы.
Если вы используете LLM для разработки — особенно для:
Блокчейн-приложений
Компонентов с доступом к деньгам
Relayer’ов и bridge’ей
Чего угодно с owner/minter/admin правами
Сначала нарисуйте модель угроз.
Задайте себе вопросы:
Кто может вызвать этот код?
Что произойдёт, если входные данные поддельные?
Какие минимальные права нужны?
Что будет, если этот компонент скомпрометирован?
И только после этого — просите LLM написать код, с детальным ТЗ, с ограничениями и конечно же тестами.
Код LLM напишет. Ответственность за архитектуру — на вас.
Мы потеряли ~$5 000 не из-за:
Багов в STON.fi [1]
Плохого блокчейна
Хацкеров
Ненадежных LLM
Мы потеряли их из-за:
Неправильной trust-модели
Слишком доверчивого relayer’а
Отсутствия документации и тестов
Торопливости
Иллюзии, что LLM может заменить архитектурное мышление
Я продолжаю использовать LLM для 100% кода. Но теперь ещё строже слежу за процессом — особенно когда дело касается денег и безопасности.
Если интересна методология LLM-разработки, которая работает (когда её применяют) — в прошлой статье [7] разбирал документацию, TDD и управление контекстом.
Вопросы по TON/STON.fi/relayer — в комментарии. Расскажу подробнее, что смогу.
Готов к помидорам. Как показывает практика — это только добавляет просмотров.
Автор: okoloboga
Источник [8]
Сайт-источник BrainTools: https://www.braintools.ru
Путь до страницы источника: https://www.braintools.ru/article/25184
URLs in this post:
[1] STON.fi: http://STON.fi
[2] ошибки: http://www.braintools.ru/article/4192
[3] Image: https://sourcecraft.dev/
[4] логика: http://www.braintools.ru/article/7640
[5] Date.now: http://Date.now
[6] мышлению: http://www.braintools.ru/thinking
[7] в прошлой статье: https://habr.com/ru/articles/971496/
[8] Источник: https://habr.com/ru/articles/992740/?utm_source=habrahabr&utm_medium=rss&utm_campaign=992740
Нажмите здесь для печати.