Files
pump-game-ops/docs/SECURITY.md
T
subochev 18ecbb42b3 docs(security): чек-лист безопасности игрового контура (аудит живого стенда)
15 пунктов, отмечаются [ ]/[x]. Критичное найдено воспроизведением на
testfront.binom.pw, не чтением кода:
- close_bet проверяет только нижнюю границу окна (now >= expire_time) —
  верхней нет, ставка висит open бесконечно, момент расчёта выбирает
  вызывающий /close;
- в шлюзе нет авто-закрытия (единственный setInterval — WS-heartbeat);
- /bet, /close, /faucet открыты наружу без авторизации; faucet выдал
  5 SOL + 10000 фантиков при SMART_ALLOW_AIRDROP=1 в боевом поде;
- эксплойт воспроизведён: ставка DOWN (entry=9998000000) висела open >2 мин
  после экспирации 15с и закрыта при exitPrice=9997000000 -> payout_done.
Плюс: множитель 1.3x требует 77% винрейта, запертые средства в ваулте,
равные цены не финализируют ставку, NATS без auth, релейер — точка доверия.
2026-09-13 12:40:51 +03:00

8.2 KiB

SECURITY.md — чек-лист безопасности игрового контура

Статус: открыт, ведётся с 2026-09-13. Область: smart-updown-token/relayer (шлюз), контракт smart-updown (program 9ALs…), шина NATS, test-front. Стенд: testfront.binom.pw (k3s ns game).

Аудит проведён на живом стенде, не по чтению кода: воспроизведение эксплойта, проверка ручек из внешнего интернета, проверка аутентификации шины. Фактические выводы приведены в строках.

Отмечать закрытое — [ ] → [x]; в закрытую строку добавлять дату и коммит/релиз.


Критично — эксплуатируется

  • Нет верхней границы окна закрытия — close_bet проверяет только now >= expire_time, но не now <= expire_time + WINDOW. Ставка остаётся open бесконечно, и момент расчёта выбирает тот, кто позвал /close. Место: program/programs/smart-updown/src/instructions/close_bet.rs:60. Фикс: require!(now <= bet.expire_time + CLOSE_WINDOW_SECONDS, Error::CloseWindowExpired).
  • Нет авто-закрытия на сервере — в шлюзе отсутствует таймер закрытия ставок; единственный setInterval в коде — WS-heartbeat. Экспирация ничего не значит, раунд завершает клиент. Место: relayer/src/gateway.ts:621. Фикс: серверный планировщик, закрывающий ставку в момент expire_time.
  • POST /close доступен из интернета без авторизации — любой может закрыть чужую ставку в выбранный им момент. Проверено: curl -X POST https://testfront.binom.pw/api/close -d '{"id":"1"}' → обработчик выполняется. Фикс: авторизация + серверное закрытие.
  • Безрисковый арбитраж на паре ставок UP+DOWN — при entry ≈ X обе ставки закрываются в свой выгодный момент, обе получают payout_done (пара как схема — вывод из дыры окна; прямого прогона пары не делали). Прямо воспроизведено: ставка №5 (DOWN, entry=9998000000) висела open >2 мин после экспирации 15 с и закрыта при exitPrice=9997000000 → {"status":1,"statusName":"payout_done"} — момент расчёта выбран вручную. Фикс: закрытие окна + серверное закрытие.
  • POST /bet без авторизации — любой может создавать ставки на произвольные адреса и накручивать счётчик раундов global.counter. Проверено: curl -X POST .../api/bet → 200. Фикс: токен/подпись запроса.
  • GET /faucet/{address} открыт наружу и выдаёт реальные средства — 5 SOL + 10 000 фантиков на любой адрес. Проверено: curl https://testfront.binom.pw/api/faucet/6Q1ko… → {"sol":5,"solLamports":5000000000,"tokenUnits":"10000000000","tokenHuman":"10000.000000"}. Гейт SMART_ALLOW_AIRDROP=1 выставлен в боевом поде. Фикс: SMART_ALLOW_AIRDROP=0 вне localnet, ручку не публиковать наружу.

Важно — экономика и устойчивость

  • Множитель 1.3× требует от дома винрейта ~77 % — SMART_MULTIPLIER_BPS=13000; порог безубытка = 1/1.3 = 76.9 %. При честной монете дом убыточен, а с дырой окна закрытия игрок забирает близко к 100 %. Фикс: пересчитать экономику под реальный edge.
  • Нефинализированные ставки запирают средства в ваулте — vault не уменьшается, пока ставку не закроют. Наблюдение: vaultBalanceHuman 28100 → 30100 при незакрытых ставках. Фикс: серверное закрытие + клинап просроченных.
  • Равные exit_price == entry_price не финализируют ставку — close_bet делает return Ok(()), статус остаётся open; при «плоской» цене ставка зависает без исхода. Место: close_bet.rs:66. Фикс: определить политику (продление / возврат / ничья) вместо молчаливого выхода.

Средний приоритет — инфраструктура

  • NATS без аутентификации — auth_required не выставлен, анонимная публикация в шину проходит (проверено на тестовом subject; боевой не трогали). Пока цена условна — терпимо; как только станет денежной, у шины нет прав на запись и любой сможет напечатать свой курс. Фикс: auth + subject-ACL, шлюзу — read-only на market.price.*.
  • Ключ релейера — единственная точка доверия в контракте — контракт не проверяет соответствие цены рынку и принимает любое значение от подписанта (create_bet.rs:75, close_bet.rs:62). Модель «релейер = оракул» стандартна, но компрометация одного ключа E7Hg… = произвольный исход всех ставок. Фикс: on-chain оракул (Pyth/Switchboard) для боя.
  • entry_price не проверяется контрактом — передаётся в create_bet целиком на усмотрение вызывающего, контракт не может убедиться, что это цена момента bet_time. Место: create_bet.rs:46,75. Фикс: внешний верифицируемый фид.

Отложено — зафиксировать как риск

  • SMART_ALLOW_AIRDROP=1 задеплоен в боевом поде — флаг «только localnet» фактически включён на публичном стенде. Проверка: kubectl get pod -n game … -o jsonpath='{.spec.containers[0].env}'. Фикс: per-environment values, по умолчанию 0.
  • Политика деградации при отсутствии цены не определена — сейчас таймаут PRICE_WAIT_TIMEOUT_MS=15000, ставки просто падают. Место: relayer/src/priceSource.ts:9. Нужно решение: останавливать приём ставок / бэкфилл / заморозка раундов.
  • Аудит binance-market (upstream цены) не проводился — по договорённости источник считается доверенным. Репо subochev/binance-market, ns invest. Проверить авторизацию публикации в 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