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>
114 lines
14 KiB
Markdown
114 lines
14 KiB
Markdown
Аудит безопасности 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<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 (строки 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. Изменений в код я не вносил — это только анализ; готов подготовить патчи на оба критических пункта, если подтвердите. |