Аудит безопасности 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 (строки 633–643) не проверяет ни адрес, ни владельца аккаунта — просто читает байты: 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 (строки 783–846) 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 (строки 1038–1075): 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 (строки 885–922): строго пинятся относительные индексы инструкций (−1/−2), требуется num_signatures == 1, все три ix-index == u16::MAX (данные внутри самой ed25519-инструкции — нельзя указать на чужую инструкцию), и сверяются pubkey/signature/message. Сделано грамотно. - Цепочка версий записи (version == record_number+1, prev_hash == hash(old)) — корректная защита от replay (строки 535–536). - Авторизация обновления записи завязана на ed25519-подпись root_key, а не на подписанта-плательщика — случайный аккаунт обновить чужую запись не может. - Монотонность used_bytes/last_block_number и used_bytes <= paid_limit_bytes (строки 979–986). - inflow_vault валидируется по derive из программы payments (строки 988–993). - transfer_from_vault сохраняет рент-экземпт (вычитает minimum_balance через available_vault_lamports). - init обеих программ безопасен к front-run: значения берутся из констант, а не от вызывающего; повторная инициализация заблокирована проверкой «uninitialized». - Арифметика: overflow-checks = true в release-профиле + повсеместные checked_add/sub/mul. Парсинг везде с проверкой границ. - manager_allowance PDA — единственный из payments, чей адрес проверяется корректно во всех путях (строки 645–646, 739–740). - 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. Изменений в код я не вносил — это только анализ; готов подготовить патчи на оба критических пункта, если подтвердите.