audit: сохранить отчёт по Solana

This commit is contained in:
AidarKC 2026-06-09 23:09:21 +04:00
parent e5fe925023
commit fb0c5ad3f8

View File

@ -0,0 +1,102 @@
Аудит безопасности 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<T>(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 через предсказуемые адреса тикетов
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. Изменений в код я не вносил — это только анализ; готов подготовить патчи на оба критических пункта, если подтвердите.