135 lines
14 KiB
Markdown
135 lines
14 KiB
Markdown
# Аудит безопасности 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`); обратных вызовов в наши программы нет.
|
||
|
||
---
|
||
|
||
## Приоритет действий
|
||
|
||
1. **LOW** — ✅ выполнено 12.06.2026: добавлено `require!(it.next().is_none(), …)` во
|
||
все инструкции `shine_users` для симметрии с `shine_payments` (L2).
|
||
2. **INFO** — зафиксировать в эксплуатационной документации known-issue гонки за
|
||
логином (L3) и экономику вольта (L4); рассмотреть привязку `init` к ожидаемому
|
||
деплой-кошельку (L1).
|
||
|
||
Критичных и высоких находок по полноте проверок входных аккаунтов в этом проходе
|
||
нет. Единственная LOW-правка (L2) применена в рамках этого же изменения; код
|
||
`shine_users` собирается успешно (`cargo build -p shine_users`).
|