# Аудит безопасности Solana-программ SHiNE — выпуск 2 (11.06.2026) Повторный независимый аудит после исправления всех 4 находок первого отчёта (`Solana-audit-by-Claude-File5-9июня2026.md`). Код перечитан целиком: - `shine_login_guard` (183 строки) — stateless-классификатор логинов; - `shine_users` (1069 строк) — реестр пользователей, PDA-записи, подписи, экономика лимитов; - `shine_payments` (1381 строка) — очереди тикетов, выплаты из вольта, оракул Pyth. Перебраны классы атак: подмена аккаунтов/PDA, авторизация и подписи, арифметика и переполнения, валидация оракула, экономика, реентранси, griefing/DoS, **алиасинг аккаунтов (передача одного аккаунта в несколько слотов инструкции)**. ## Статус прошлых находок (все закрыты) - 🔴 Critical #1 (economy-config PDA в `shine_users`) — закрыто: `validate_users_economy_config_pda` проверяет и адрес, и `owner == program_id`, и вызывается перед чтением и в create, и в update. - 🔴 Critical #2 (singleton-PDA в `shine_payments`) — закрыто: `validate_singleton_state_pda` проверяет точный адрес + `owner == id()` во всех инструкциях (`update_coef_limit`, `grant_manager_limits`, `buy_ticket*`, `manager_add_ticket`, `step_payout`, `change_ticket_recipient`). - 🟠 Medium (валидация Pyth) — закрыто: пин адреса аккаунта `PYTH_SOL_USD_ACCOUNT`, проверка `owner == pyth_receiver`, разбор официальным `PriceUpdateV2`, `get_price_no_older_than` с проверкой `feed_id`, проверка возраста и доверительного интервала (`ORACLE_MAX_CONFIDENCE_PPM`). - 🟡 Low (griefing на предсказуемых адресах) — закрыто: `create_pda_account` в обеих программах переведён на «создание поверх предзаполненного» (allocate + assign + добор ренты). --- ## 🔴 HIGH (НОВОЕ) — `shine_payments`: тикет с `recipient_wallet == inflow_vault` навсегда замораживает все выплаты — ✅ ИСПРАВЛЕНО (11.06.2026) Закрыто: равенство `recipient == inflow_vault` запрещено во всех точках задания получателя — `buy_ticket_by_purchase_usd` (через `config.inflow_vault`), `process_manager_add_ticket` и `process_change_ticket_recipient` (через `find_single_pda(INFLOW_VAULT_SEED)`). Дополнительно в `transfer_from_vault` добавлена защита по умолчанию `require!(vault.key != recipient.key)`. Документация — `doc/programs/shine_payments.md` §10.1. Историческое описание находки ниже. ### Где `transfer_from_vault` (строки 1258–1268) переводит лампорты из вольта прямой манипуляцией балансами (вольт — PDA без приватного ключа, обычный system-перевод невозможен): ```rust fn transfer_from_vault(vault: &AccountInfo, recipient: &AccountInfo, amount: u64) -> ProgramResult { if amount == 0 { return Ok(()); } let mut vault_lamports = vault.try_borrow_mut_lamports()?; // займ #1 let mut recipient_lamports = recipient.try_borrow_mut_lamports()?; // займ #2 ... } ``` В `step_payout` (строка 849) получатель — это `ticket.recipient_wallet`: ```rust transfer_from_vault(inflow_vault_pda, ticket_recipient_wallet, ticket_lamports)?; ``` А `recipient_wallet` нигде не валидируется при создании тикета: `buy_ticket*` (строки 696/711/725 → 1031), `manager_add_ticket` (строка 765), `change_ticket_recipient` (строка 900) — берут его «как есть» из аргументов. ### Суть атаки (алиасинг аккаунта) В Solana, если один и тот же аккаунт передан в инструкцию в нескольких слотах, рантайм отдаёт для всех слотов **один и тот же** `RefCell` (механизм дублей). Поэтому если `ticket.recipient_wallet` равен адресу `inflow_vault` PDA, то в `step_payout` аккаунт вольта попадает и в слот `inflow_vault_pda`, и в слот `ticket_recipient_wallet`. Тогда внутри `transfer_from_vault`: - `vault.try_borrow_mut_lamports()` — берёт mutable-займ (успех); - `recipient.try_borrow_mut_lamports()` — это **тот же** аккаунт → второй mutable-займ → `Err(AccountBorrowFailed)` → `?` возвращает ошибку → инструкция падает. ### Почему это «заморозка всего», а не один тикет Выплаты идут строго по возрастанию индекса. `step_payout` всегда обслуживает сначала очередь Q1 (если в ней есть pending), затем Q2, затем Q3, и в каждой — ровно «следующий неоплаченный» тикет (`paid + 1`). Тикет с `recipient == vault`: - не может быть оплачен (`step_payout` всегда падает на нём); - не может быть пропущен (нет механизма «skip»); - блокирует все тикеты после него в своей очереди; - если он в Q1 — блокирует обслуживание Q2 и Q3 (до них очередь не доходит); - лампорты вольта (накопленные регистрационные комиссии) перестают выплачиваться и не уходят в DAO (слив в DAO происходит только когда `pending == 0` по всем очередям, а это состояние недостижимо). ### Эксплуатация (тривиальная, перестановочная) Q1 — публичная очередь (`buy_ticket` доступен любому). Атакующий покупает **один** дешёвый тикет Q1, указав `recipient_wallet = <адрес inflow_vault PDA>`. Адрес вольта детерминирован и публичен (`find_single_pda(INFLOW_VAULT_SEED)`). С этого момента вся подсистема выплат и средства вольта заморожены за стоимость одного тикета + ренты. Дополнительно: даже при защите на этапе покупки остаётся вектор через `change_ticket_recipient` (строка 900) — владелец любого своего неоплаченного тикета может выставить `new_recipient_wallet = vault` позже. ### Класс и серьёзность Класс: «account aliasing / duplicate-account mutable borrow» + отсутствие валидации адреса получателя. Прямой кражи средств нет, но это перманентный отказ в обслуживании (availability) с блокировкой средств вольта, триггер — копеечный и доступен анонимно. Оценка: **HIGH**. ### Рекомендуемый фикс Запретить `recipient`, равный адресу вольта, во всех точках, где он задаётся, чтобы тикет с таким получателем вообще не мог появиться: 1. в `buy_ticket_by_purchase_usd` — `require!(recipient_wallet != config.inflow_vault, …)` (config уже прочитан); 2. в `process_manager_add_ticket` — сверять с `find_single_pda(INFLOW_VAULT_SEED).0`; 3. в `process_change_ticket_recipient` — то же для `new_recipient_wallet`. Дополнительно (defense-in-depth) — в `transfer_from_vault` явно `require!(vault.key != recipient.key, …)` с понятной ошибкой, чтобы любой будущий вызов был защищён от алиасинга. Этого `require` недостаточно как единственной меры (тикет всё равно застрял бы), поэтому основная защита — на входе. --- ## 🟡 LOW / INFO — наблюдения без прямой эксплуатации ### L1. `change_ticket_recipient` и `buy_ticket` не проверяют получателя на «опасные» адреса Связано с HIGH выше; после фикса основной проблемы стоит заодно зафиксировать правило «получатель не должен совпадать с системными PDA программы». ### L2. Гонка за логином (first-come) в `shine_users` Адрес `user_pda` выводится из логина. После закрытия griefing-подсева остаётся обычное состязание: увидев в мемпуле регистрацию `alice`, атакующий может зарегистрировать `alice` со своим `root_key` первым. On-chain это решается только commit-reveal; для текущей модели — приемлемый риск, отметить как известный. ### L3. `step_payout` без slippage-параметра Выплата считается по текущей цене оракула без верхней границы лампортов. Цена ограничена возрастом (120с) и доверительным интервалом (10%), аккаунт оракула запинен — манипуляция маловероятна, но при резком движении цены SOL объём выплаты в лампортах плавает. Риск низкий; при желании добавить верхнюю границу на шаг. ### L4. Экономическая устойчивость вольта (дизайн, не баг) Деньги за покупку тикетов (`buy_ticket`) уходят на `dao_wallet`, а выплаты в `step_payout` идут из `inflow_vault`, который наполняется **регистрационными комиссиями** `shine_users`. Если поток регистраций меньше обязательств по выплатам, вольт истощается и выплаты останавливаются (без потери средств, но с остановкой сервиса). Это свойство экономической модели — стоит явно держать в уме и мониторить баланс вольта/обязательств. ### L5. Заполнение Q1 до лимита как мягкий DoS `buy_ticket` блокируется при `q1_sum_total >= limit_usd_cents`. Атакующий может наполнить Q1 своими тикетами и приостановить покупки. Дорого (тратит SOL в DAO и ренту) и его же тикеты потом оплачиваются из вольта, поэтому это скорее экономический, а не дешёвый griefing. Риск низкий. --- ## ✅ Проверено и подтверждено как корректное - **Подмена singleton-PDA** невозможна: везде сверяется точный адрес и владелец. - **Авторизация**: `update_coef_limit`/`grant_manager_limits` требуют `signer == config.dao_wallet`; `manager_add_ticket` — `signer == allowance.manager_wallet`; `change_ticket_recipient` — `signer == ticket.recipient_wallet`; обновление economy-config — `signer == DAO_AUTHORITY`. - **Ed25519 в `shine_users`**: строгие относительные индексы (−1/−2), `num_signatures == 1`, все три `ix_index == u16::MAX` (данные внутри самой ed25519-инструкции), сверка pubkey/signature/message по хэшу. Подмена и указание на чужую инструкцию исключены. - **Цепочка версий записи** (`version == record_number+1`, `prev_hash == hash(old)`) — корректная защита от replay; сигнатура записи завязана на `root_key`, а не на плательщика. - **Монотонность** `used_bytes`/`last_block_number` и `used_bytes <= paid_limit_bytes`. - **Арифметика**: повсеместные `checked_*`, `overflow-checks = true`, расчёты оракула в `u128` с `u64::try_from` на сужении. - **Оракул Pyth**: пин аккаунта + owner + feed_id + возраст + confidence через официальный SDK. - **Рент-экземпт вольта** сохраняется: `available_vault_lamports` вычитает `minimum_balance`, а суммарная проверка `available >= needed` гарантирует, что после выплат вольт не опустится ниже ренты. - **Двойная оплата тикета** исключена: `is_paid` + инкремент `*_tickets_paid`, следующий шаг адресует следующий индекс. - **Реентранси отсутствует**: CPI только в System Program (transfer/allocate/assign) и в stateless `shine_login_guard` (с проверкой возвращённого `program_id`); обратных вызовов в наши программы нет. - **create_pda_account (новый)**: устойчив к подсеву лампортов; атакующий не может ни выделить данные, ни сменить владельца PDA (нет ключа/seeds), поэтому ветка allocate+assign безопасна. - **shine_login_guard**: stateless, без аккаунтов и средств; DFS-классификация ограничена (`MAX_WORDS_PER_LOGIN = 3`, длина ≤ 20) — без compute-DoS. --- ## Приоритет действий 1. **HIGH** — запретить `recipient == inflow_vault` в `buy_ticket*`, `manager_add_ticket`, `change_ticket_recipient`; добавить `require!(vault.key != recipient.key)` в `transfer_from_vault` как защиту по умолчанию. Закрыть до mainnet. 2. **LOW** — зафиксировать правило «получатель ≠ системные PDA» (L1), оценить добавление верхней границы выплаты на шаг (L3). 3. **INFO** — формально задокументировать экономику вольта (L4) и known-issue гонки за логином (L5/L2). Изменений в код в рамках этого аудита не вносил — это анализ. Готов подготовить патч по пункту 1, если подтвердите.