Files
Hermes Agent 101a4b60a9 docs(sec): SEC-16 — безвозвратная утечка ренты PDA-ставки с кошелька игрока
Найдено при приёмке боевого домена smart-updown.binom.pw (симптом «SOL не обновляется»).

- SEC-16: при create_bet ренту PDA-аккаунта ставки (1 559 040 lamports = (96+32+128)*6960)
  платит БЕТТОР, а не шлюз; баланс шлюза-админа при этом не меняется (замер whose-fee.cjs).
  Аккаунт не закрывается никогда (в контракте 9ALs четыре инструкции, ни одной close = bettor)
  → рента теряется безвозвратно. В цепи 75 аккаунтов = 0.116928 SOL заперто, растёт линейно.
  Вердикт: на тестах терпимо (маскируется краном, 5 SOL = ~3207 раундов), перед mainnet — блокер.
- TASKS.md: фича «если цена не изменилась — подождать ещё 15 с» (НЕ доделана), заметка про
  боевой домен на общем TS-шлюзе и лаг крана 8–13 с.
- tools: доказательные скрипты (whose-fee, bet-pda-rent, faucet-latency, sol-balance-cycle,
  settle-math-test, backend-full-test) + README с питфоллами замера.
- k8s/smart-updown-api-ingress.yaml: /api,/ws вынесены вне helm (релиз игры их сносил).
2026-09-15 05:57:34 +03:00

27 KiB
Raw Permalink Blame History

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

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

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

Пункты нумеруются сквозным SEC-N (не меняется при добавлении/закрытии пунктов) — ссылаться на них номером. Отмечать закрытое — [ ] → [x], в закрытую строку добавлять дату и коммит/релиз.

Сводка: открыто 9 — критичных 1 (SEC-5, частично закрыт), экономика 2 (SEC-7, SEC-16), инфраструктура 3 (SEC-10…SEC-12), отложено 3 (SEC-13…SEC-15). Закрыто: 8 — 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-3, SEC-8 (2026-09-13, updown-relayer:6 — авто-закрытие + одна открытая ставка на адрес), SEC-9 (2026-09-14, updown-relayer:7 / test-front:5 — возврат залога при ничьей в контракте, коммит 42dfa58), плюс SEC-2 и SEC-4 усилены в :6.

Добавлено 2026-09-15: SEC-16 — безвозвратная утечка ренты PDA-аккаунта ставки с кошелька игрока (найдено при приёмке боевого домена smart-updown.binom.pw).

