SHiNE-server/Dev_Docs/audit/Solana-audit-2-by-Claude-11июня2026.md
AidarKC cf6a2830c8 solana: закрыть griefing создания PDA и заморозку выплат, добавить аудит №2
shine_payments + shine_users:
- create_pda_account переведён на «создание поверх предзаполненного»
  (allocate+assign+добор ренты), чтобы подсев лампортов на детерминированный
  адрес PDA (тикет/логин) не блокировал создание — закрыт LOW из аудита №1;
  в shine_payments is_uninitialized_account перестала зависеть от баланса.

shine_payments (HIGH из аудита №2):
- запрещён recipient == inflow_vault в buy_ticket*, manager_add_ticket и
  change_ticket_recipient; добавлена защита по умолчанию в transfer_from_vault
  (require vault.key != recipient.key). Это убирает алиасинг аккаунта в
  step_payout, который навсегда замораживал очередь выплат и средства вольта.

Документация и учёт:
- doc/programs/shine_payments.md §3.4, §10.1; doc/programs/shine_users.md §3.3;
- Dev_Docs/audit: добавлен аудит №2, обе закрытые находки помечены ИСПРАВЛЕНО;
- Dev_Docs/Pending_Features: две записи на ручную e2e-проверку на devnet;
- VERSION.properties: client 1.2.161, server 1.2.150.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 04:10:31 +04:00

16 KiB
Raw Permalink Blame History

Аудит безопасности 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 (строки 12581268) переводит лампорты из вольта прямой манипуляцией балансами (вольт — PDA без приватного ключа, обычный system-перевод невозможен):

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:

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_usdrequire!(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_ticketsigner == allowance.manager_wallet; change_ticket_recipientsigner == 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, если подтвердите.