SHA256
solana: закрыть griefing создания PDA и заморозку выплат, добавить аудит №2
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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
01d9553db4
commit
cf6a2830c8
@@ -0,0 +1,33 @@
|
||||
# Запрет получателя тикета, равного inflow-вольту (защита от заморозки очереди)
|
||||
|
||||
## Краткое описание
|
||||
Закрыта HIGH-находка второго аудита (`Dev_Docs/audit/Solana-audit-2-by-Claude-11июня2026.md`).
|
||||
Тикет с `recipient_wallet == адрес inflow_vault` приводил к алиасингу аккаунта в
|
||||
`step_payout` (вольт одновременно источник и получатель), второй mutable-займ
|
||||
лампортов в `transfer_from_vault` падал, и такой тикет навсегда блокировал
|
||||
обслуживание очереди и замораживал средства вольта.
|
||||
|
||||
Исправление в `shine_payments`:
|
||||
- `buy_ticket_by_purchase_usd` — `require!(recipient_wallet != config.inflow_vault)`;
|
||||
- `process_manager_add_ticket` — запрет через `find_single_pda(INFLOW_VAULT_SEED)`;
|
||||
- `process_change_ticket_recipient` — тот же запрет для `new_recipient_wallet`;
|
||||
- `transfer_from_vault` — защита по умолчанию `require!(vault.key != recipient.key)`.
|
||||
|
||||
Ошибка во всех случаях — `InvalidTicketRecipient`.
|
||||
|
||||
## Что проверять (devnet/localnet)
|
||||
1. Покупка тикета с `recipient_wallet = <адрес inflow_vault PDA>` → отклоняется
|
||||
(`InvalidTicketRecipient`), тикет не создаётся.
|
||||
2. `manager_add_ticket` с тем же recipient → отклоняется.
|
||||
3. `change_ticket_recipient` с `new_recipient = inflow_vault` → отклоняется.
|
||||
4. Обычные покупки/выплаты с нормальным получателем → работают как раньше, очередь
|
||||
обслуживается, `step_payout` выплачивает корректно.
|
||||
5. Регресс не затронул выплаты в `dao_wallet` и `call_reward` подписанту (их адреса
|
||||
не совпадают с вольтом).
|
||||
|
||||
## Ожидаемый результат
|
||||
Тикет с получателем-вольтом невозможно создать; ранее существовавший вектор
|
||||
перманентной заморозки очереди закрыт. Прочая логика выплат без изменений.
|
||||
|
||||
## Статус
|
||||
pending
|
||||
@@ -0,0 +1,32 @@
|
||||
# Устойчивое создание PDA (защита от «минирования» адреса)
|
||||
|
||||
## Краткое описание
|
||||
В `shine_payments` и `shine_users` функция `create_pda_account` переведена с жёсткого
|
||||
`system_instruction::create_account` на паттерн «создание поверх предзаполненного»:
|
||||
- если на детерминированном адресе будущего PDA нет лампортов — обычный `create_account`;
|
||||
- если лампорты уже есть («подсев» атакующим) — добор ренты переводом, затем
|
||||
`allocate` + `assign` под подписью PDA.
|
||||
|
||||
Дополнительно в `shine_payments` `is_uninitialized_account` перестала требовать нулевой
|
||||
баланс (проверяет только пустые данные + владельца System Program).
|
||||
|
||||
Это закрывает последний (LOW) пункт аудита: griefing-DoS на покупку тикетов и сквоттинг
|
||||
логинов через предсказуемые адреса PDA.
|
||||
|
||||
## Что проверять (devnet/localnet)
|
||||
1. Обычная покупка тикета без «подсева» — создаётся как раньше (быстрый путь).
|
||||
2. На адрес следующего тикета заранее переведены лампорты обычным system-переводом →
|
||||
покупка/`manager_add_ticket` всё равно проходит, тикет создаётся корректно, данные
|
||||
и владелец = program id.
|
||||
3. На адрес `user_pda` будущего логина заранее переведены лампорты → регистрация логина
|
||||
(`create_user_pda`) всё равно проходит; запись и комиссия корректны.
|
||||
4. Повторная инициализация уже существующего тикета/пользователя по-прежнему отклоняется
|
||||
(`PdaAlreadyExists` / `UserAlreadyExists`).
|
||||
5. Singleton-PDA (`init`, economy config) и manager allowance создаются без регрессий.
|
||||
|
||||
## Ожидаемый результат
|
||||
Подсев лампортов на заранее известный адрес PDA не блокирует создание; вся остальная
|
||||
экономика и проверки повторной инициализации работают как прежде.
|
||||
|
||||
## Статус
|
||||
pending
|
||||
@@ -0,0 +1,177 @@
|
||||
# Аудит безопасности 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-перевод
|
||||
невозможен):
|
||||
|
||||
```rust
|
||||
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`:
|
||||
|
||||
```rust
|
||||
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`, равный адресу вольта, во всех точках, где он задаётся, чтобы
|
||||
тикет с таким получателем вообще не мог появиться:
|
||||
|
||||
1. в `buy_ticket_by_purchase_usd` — `require!(recipient_wallet != config.inflow_vault, …)`
|
||||
(config уже прочитан);
|
||||
2. в `process_manager_add_ticket` — сверять с `find_single_pda(INFLOW_VAULT_SEED).0`;
|
||||
3. в `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.
|
||||
|
||||
---
|
||||
|
||||
## Приоритет действий
|
||||
|
||||
1. **HIGH** — запретить `recipient == inflow_vault` в `buy_ticket*`, `manager_add_ticket`,
|
||||
`change_ticket_recipient`; добавить `require!(vault.key != recipient.key)` в
|
||||
`transfer_from_vault` как защиту по умолчанию. Закрыть до mainnet.
|
||||
2. **LOW** — зафиксировать правило «получатель ≠ системные PDA» (L1), оценить
|
||||
добавление верхней границы выплаты на шаг (L3).
|
||||
3. **INFO** — формально задокументировать экономику вольта (L4) и known-issue
|
||||
гонки за логином (L5/L2).
|
||||
|
||||
Изменений в код в рамках этого аудита не вносил — это анализ. Готов подготовить патч
|
||||
по пункту 1, если подтвердите.
|
||||
@@ -73,7 +73,19 @@ read_sol_usd_price / parse_pyth_price_update_v2 (строки 1038–1075):
|
||||
Проверка возраста цены (ORACLE_MAX_AGE_SECS = 120) есть и сделана корректно. Рекомендация: проверять владельца аккаунта, сверять feed_id с константой и валидировать verification_level == Full (или парсить через официальный pyth_solana_receiver_sdk, который уже завендорен в .vendor/).
|
||||
|
||||
---
|
||||
🟡 LOW — DoS через предсказуемые адреса тикетов
|
||||
🟡 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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user