Итерация 2026-09-13 (updown-relayer:6) — что сделано: авто-закрытие ставок серверным планировщиком (settle.ts), ручка POST /close удалена (404), WS-события bet_open/bet_closed в существующем канале (/ws), ограничение «одна открытая ставка на адрес» (409), пакетный обход цепочки вместо поштучного (9.3× быстрее). Проверено живьём: 9/9 проверок сквозного сценария (кошелёк → faucet → ставка → блок второй ставки → авто-закрытие через 16 с → /close 404).


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

  • 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:6). Серверное авто-закрытие сделано: relayer/src/settle.ts — startScheduler крутит тик каждые SMART_SETTLE_INTERVAL_MS (дефолт 2000 мс), находит просроченные открытые ставки, берёт цену из истории на момент expire_time и сам зовёт close_bet. Клиент больше не участвует в закрытии вообще — ручки /close нет. Проверено на живом стенде: ставка id=14 закрыта сама через 16 с после экспирации, exitPrice=10037000000, status=2 (house_won), по WS пришло событие bet_closed. Дополнительно: в шлюзе включён пакетный обход (getProgramAccounts, 1 запрос вместо N) — замерено 3 мс против 28 мс у поштучного перебора, 9.3× быстрее, и время тика больше не растёт с числом ставок.
  • SEC-3. POST /close доступен из интернета без авторизации — ЗАКРЫТ 2026-09-13 (релиз updown-relayer:6). Ручка удалена: закрывать ставки снаружи больше нечем. Закрытие делает сам шлюз — серверный планировщик (relayer/src/settle.ts, startScheduler) каждые SMART_SETTLE_INTERVAL_MS (дефолт 2000 мс) обходит Bet-аккаунты, для просроченных (expire_time*1000 <= now) берёт цену из истории на момент экспирации и сам зовёт close_bet. Выгоды позвать чужую ставку в удобный момент больше нет — и звать нечего. Проверено на живом стенде: POST /close → 404; ставка id=14 (UP, вход 10038000000) закрыта автоматически через 16 с после экспирации, exitPrice=10037000000, status=2 (house_won) — при цене вниз ставка UP проиграла, то есть расчёт корректен по направлению. Остаток (не критично): авторизации на /bet по-прежнему нет (см. SEC-5).
  • SEC-4. Безрисковый арбитраж на паре ставок UP+DOWN — ЗАКРЫТ 2026-09-13 (релиз updown-relayer:6, вторая линия обороны). Схема опиралась на возможность выбрать выгодный момент закрытия; цена закрытия фиксируется историей на момент expire_time (релиз :3), и вдобавок пара больше физически не набирается: шлюз отклоняет вторую ставку, пока открыта первая. Арбитраж закрыт дважды — и по цене, и по возможности занять обе стороны. Проверено на живом стенде: на кошелёк с открытой ставкой id=14 ставка DOWN → 409 {"error":"you already have an open bet: close it before placing a new one","openBetId":"14"}. Первая ставка при этом ставится нормально (200).
  • SEC-5. POST /bet без авторизации — любой может создавать ставки на произвольные адреса. Проверено: curl -X POST .../api/bet → 200. Частично закрыт 2026-09-13 (релиз updown-relayer:6): накрутка счётчика global.counter больше не работает в промышленных масштабах — на адрес допускается только одна открытая ставка, вторая → 409. Ручка по-прежнему не авторизована, поэтому перебор адресов формально возможен. Фикс: токен/подпись запроса.
  • 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 test 24/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. Нефинализированные ставки запирают средства в ваулте — ЗАКРЫТ 2026-09-13 (релиз updown-relayer:6). Причины было две, обе устранены. (1) Закрывать было некому: авто-закрытия не существовало, а клиент мог просто не позвать /close — теперь шлюз закрывает сам (см. SEC-2), и ставка не может остаться висеть. (2) Можно было набрать много ставок разом: ничто не мешало занять сто ставок, заперев сто залогов; теперь на адрес допускается одна открытая ставка (вторая → 409), поэтому размер запертого одним человеком залога ограничен одной ставкой. Проверено на живом стенде: вторая ставка при открытой первой → 409; висевшая ставка id=14 закрыта автоматически через 16 с, средства разблокированы. Остаток (закрыт): если ставка попадала в «равные цены» (exit == entry), залог оставался запертым, а из-за правила «одна ставка на адрес» игрок терял возможность играть (409). Устранено 2026-09-14 вместе с SEC-9 — ничья возвращает залог и финализирует ставку, поэтому залипаний больше нет.

  • SEC-9. Равные exit_price == entry_price не финализировали ставку — ЗАКРЫТО 2026-09-14 (возврат залога в контракте). Было: close_bet.rs:66 делал return Ok(()), статус оставался open; settle.ts:303-304 берёт цену выхода по фиксированному моменту экспирации, поэтому ретрай каждые 2 с вечно получал ту же пару — залипание ВЕЧНОЕ. Замер: равные цены — 33.6 % окон (11 682 из 34 769 за 580 минут). Наложенный эффект SEC-8: игрок с залипшей ставкой получал 409 навсегда. Решение владельца: править контракт (залог лежит в ваулте под PDA — тронуть может только контракт, значит «настоящий возврат» возможен лишь правкой Rust). Программа обновляемая, authority GXd83Q… — у нас. Сделано:

    • constants.rs: STATUS_REFUNDED = 3 (0/1/2 не тронуты — зашиты в шлюз и в живых аккаунтах цепи).
    • close_bet.rs: при exit == entry — CPI-перевод amount из ваулта беттору под сидами ваулт-PDA, запись exit_price и status = 3, событие BetClosed { user_won: false, payout: amount, status: 3 }.
    • settle.ts: STATUS_REFUNDED, statusName → "refunded", статус 3 в условии финализации (иначе возврат попадал в unexpected status и WS-событие игроку не уходило).
    • check.ts: проверка формы события refunded.
    • Тест close_bet_equal_price_stays_open (закреплял старое поведение) → close_bet_equal_price_refunds + отклонение повторного закрытия.
    • UI (test-front/index.html): три исхода вместо двух — ничья больше не показывается как «ПРОИГРАЛ (house_won)»; API.md дополнен. Проверка (своими руками, не отчётом агента): cargo test-sbf 10/10 зелёных; npm run typecheck EXIT=0; две мутации (снять запись статуса / снять CPI-перевод) валят ровно новый тест, после отката снова 10/10. Деплой: программа 9ALs… апгрейднута (ProgramData расширен 247 848 → 267 848, хэш дампа из цепи совпал с локальным .so побайтово); шлюз updown-relayer:7 (helm rev 15); фронт test-front:5 (rev 5). Живой эффект: две залипшие ставки (#0, #1) закрылись сами статусом 3; в обеих транзакциях ваулт −500 и игрок +500 фантиков (проверено preTokenBalances/postTokenBalances), ваулт 26 850 → 25 850. Канал bet_closed → statusName: "refunded" подтверждён. Остаток: политика выбрана как «возврат залога без множителя»; доля ничьих ~33 % означает, что треть ставок не приносит дому ни прибыли, ни убытка (экономика остаётся положительной за счёт множителя 1.3×).
  • SEC-16. Рента PDA-аккаунта ставки списывается с кошелька ИГРОКА и НЕ возвращается — безвозвратная утечка 1 559 040 lamports за каждую ставку. Найдено 2026-09-15 при приёмке боевого домена smart-updown.binom.pw (симптом владельца: «SOL не обновляется / не растёт»). Механика. При create_bet создаётся PDA-аккаунт ставки (PDA([BET_SEED, id_le_u64]), размер 96 байт + 8 дискриминатор), и ренту за него платит беттор, а не шлюз. Рента = (96 + 32 + 128) × 6960 lamports = 1 559 040 — это не комиссия транзакции (та 5 000). Замер на цепи (pump-game-ops/tools/whose-fee.cjs): SOL игрока 5 000 000 000 → 4 998 440 960 (−1 559 040) при неизменном балансе шлюза-админа E7Hg… (4 997 858 09 320). Подтверждено по каждой ставке (tools/bet-pda-rent.cjs, id 60–74): у каждой PDA ровно 1 559 040 lamports. Почему безвозвратно. Аккаунт ставки никогда не закрывается. В контракте 9ALs… ровно четыре инструкции (lib.rs): initialize, create_bet, close_bet, update — ни одна не делает close = bettor / close_account / realloc (проверено грепом по program/programs/smart-updown/src). Рента остаётся приколотой к мёртвому аккаунту навсегда. Масштаб. В цепи уже 75 аккаунтов ставок = 0.116928 SOL запертой ренты (getProgramAccounts, dataSize:96), и она растёт линейно с числом ставок. Замер tools/faucet-latency.cjs: та же дельта воспроизводится на каждом раунде независимо от исхода (WIN/LOSS/TIE). По цене SOL $150–200 это $0.25–0.31 реальных денег за каждый раунд — до mainnet. Почему это пока не видно. Кран (fundBettor, ops.ts:204) догружает кошелёк до 5 SOL, как только баланс падает ниже 2 SOL — на localnet утечка маскируется, 5 SOL хватает на ~3 207 раундов. На mainnet airdrop'а нет, поэтому игрок увидит, что баланс тает сам по себе, без проигрышей. Вердикт: для тестов — терпимо (деньги фальшивые, маскируется краном). Перед выводом на mainnet — блокер, т.к. это списание реальных средств игрока без его убытка. Фикс (правка контракта + редеплой, на выбор):

    1. в close_bet добавить close = bettor на аккаунт bet — рента вернётся беттору при финализации;
    2. либо платить ренту с кошелька шлюза (relayer/admin), тогда игрок не теряет SOL вовсе;
    3. либо отдельная close_bet-инструкция очистки для уже накопленных аккаунтов.

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

  • 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, 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