SHiNE-server/Dev_Docs/audit/Solana-audit-by-Claude-File5-9июня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

14 KiB
Raw Blame History

Аудит безопасности Solana-программ SHiNE

Проверены три программы в shine-solana/shine/programs/:

  • shine_login_guard (183 строки) — stateless-классификатор логинов
  • shine_users (1035 строк) — реестр пользователей, PDA-записи, подписи, экономика лимитов
  • shine_payments (1330 строк) — очереди тикетов, выплаты, оракул Pyth

Общая инженерная культура высокая: везде checked_*-арифметика, overflow-checks = true, ручная верификация ed25519 через sysvar инструкций, аккуратный bounds-checked парсинг. Но есть две критические дыры одного класса — отсутствие проверки адреса PDA, которые позволяют обойти всю экономику и украсть средства из вольта.


🔴 CRITICAL #1 — shine_users: economy-config PDA не валидируется → бесплатная регистрация и бесконечный лимит

В process_create_user_pda (строка 448) и process_update_user_pda (строка 525):

let economy = read_users_economy_config(users_economy_config_pda)?;

А read_users_economy_config (строки 633643) не проверяет ни адрес, ни владельца аккаунта — просто читает байты:

fn read_users_economy_config(pda: &AccountInfo) -> Result<...> { let raw = read_pda_all(pda)?; // try_borrow_data, без проверок require!(raw.len() >= 25, ...); Ok(UsersEconomyConfigState { version: raw[0], registration_fee_lamports: ..., ... }) }

Сравните: в init/update_economy_config адрес проверяется через find_users_economy_config_pda (строки 382, 414), а в create/update — нет.

Эксплуатация. Атакующий создаёт любой свой аккаунт с 25 байтами произвольного содержимого и передаёт его как users_economy_config_pda:

  • registration_fee_lamports = 0 → регистрация без оплаты;
  • start_bonus_limit = u64::MAX → запись пользователя сразу получает гигантский paid_limit_bytes (бесплатная безлимитная квота хранилища/блокчейна);
  • lamports_per_limit_step = 0 → бесплатное пополнение лимита на любую величину.

Комиссия (когда она ненулевая) уходит в правильный вольт — validate_inflow_vault это проверяет — но атакующему достаточно обнулить комиссию и накрутить лимит. Это полный обход экономической модели программы.

Фикс: в обеих функциях перед чтением добавить require_keys_eq!(find_users_economy_config_pda(program_id).0, *users_economy_config_pda.key, ShineUsersError::InvalidPdaAddress); require!(users_economy_config_pda.owner == program_id, ShineUsersError::InvalidPdaAddress);


🔴 CRITICAL #2 — shine_payments: singleton-PDA не привязаны к адресу → кража из вольта в step_payout

ensure_expected_pdas вызывается только в process_init (строка 519). Во всех остальных инструкциях config_pda, coef_limit_pda, queues_pda читаются через read_state, который проверяет только владельца (*pda.owner == id()), но не адрес:

fn read_state(pda) -> ... { require!(!is_uninitialized_account(pda), ...); require_keys_eq!(*pda.owner, id(), ...); // только owner, адрес НЕ проверяется ... }

Программа владеет аккаунтами нескольких типов (config, coef_limit, queues, vault, tickets, manager_allowances), и часть их содержимого атакующий контролирует напрямую. В TicketState поле recipient_wallet (32 байта по смещению 11) — полностью произвольные байты из BuyTicketArgs, это не обязан быть валидный ключ.

Эксплуатация (конкретная, практически реализуемая). В process_step_payout (строки 783846) coef_limit_pda не проверяется по адресу. Раскладка CoefLimitState при декодировании: version=byte0, coef_ppm=[1..9], limit=[9..17], call_reward_lamports=[17..25]. Байты [17..25] тикета попадают на recipient_wallet[6..14] — их атакующий задаёт сам при покупке тикета. То есть:

  1. Атакующий покупает тикет с recipient_wallet, чьи байты 6..14 кодируют огромный call_reward_lamports.
  2. В step_payout подставляет этот тикет как coef_limit_pda.
  3. transfer_from_vault(inflow_vault_pda, signer, coef_limit.call_reward_lamports) (строка 840) переводит подписанту (атакующему) почти весь доступный баланс вольта, который должен был накопиться для DAO.

version тикета = 1, decode значение версии не проверяет, поэтому подстановка проходит.

Подстановка config_pda для обхода DAO-авторизации в update_coef_limit/grant_manager_limits теоретически тоже возможна, но непрактична: там нужный dao_wallet пересекается со структурными полями тикета, и подбор потребовал бы грайндинга ~80 бит ключа. А вот путь через coef_limit/step_payout — реальная кража.

Фикс: во всех инструкциях, принимающих singleton-PDA, проверять адрес, например вызывать ensure_expected_pdas-подобную проверку (require_keys_eq!(find_single_pda(program_id, SEED).0, *pda.key, …)) для config/coef_limit/queues/inflow_vault, а для queues_pda в change_ticket_recipient — тоже.


🟠 MEDIUM — shine_payments: слабая валидация оракула Pyth

read_sol_usd_price / parse_pyth_price_update_v2 (строки 10381075):

  1. Не проверяется владелец аккаунта цены (что он принадлежит Pyth receiver). Спасает только то, что адрес жёстко закреплён константой PYTH_SOL_USD_ACCOUNT. Это делает подмену невозможной, но защита держится на одном инварианте.
  2. feed_id не проверяется. Константа PYTH_SOL_USD_FEED_ID объявлена в settings.rs, но в коде нигде не используется — программа доверяет, что в закреплённом аккаунте лежит именно SOL/USD.
  3. Фиксированные смещения (73/89/93) предполагают VerificationLevel::Full. Borsh сериализует enum переменной длиной: Partial{num_signatures} занимает 2 байта вместо 1, что сдвигает все поля на 1 байт и приведёт к чтению мусорной цены. Уровень верификации не проверяется.
  4. Confidence (conf) игнорируется — нет защиты от широкого ценового интервала.

Проверка возраста цены (ORACLE_MAX_AGE_SECS = 120) есть и сделана корректно. Рекомендация: проверять владельца аккаунта, сверять feed_id с константой и валидировать verification_level == Full (или парсить через официальный pyth_solana_receiver_sdk, который уже завендорен в .vendor/).


🟡 LOW — DoS через предсказуемые адреса тикетов — ИСПРАВЛЕНО (11.06.2026)

Закрыто: create_pda_account в shine_payments и shine_users переведён на паттерн «создание поверх предзаполненного» (allocate + assign + добор ренты вместо строгого system_instruction::create_account). «Подсев» лампортов на заранее известный адрес тикета или пользовательской записи больше не блокирует создание PDA. Проверка is_uninitialized_account в payments перестала зависеть от нулевого баланса. Тот же фикс закрывает аналогичный сквоттинг логинов в shine_users (адрес выводится из логина). Подробности — в doc/programs/shine_payments.md §3.4 и doc/programs/shine_users.md §3.3.

Историческое описание находки ниже.

is_uninitialized_account (строка 1195) считает аккаунт неинициализированным только если lamports() == 0. Адреса тикетов детерминированы (queue_seed + index), а индекс последователен и предсказуем. Любой может заранее перевести немного лампортов на адрес следующего тикета — тогда create_pda_account упадёт (PdaAlreadyExists / ошибка create_account), заблокировав покупку/добавление тикета. Это griefing-DoS, не кража. Митигировать можно паттерном «create поверх предзаполненного» (allocate + assign + добор ренты) вместо system_instruction::create_account.


Что проверено и сделано корректно

  • Верификация подписей ed25519 в shine_users (строки 885922): строго пинятся относительные индексы инструкций (1/2), требуется num_signatures == 1, все три ix-index == u16::MAX (данные внутри самой ed25519-инструкции — нельзя указать на чужую инструкцию), и сверяются pubkey/signature/message. Сделано грамотно.
  • Цепочка версий записи (version == record_number+1, prev_hash == hash(old)) — корректная защита от replay (строки 535536).
  • Авторизация обновления записи завязана на ed25519-подпись root_key, а не на подписанта-плательщика — случайный аккаунт обновить чужую запись не может.
  • Монотонность used_bytes/last_block_number и used_bytes <= paid_limit_bytes (строки 979986).
  • inflow_vault валидируется по derive из программы payments (строки 988993).
  • transfer_from_vault сохраняет рент-экземпт (вычитает minimum_balance через available_vault_lamports).
  • init обеих программ безопасен к front-run: значения берутся из констант, а не от вызывающего; повторная инициализация заблокирована проверкой «uninitialized».
  • Арифметика: overflow-checks = true в release-профиле + повсеместные checked_add/sub/mul. Парсинг везде с проверкой границ.
  • manager_allowance PDA — единственный из payments, чей адрес проверяется корректно во всех путях (строки 645646, 739740).
  • shine_login_guard — stateless, без аккаунтов и средств; рисков безопасности не несёт.

Приоритет действий

  1. Critical #1 — добавить проверку адреса+владельца economy-config в create/update shine_users. Тривиальный фикс, помощник find_users_economy_config_pda уже есть.
  2. Critical #2 — добавить проверку адресов всех singleton-PDA во все инструкции shine_payments (минимум coef_limit_pda/config_pda/queues_pda в step_payout и change_ticket_recipient).
  3. Medium — ужесточить парсинг Pyth (owner + feed_id + verification_level), либо перейти на завендоренный SDK.
  4. Low — учесть griefing-DoS на предсказуемых адресах тикетов.

Обе критические находки относятся к одному классу (Solana «missing ownership/address check» — самая частая категория эксплойтов), их стоит закрыть до любого деплоя в mainnet. Изменений в код я не вносил — это только анализ; готов подготовить патчи на оба критических пункта, если подтвердите.