14 KiB
Аудит безопасности Solana-программ SHiNE — выпуск 3 (12.06.2026)
Тематический аудит с фокусом на полноту проверок входных аккаунтов
(signer / owner / каноничный PDA-адрес / system-program / sysvar инструкций /
аккаунт оракула) — отвечает на вопрос «точно ли хватает всех проверок входных
аккаунтов». Код перечитан целиком после исправлений аудита №2
(Solana-audit-2-by-Claude-11июня2026.md):
shine_login_guard(183 строки) — stateless-классификатор логинов, аккаунтами не пользуется;shine_users(1068 строк) — реестр пользователей, PDA-записи, ed25519-подписи, экономика лимитов;shine_payments(1398 строк) — очереди тикетов, выплаты из вольта, оракул Pyth.
Это ручная (не-Anchor #[derive(Accounts)]) реализация на solana_program, поэтому
каждая проверка аккаунта выполняется явно в коде handler-а. Перебраны: подмена
аккаунтов/PDA, подмена владельца, bump-seed атаки, отсутствие signer/authority,
подмена system-program и sysvar, подмена аккаунта оракула, неинициализированные/
повторно инициализируемые PDA, «лишние» аккаунты.
Итоговый вердикт
Проверок входных аккаунтов достаточно во всех трёх программах. По каждому handler присутствуют все требуемые классы проверок; грубых дыр (подмена PDA на чужой аккаунт, отсутствие owner/signer-проверки, использование пользовательского bump, подмена аккаунта оракула) не найдено. Все Critical/HIGH из аудитов №1 и №2 закрыты и в этом проходе подтверждены в коде. Новых эксплуатируемых пробелов в валидации аккаунтов нет; есть несколько LOW/INFO-замечаний «by design».
Статус прошлых находок (подтверждено в коде на 12.06.2026)
- 🔴 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()) во всех инструкциях. - 🟠 Medium (валидация Pyth) — закрыто: пин адреса
PYTH_SOL_USD_ACCOUNT,owner == pyth_receiver,PriceUpdateV2,feed_id, возраст, доверительный интервал. - 🟡 Low (griefing на предсказуемых адресах) — закрыто:
create_pda_accountсоздаёт «поверх предзаполненного» в обеих программах. - 🔴 HIGH аудита №2 (
recipient_wallet == inflow_vaultзамораживает выплаты) — закрыто: запретrecipient == inflow_vaultвbuy_ticket_by_purchase_usd(стр. 1026),process_manager_add_ticket(стр. 747),process_change_ticket_recipient(стр. 878) + защита по умолчаниюrequire!(vault.key != recipient.key)вtransfer_from_vault(стр. 1278).
Матрица проверок входных аккаунтов
shine_users
| Инструкция | signer | owner PDA | адрес/seed PDA | system | sysvar / подпись | прочее |
|---|---|---|---|---|---|---|
init_users_economy_config |
✓ | owner == system + data_is_empty (анти-reinit) |
деривация + сверка | ✓ | — | значения из settings, не из ввода |
update_users_economy_config |
✓ + signer == DAO_AUTHORITY |
owner == program_id |
деривация + сверка | — | — | lamports_per_limit_step > 0 |
create_user_pda |
✓ + signer == client_key |
user_pda owner == system + empty; econ_config owner == program_id |
user_pda, econ_config, inflow_vault, login_guard — все сверены | ✓ | ed25519 (record sig idx −2, last_block idx −1) | inflow_vault сверен с PDA shine_payments; login_guard сверен дважды |
update_user_pda |
✓ + signer == client_key |
user_pda owner == program_id; econ_config owner == program_id |
деривация + сверка | ✓ | ed25519 + version == old+1 + prev_hash == hash(old) |
immutable-поля сверены с прежней записью |
shine_payments
| Инструкция | signer | owner / валидация PDA | адрес PDA | system | прочее |
|---|---|---|---|---|---|
init |
✓ payer | все 4 PDA is_uninitialized |
деривация + сверка | ✓ | dao_wallet из settings, нет лишних аккаунтов |
update_coef_limit |
✓ + signer == config.dao_wallet |
config/coef owner == id() |
деривация + сверка | — | границы coef/limit/reward; нет лишних аккаунтов |
grant_manager_limits |
✓ + signer == config.dao_wallet |
config owner == id(); allowance create/read |
allowance из manager_wallet |
✓ | state.manager_wallet == args.manager_wallet |
buy_ticket / _usd / _sol |
✓ | config/coef/queues owner == id() |
ticket деривация + сверка + is_uninitialized |
✓ | oracle (key+owner+возраст+confidence), dao_wallet == config.dao_wallet, recipient != inflow_vault, slippage |
manager_add_ticket |
✓ | allowance/queues owner == id() |
allowance из signer; ticket деривация + сверка + uninit |
✓ | allowance.manager_wallet == signer, queue_id ∈ {1,2,3}, recipient != inflow_vault |
step_payout |
✓ | все singleton-PDA owner == id() |
ticket деривация + сверка | — | dao_wallet == config.dao_wallet, inflow == config.inflow_vault, ticket queue/index/!is_paid/recipient, oracle |
change_ticket_recipient |
✓ + signer == ticket.recipient_wallet |
queues + ticket owner == id() (через read_state) |
ticket деривация из своих queue_id/index + сверка |
— | !is_paid, запрет менять «следующий к выплате», recipient != inflow_vault |
shine_login_guard
Аккаунты не используются (_accounts); программа stateless, средствами не владеет.
Защита со стороны вызова реализована в shine_users: сверяется и адрес вызываемой
программы (login_guard_program.key == SHINE_LOGIN_GUARD_PROGRAM_ID), и program_id
в get_return_data. Подмена/подделка ответа исключены. Отдельных проверок входных
аккаунтов внутри программы не требуется.
🟡 LOW / INFO — наблюдения без прямой эксплуатации
L1. Permissionless init в обеих программах
shine_payments::init и shine_users::init_users_economy_config может вызвать кто
угодно первым. Практического эксплойта нет: все значения (включая dao_wallet и
DAO_AUTHORITY) берутся из констант settings, а не из ввода, повторная
инициализация заблокирована проверками is_uninitialized / data_is_empty. Риск
низкий; при желании привязать init к ожидаемому деплой-кошельку. Совпадает с моделью
«первый init = деплой».
L2. В shine_users нет явной проверки «лишних аккаунтов» — ✅ ИСПРАВЛЕНО (12.06.2026)
shine_payments в каждом handler делает require!(account_iter.next().is_none()).
В shine_users такой проверки не было — лишние аккаунты в конце списка просто
игнорировались (читается строго нужное количество через next_account_info). Это
безвредно (на безопасность не влияло), но для симметрии и явности добавлено.
Класс: гигиена, не уязвимость.
Закрыто: во все 4 инструкции shine_users (init_users_economy_config,
update_users_economy_config, create_user_pda, update_user_pda) после чтения
фиксированного набора аккаунтов добавлено require!(it.next().is_none(), ShineUsersError::InvalidInstruction). Документация — doc/programs/shine_users.md §3.4.
L3. Гонка за логином (first-come) в shine_users — known issue
Адрес user_pda детерминирован из логина; после закрытия griefing-подсева остаётся
обычное состязание за регистрацию (front-run в мемпуле). On-chain решается только
commit-reveal; для текущей модели — приемлемый риск, ранее зафиксирован в аудите №2
(L2). К проверкам аккаунтов не относится.
L4. Экономическая устойчивость вольта (дизайн, не баг)
Деньги за покупку тикетов уходят на dao_wallet, а выплаты step_payout идут из
inflow_vault, наполняемого регистрационными комиссиями shine_users (коэффициент
по умолчанию START_COEF_PPM = 5x). При недостаточном притоке регистраций вольт
истощается и выплаты останавливаются (без потери средств). Это свойство
экономической модели «очередь/билеты», а не дефект валидации аккаунтов — отмечено
для полноты (ранее L4 в аудите №2). Мониторить баланс вольта vs обязательств.
✅ Проверено и подтверждено как корректное (по входным аккаунтам)
- Подмена PDA невозможна нигде: всюду пара «деривация
find_program_address+ сверка полного адреса». Пользовательский bump не принимается,create_program_addressс внешним bump не используется — bump-seed атаки исключены. - Проверка владельца при каждом чтении PDA:
read_stateиvalidate_singleton_state_pda(shine_payments) требуютowner == id();validate_users_economy_config_pdaи проверкаuser_pda.owner == program_id(shine_users) — перед десериализацией данных. - Создаваемые PDA: проверка
is_uninitialized/owner == system && data_is_emptyисключает повторную инициализацию и перезапись чужого аккаунта. - signer / authority: все handler начинают с обязательного
is_signer; привилегированные операции дополнительно сверяют ключ с авторитетом (config.dao_wallet,DAO_AUTHORITY,allowance.manager_wallet,ticket.recipient_wallet,client_key). - system-program сверяется с
system_program::IDтам, где идёт создание аккаунта/перевод; sysvar инструкций сверяется сsysvar::instructions::id()перед ed25519-интроспекцией. - Аккаунт оракула: пин адреса
PYTH_SOL_USD_ACCOUNT+owner == pyth_receiver+feed_id+ возраст (120 с) + доверительный интервал (10%). - Ed25519 в
shine_users: относительные индексы −1/−2,num_signatures == 1, все триix_index == u16::MAX(offset-данные внутри самой ed25519-инструкции), сверкаprogram_id == ed25519_programи pubkey/signature/message по хэшу — указать на чужую инструкцию нельзя. - Алиасинг аккаунтов:
recipient != inflow_vaultзапрещён на входе во всех точках задания получателя +vault.key != recipient.keyвtransfer_from_vault. inflow_vaultвshine_usersсверяется с PDA, выведенным изSHINE_PAYMENTS_PROGRAM_IDиSHINE_PAYMENTS_INFLOW_VAULT_SEED— комиссия не может уйти на чужой адрес.- Реентранси отсутствует: CPI только в System Program и в stateless
shine_login_guard(с проверкой возвращённогоprogram_id); обратных вызовов в наши программы нет.
Приоритет действий
- LOW — ✅ выполнено 12.06.2026: добавлено
require!(it.next().is_none(), …)во все инструкцииshine_usersдля симметрии сshine_payments(L2). - INFO — зафиксировать в эксплуатационной документации known-issue гонки за
логином (L3) и экономику вольта (L4); рассмотреть привязку
initк ожидаемому деплой-кошельку (L1).
Критичных и высоких находок по полноте проверок входных аккаунтов в этом проходе
нет. Единственная LOW-правка (L2) применена в рамках этого же изменения; код
shine_users собирается успешно (cargo build -p shine_users).