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>
16 KiB
Аудит безопасности 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-перевод
невозможен):
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, равный адресу вольта, во всех точках, где он задаётся, чтобы
тикет с таким получателем вообще не мог появиться:
- в
buy_ticket_by_purchase_usd—require!(recipient_wallet != config.inflow_vault, …)(config уже прочитан); - в
process_manager_add_ticket— сверять сfind_single_pda(INFLOW_VAULT_SEED).0; - в
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.
Приоритет действий
- HIGH — запретить
recipient == inflow_vaultвbuy_ticket*,manager_add_ticket,change_ticket_recipient; добавитьrequire!(vault.key != recipient.key)вtransfer_from_vaultкак защиту по умолчанию. Закрыть до mainnet. - LOW — зафиксировать правило «получатель ≠ системные PDA» (L1), оценить добавление верхней границы выплаты на шаг (L3).
- INFO — формально задокументировать экономику вольта (L4) и known-issue гонки за логином (L5/L2).
Изменений в код в рамках этого аудита не вносил — это анализ. Готов подготовить патч по пункту 1, если подтвердите.