Привет, Хабр! Наконец таки статья о том как я облажался. Точнее — как облажалась команда, но ответственность все равно моя.
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-токены сожжены через стандартный
StonfiBurnNotificationSTON.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 — в комментарии. Расскажу подробнее, что смогу.
Готов к помидорам. Как показывает практика — это только добавляет просмотров.
