Механизм: шлюз берёт цену закрытия из локальной истории на момент expire_time ставки (priceHistory, node:sqlite, ретеншен 24ч), а не текущую. Момент клика перестаёт влиять на исход — окно выбора времени закрыто. Живая проверка (ставка №10, релиз updown-relayer:3): цена на момент экспирации 9977000000 текущая цена в момент клика 9976000000 exitPrice в транзакции 9977000000 -> взята история, не 'сейчас' статус payout_done Также: tools/ — скрипты проверки (price-history-probe, exploit-decisive, exploit-check, check-close-window.sh). Остаток: в контракте верхней границы окна по-прежнему нет (второй слой, не критично); серверное авто-закрытие — следующая итерация.
15 KiB
SECURITY.md — чек-лист безопасности игрового контура
Статус: открыт, ведётся с 2026-09-13.
Область: smart-updown-token/relayer (шлюз), контракт smart-updown (program 9ALs…),
шина NATS, test-front. Стенд: testfront.binom.pw (k3s ns game).
Аудит проведён на живом стенде, не по чтению кода: воспроизведение эксплойта, проверка ручек из внешнего интернета, проверка аутентификации шины. Фактические выводы приведены в строках.
Пункты нумеруются сквозным SEC-N (не меняется при добавлении/закрытии пунктов) — ссылаться на них
номером. Отмечать закрытое — [ ] → [x], в закрытую строку добавлять дату и коммит/релиз.
Сводка: открыто 12 — критичных 3 (SEC-3, SEC-5), экономика 3 (SEC-7…SEC-9),
инфраструктура 3 (SEC-10…SEC-12), отложено 3 (SEC-13…SEC-15). Закрыто: 4 —
SEC-6 (2026-09-13, updown-relayer:2 / test-front:3),
SEC-1, SEC-2, SEC-4 (2026-09-13, updown-relayer:3, коммит 0b136c6).
Критично — эксплуатируется
- SEC-1. Нет верхней границы окна закрытия ставки — ЗАКРЫТ 2026-09-13 по сути (релиз
updown-relayer:3, коммит0b136c6). Контракт по-прежнему не ограничивает время закрытия, но выбирать момент расчёта больше нельзя: шлюз берёт цену закрытия из локальной истории на моментexpire_timeставки (history.priceAtOrBefore(expireTime*1000)), а не текущую. Поздний клик теперь даёт ту же цену, что и своевременный. Место правки:relayer/src/gateway.ts(handleClose),relayer/src/priceHistory.ts. Проверено на живом стенде (ставка №10): цена на момент экспирации9977000000, текущая цена в момент клика9976000000→exitPrice=9977000000(взята история), ставка финализированаpayout_done. Уязвимый код взял бы9976000000. Остаток (не критично): в контракте верхней границы так и нет — открытый вопрос «как долго позволять закрывать» остаётся косметикой, поскольку цена уже зафиксирована. Строгий фикс на цепочке (тот, что был предложен изначально) — по-прежнему желателен как второй слой. - SEC-2. Нет авто-закрытия ставок на сервере — ЗАКРЫТ 2026-09-13 по сути (релиз
updown-relayer:3). Серверное авто-закрытие по-прежнему не сделано (следующая итерация, по решению владельца), но риск снят: исход ставки больше не зависит от того, кто и когда позвал/close, потому что цена закрытия определяется историей. Опоздание сервера перестало быть риском — дозакрытие даст ту же цену. Место:relayer/src/gateway.ts(handleClose). Остаток: без авто-закрытия незакрытые ставки продолжают запирать средства в ваулте (см. SEC-8) — вопрос экономики, не честности расчёта. - SEC-3.
POST /closeдоступен из интернета без авторизации — любой может закрыть чужую ставку в выбранный им момент. Проверено:curl -X POST https://testfront.binom.pw/api/close -d '{"id":"1"}'→ обработчик выполняется. Фикс: авторизация + серверное закрытие. - SEC-4. Безрисковый арбитраж на паре ставок UP+DOWN — ЗАКРЫТ 2026-09-13 по сути (релиз
updown-relayer:3, коммит0b136c6). Схема опиралась на возможность выбрать выгодный момент закрытия; теперь цена закрытия фиксируется историей на моментexpire_time, и обе половины пары закрываются по одной и той же цене независимо от времени клика. Арбитраж исчезает вместе с SEC-1. Прямого прогона пары UP+DOWN не делали — закрытие выводится из закрытия SEC-1 (тот же механизм). - SEC-5.
POST /betбез авторизации — любой может создавать ставки на произвольные адреса и накручивать счётчик раундовglobal.counter. Проверено:curl -X POST .../api/bet→ 200. Фикс: токен/подпись запроса. - SEC-6.
GET /faucet/{address}открыт наружу и выдаёт реальные средства — ЗАКРЫТ 2026-09-13 (релизыupdown-relayer:2,test-front:3). Что было: из двух механизмов ручки (handleFaucet) — (а) SOL-налив (requestAirdrop) технически только localnet (mainnet-RPC отдаёт410 Gone); (б) налив фантиков (mintTo) — НЕ был привязан к сети: работал везде, где у шлюза есть mint authority минта. Гейтом был только конфиг-флагSMART_ALLOW_AIRDROPс дефолтом"1"(включено), проверки кластера в коде не было; SOL-барьер обходился (airdrop звался лишь при балансе <2 SOL,ops.ts:204) — адрес с ≥2 SOL доходил прямо доmintTo. Было воспроизведено:curl …/api/faucet/6Q1ko…→{"sol":5,"tokenUnits":"10000000000"}на произвольный адрес без авторизации. Что сделано: (1) новый модульrelayer/src/faucetGate.ts—parseMode+faucetDenyReasonс политикой fail-closed: отказ, если режим неtest, если genesis-hash недоступен, или если он принадлежит mainnet-beta/devnet/testnet (5eykt4…/EtWTRA…/4uhcVJ…). (2)SMART_MODE(test|release), дефолтrelease— безопасно забыть, а не вспомнить. (3)handleFaucet: 403 на уровне режима и 403 на уровне сети, в логfaucet DENIED (<причина>). (4) Начисление вынесено из/bet— ставка больше не печатает денег, аaddressстал обязательным; перед ставкой проверяются балансы (SOL ≥ 0.01, фантиков ≥ суммы). (5) Новая ручкаPOST /wallet— создать custodial-кошелёк, ничего не наливает. (6) На пультеtest-front— отдельный блок «Кошелёк и пополнение» с кнопками «Создать кошелёк» / «Пополнить кошелёк». Проверено на живом стенде:/betбез адреса →400 "address is required…"(и ничего не начисляет);/betна непополненный кошелёк →400 "insufficient funds…"; полный кругPOST /wallet→GET /faucet→POST /bet→entryPrice(HTTP 200); гейт в обратную сторону:smart.mode=release→403 {"error":"faucet is disabled: only test mode (SMART_MODE=test)"}— начисления нет. Тесты: 6 новых проверок гейта,npm test24/24; мутационная приёмка пройдена (ослабление проверки публичных кластеров → падают ровно 2 теста). Остаточный риск: ручка по-прежнему публично маршрутизируется (/api/*наружу) — гейт теперь в коде, но лучше не выставлять её в интернет. Настройкаsmart.modeвhelm/values.yamlстенда =test; при выкладке в бой обязательноrelease(или убрать/api/faucetиз ингресса). Назначение (историческая справка): ручка задумана как тестовая (ТЗrelayer/faucet-TASK.md: «чтобы Phantom-кошелёк показал баланс > 0»), и на проде она не нужна — там деньги покупаются через swap.
Важно — экономика и устойчивость
- SEC-7. Множитель 1.3× требует от дома винрейта ~77 % —
SMART_MULTIPLIER_BPS=13000; порог безубытка = 1/1.3 = 76.9 %. При честной монете дом убыточен, а с дырой окна закрытия игрок забирает близко к 100 %. Фикс: пересчитать экономику под реальный edge. - SEC-8. Нефинализированные ставки запирают средства в ваулте —
vaultне уменьшается, пока ставку не закроют. Наблюдение:vaultBalanceHuman28100 → 30100 при незакрытых ставках. Фикс: серверное закрытие + клинап просроченных. - SEC-9. Равные
exit_price == entry_priceне финализируют ставку —close_betделаетreturn Ok(()), статус остаётсяopen; при «плоской» цене ставка зависает без исхода. Место:close_bet.rs:66. Фикс: определить политику (продление / возврат / ничья) вместо молчаливого выхода.
Средний приоритет — инфраструктура
- SEC-10. NATS без аутентификации —
auth_requiredне выставлен, анонимная публикация в шину проходит (проверено на тестовом subject; боевой не трогали). Пока цена условна — терпимо; как только станет денежной, у шины нет прав на запись и любой сможет напечатать свой курс. Фикс: auth + subject-ACL, шлюзу — read-only наmarket.price.*. - SEC-11. Ключ релейера — единственная точка доверия в контракте — контракт не проверяет соответствие цены рынку и принимает любое значение от подписанта (
create_bet.rs:75,close_bet.rs:62). Модель «релейер = оракул» стандартна, но компрометация одного ключаE7Hg…= произвольный исход всех ставок. Фикс: on-chain оракул (Pyth/Switchboard) для боя. - SEC-12.
entry_priceне проверяется ни контрактом, ни прод-бэкендом — передаётся вcreate_betцеликом на усмотрение вызывающего; контракт не может убедиться, что это цена моментаbet_time(create_bet.rs:46,75). Проверено на прод-бэкендеsmart-updown-server: вGameService.submitBetцена из подписанной tx НЕ сверяется со своимpriceSource—payload.entryPriceпроходит насквозь; сравнениеentryPrice != payload.entryPrice(GameService.kt:100-118) сверяет лишь JSON-подсказку с tx и на расхождении пишетlog.warn(«trusting the signed transaction»), не отклоняя. Валидируются толькоpaused, парсинг tx,payload.id == global.counterи границыamount. Клиент подписываетentry_priceсам → может зашить любое значение (entry_price=1→ UP выигрывает почти всегда). Важная инверсия моделей: сегодня custodia-шлюз стенда эту дыру случайно ЗАКРЫВАЕТ (цена берётся из NATS на сервере,gateway.ts:280), а «правильный» неcustodial прод-бэкенд — ОТКРЫТ. Значит переход на неcustodial-модель (/betпринимает подписанную tx, какsmart-updown-server) БЕЗ серверной проверкиentry_priceпо своему оракулу сделает хуже, чем есть. Фикс: внешний верифицируемый фид (Pyth/Switchboard) + серверная сверкаentry_priceс допуском до ретрансляции.
Отложено — зафиксировать как риск
- SEC-13.
SMART_ALLOW_AIRDROP=1задеплоен в боевом поде — флаг «только localnet» фактически включён на публичном стенде. Проверка:kubectl get pod -n game … -o jsonpath='{.spec.containers[0].env}'. Фикс: per-environment values, по умолчанию0. - SEC-14. Политика деградации при отсутствии цены не определена — сейчас таймаут
PRICE_WAIT_TIMEOUT_MS=15000, ставки просто падают. Место:relayer/src/priceSource.ts:9. Нужно решение: останавливать приём ставок / бэкфилл / заморозка раундов. - SEC-15. Аудит
binance-market(upstream цены) не проводился — по договорённости источник считается доверенным. Репоsubochev/binance-market, nsinvest. Проверить авторизацию публикации в NATS и целостность источника.
Ссылки
- Реализация цены и диаграммы:
docs/PRICE-NATS.md,docs/diagrams/price-flow.puml - Решения:
docs/DECISIONS.md— ADR-006 (предмет ставки), ADR-007 (источник), ADR-008 (реализация) - Контракт:
program/programs/smart-updown/src/instructions/{create_bet,close_bet}.rs - Шлюз:
relayer/src/{gateway,natsPrice,priceSource}.ts