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
@@ -58,6 +58,24 @@
|
||||
- seed prefix: `shine_p_manager_allow`
|
||||
- второй seed: `manager_wallet.as_ref()`
|
||||
|
||||
### 3.4. Правило создания PDA (защита от «минирования» адреса)
|
||||
|
||||
Все адреса PDA детерминированы (особенно тикеты: seed + последовательный `ticket_index`),
|
||||
поэтому злоумышленник может заранее вычислить адрес будущего тикета и перевести на него
|
||||
немного лампортов обычным system-переводом. Если бы создание шло строго через
|
||||
`system_instruction::create_account`, такой «подсев» приводил бы к ошибке
|
||||
«account already in use» и навсегда блокировал бы покупку/добавление тикета (griefing-DoS).
|
||||
|
||||
Поэтому `create_pda_account` создаёт аккаунт устойчиво к предзаполненному балансу:
|
||||
|
||||
- если на адресе нет лампортов — обычный `create_account` (быстрый путь);
|
||||
- если лампорты уже есть — «создание поверх предзаполненного»: добор ренты переводом,
|
||||
затем `allocate` + `assign` под подписью PDA.
|
||||
|
||||
Признак «инициализируемого» аккаунта (`is_uninitialized_account`) — пустые данные и
|
||||
владелец System Program; условие нулевого баланса намеренно не проверяется. Уже созданный
|
||||
программой PDA имеет данные/владельца = program id и повторно инициализирован не будет.
|
||||
|
||||
## 4. Состояния программы
|
||||
|
||||
### 4.1. `ConfigState`
|
||||
@@ -218,7 +236,8 @@
|
||||
- ticket создаётся только в `Q1`;
|
||||
- очередь `Q1` временно блокируется, если достигнут её суммарный лимит;
|
||||
- `payout_usd_cents = purchase_usd_cents * coef_ppm / COEF_SCALE_PPM`;
|
||||
- `recipient_wallet` записывается в ticket.
|
||||
- `recipient_wallet` записывается в ticket;
|
||||
- `recipient_wallet` **не должен совпадать с адресом inflow-вольта** (см. §10.1).
|
||||
|
||||
### 9.2. Важная деталь денежного потока
|
||||
|
||||
@@ -248,6 +267,7 @@ Signer должен совпадать с `manager_wallet` в `manager_allowance
|
||||
|
||||
- `queue_id` только `1`, `2` или `3`;
|
||||
- `payout_usd_cents > 0`;
|
||||
- `recipient_wallet` не равен адресу inflow-вольта (см. §10.1);
|
||||
- доступный allowance по очереди не меньше суммы тикета;
|
||||
- ticket PDA ещё не существует.
|
||||
|
||||
@@ -257,6 +277,21 @@ Signer должен совпадать с `manager_wallet` в `manager_allowance
|
||||
- allowance уменьшается;
|
||||
- агрегаты соответствующей очереди увеличиваются.
|
||||
|
||||
### 10.1. Запрет получателя, равного inflow-вольту (важно для безопасности)
|
||||
|
||||
`recipient_wallet` тикета не должен совпадать с адресом `inflow_vault` PDA.
|
||||
Причина: в `step_payout` выплата получателю исполняется прямой манипуляцией
|
||||
балансами (`transfer_from_vault`), где источником служит сам вольт. Если получатель
|
||||
равен вольту, один и тот же аккаунт попадает в инструкцию дважды (в Solana дубли
|
||||
делят общий `RefCell`), и второй mutable-займ лампортов завершается ошибкой. Такой
|
||||
тикет невозможно ни оплатить, ни пропустить — он навсегда блокирует обслуживание
|
||||
очереди (а если он в `Q1`, то и `Q2`/`Q3`) и замораживает средства вольта.
|
||||
|
||||
Поэтому равенство `recipient == inflow_vault` запрещено во всех точках, где
|
||||
получатель задаётся: `buy_ticket*`, `manager_add_ticket`, `change_ticket_recipient`.
|
||||
Дополнительно `transfer_from_vault` содержит защиту по умолчанию
|
||||
`require!(vault.key != recipient.key)`.
|
||||
|
||||
## 11. Инструкция `step_payout`
|
||||
|
||||
### Назначение
|
||||
@@ -334,6 +369,8 @@ next_index = tickets_paid + 1
|
||||
|
||||
### Ограничения
|
||||
|
||||
`new_recipient_wallet` не должен совпадать с адресом inflow-вольта (см. §10.1).
|
||||
|
||||
Нельзя менять получателя у следующего тикета на выплату в активной очереди.
|
||||
|
||||
Логика:
|
||||
|
||||
@@ -74,6 +74,23 @@ PDA экономических настроек:
|
||||
users_economy_config_pda = PDA(["shine_users_economy_config"], shine_users_program_id)
|
||||
```
|
||||
|
||||
### 3.3. Правило создания PDA (защита от «минирования» адреса)
|
||||
|
||||
Адрес пользовательской PDA выводится из логина и публично предсказуем: зная желаемый логин,
|
||||
любой может заранее вычислить адрес записи и перевести на него немного лампортов обычным
|
||||
system-переводом. Если бы создание шло строго через `system_instruction::create_account`,
|
||||
такой «подсев» приводил бы к ошибке «account already in use» и навсегда блокировал бы
|
||||
регистрацию этого логина (targeted-DoS / сквоттинг логинов), причём без оплаты комиссии.
|
||||
|
||||
Поэтому `create_pda_account` создаёт аккаунт устойчиво к предзаполненному балансу:
|
||||
|
||||
- если на адресе нет лампортов — обычный `create_account` (быстрый путь);
|
||||
- если лампорты уже есть — «создание поверх предзаполненного»: добор ренты переводом,
|
||||
затем `allocate` + `assign` под подписью PDA.
|
||||
|
||||
Проверки повторной инициализации (`owner == System Program` и пустые данные) остаются и
|
||||
не зависят от баланса аккаунта.
|
||||
|
||||
## 4. Состояния программы
|
||||
|
||||
### 4.1. `UsersEconomyConfigState`
|
||||
|
||||
@@ -741,6 +741,10 @@ fn process_manager_add_ticket(
|
||||
|
||||
require!(args.payout_usd_cents > 0, PaymentsError::InvalidPayoutAmount);
|
||||
require!(is_valid_queue_id(args.queue_id), PaymentsError::InvalidTicketQueue);
|
||||
// Получатель не должен совпадать с inflow-вольтом (см. подробности в
|
||||
// buy_ticket_by_purchase_usd): иначе тикет навсегда застрянет в step_payout.
|
||||
let (inflow_vault_addr, _) = find_single_pda(program_id, settings::INFLOW_VAULT_SEED);
|
||||
require!(args.recipient_wallet != inflow_vault_addr, PaymentsError::InvalidTicketRecipient);
|
||||
|
||||
let (expected_manager_pda, _) = find_manager_allowance_pda(program_id, signer.key);
|
||||
require_keys_eq!(expected_manager_pda, *manager_allowance_pda.key, PaymentsError::InvalidPdaAddress);
|
||||
@@ -868,6 +872,11 @@ fn process_change_ticket_recipient(
|
||||
require!(account_iter.next().is_none(), PaymentsError::InvalidInstruction);
|
||||
|
||||
validate_singleton_state_pda(program_id, queues_pda, settings::QUEUES_SEED)?;
|
||||
// Новый получатель не должен совпадать с inflow-вольтом (см. подробности в
|
||||
// buy_ticket_by_purchase_usd): иначе тикет навсегда застрянет в step_payout.
|
||||
let (inflow_vault_addr, _) = find_single_pda(program_id, settings::INFLOW_VAULT_SEED);
|
||||
require!(args.new_recipient_wallet != inflow_vault_addr, PaymentsError::InvalidTicketRecipient);
|
||||
|
||||
let queues = read_state::<QueuesState>(queues_pda)?;
|
||||
let mut ticket = read_state::<TicketState>(ticket_pda)?;
|
||||
require!(!ticket.is_paid, PaymentsError::TicketAlreadyPaid);
|
||||
@@ -1010,6 +1019,11 @@ fn buy_ticket_by_purchase_usd(
|
||||
let mut queues = read_state::<QueuesState>(ctx.queues_pda)?;
|
||||
|
||||
require_keys_eq!(*ctx.dao_wallet.key, config.dao_wallet, PaymentsError::InvalidDaoWallet);
|
||||
// Получатель тикета не должен совпадать с inflow-вольтом: иначе в step_payout
|
||||
// вольт окажется и источником, и получателем перевода (алиасинг одного аккаунта),
|
||||
// второй mutable-займ в transfer_from_vault упадёт, и такой тикет навсегда
|
||||
// заморозит обслуживание очереди. Запрещаем такой recipient на входе.
|
||||
require!(recipient_wallet != config.inflow_vault, PaymentsError::InvalidTicketRecipient);
|
||||
let queue1_sum_total_before = queues.q1_sum_total_usd_cents;
|
||||
require!(queue1_sum_total_before < coef_limit.limit_usd_cents, PaymentsError::QueueTemporarilyPaused);
|
||||
|
||||
@@ -1192,13 +1206,31 @@ fn create_pda_account<'info>(
|
||||
space: u64,
|
||||
) -> ProgramResult {
|
||||
require!(is_uninitialized_account(pda), PaymentsError::PdaAlreadyExists);
|
||||
let lamports = Rent::get()?.minimum_balance(space as usize);
|
||||
let create_ix = system_instruction::create_account(payer.key, pda.key, lamports, space, program_id);
|
||||
invoke_signed(
|
||||
&create_ix,
|
||||
&[payer.clone(), pda.clone(), system_program_ai.clone()],
|
||||
&[seeds],
|
||||
)
|
||||
let required_lamports = Rent::get()?.minimum_balance(space as usize);
|
||||
let current_lamports = pda.lamports();
|
||||
|
||||
if current_lamports == 0 {
|
||||
// Быстрый путь: адрес пуст — обычное создание аккаунта одной инструкцией.
|
||||
let create_ix = system_instruction::create_account(payer.key, pda.key, required_lamports, space, program_id);
|
||||
return invoke_signed(
|
||||
&create_ix,
|
||||
&[payer.clone(), pda.clone(), system_program_ai.clone()],
|
||||
&[seeds],
|
||||
);
|
||||
}
|
||||
|
||||
// На адресе уже лежат лампорты — вероятно, «подсев» атакующим на заранее
|
||||
// известный детерминированный адрес тикета/PDA. Создаём «поверх предзаполненного»:
|
||||
// доводим ренту переводом, затем allocate + assign под подписью PDA.
|
||||
let top_up = required_lamports.saturating_sub(current_lamports);
|
||||
if top_up > 0 {
|
||||
let transfer_ix = system_instruction::transfer(payer.key, pda.key, top_up);
|
||||
invoke(&transfer_ix, &[payer.clone(), pda.clone(), system_program_ai.clone()])?;
|
||||
}
|
||||
let allocate_ix = system_instruction::allocate(pda.key, space);
|
||||
invoke_signed(&allocate_ix, &[pda.clone(), system_program_ai.clone()], &[seeds])?;
|
||||
let assign_ix = system_instruction::assign(pda.key, program_id);
|
||||
invoke_signed(&assign_ix, &[pda.clone(), system_program_ai.clone()], &[seeds])
|
||||
}
|
||||
|
||||
fn write_state<T: StateCodec>(pda: &AccountInfo, state: &T) -> ProgramResult {
|
||||
@@ -1219,9 +1251,15 @@ fn read_state<T: StateCodec>(pda: &AccountInfo) -> Result<T, ProgramError> {
|
||||
T::decode(&data[..encoded_len])
|
||||
}
|
||||
|
||||
// «Инициализируемым» считаем аккаунт без данных, всё ещё принадлежащий System
|
||||
// Program. Условие lamports() == 0 сознательно убрано: адреса тикетов и прочих
|
||||
// PDA детерминированы, и любой может заранее перевести на них немного лампортов,
|
||||
// чтобы заблокировать создание (griefing-DoS). Наличие лампортов больше не должно
|
||||
// мешать инициализации — за безопасное создание «поверх предзаполненного» отвечает
|
||||
// create_pda_account. Уже созданный нашей программой PDA имеет данные/владельца id()
|
||||
// и сюда не пройдёт.
|
||||
fn is_uninitialized_account(account: &AccountInfo) -> bool {
|
||||
account.lamports() == 0
|
||||
&& account.data_len() == 0
|
||||
account.data_len() == 0
|
||||
&& (*account.owner == system_program::ID || *account.owner == Pubkey::default())
|
||||
}
|
||||
|
||||
@@ -1235,6 +1273,9 @@ fn transfer_from_vault(vault: &AccountInfo, recipient: &AccountInfo, amount: u64
|
||||
if amount == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
// Защита по умолчанию от алиасинга: источник и получатель не должны быть одним
|
||||
// и тем же аккаунтом, иначе второй mutable-займ лампортов ниже завершится ошибкой.
|
||||
require!(vault.key != recipient.key, PaymentsError::InvalidTicketRecipient);
|
||||
let mut vault_lamports = vault.try_borrow_mut_lamports()?;
|
||||
let mut recipient_lamports = recipient.try_borrow_mut_lamports()?;
|
||||
require!(**vault_lamports >= amount, PaymentsError::NotEnoughInflowForStep);
|
||||
|
||||
@@ -1007,11 +1007,35 @@ fn transfer_lamports<'a>(payer: &AccountInfo<'a>, recipient: &AccountInfo<'a>, s
|
||||
invoke(&ix, &[payer.clone(), recipient.clone(), system_program_ai.clone()])
|
||||
}
|
||||
|
||||
// Создание PDA, устойчивое к «минированию» детерминированного адреса.
|
||||
// Адрес будущей записи логина выводится из самого логина, поэтому злоумышленник
|
||||
// может заранее вычислить адрес и перевести на него немного лампортов обычным
|
||||
// system-переводом. Тогда обычный system_instruction::create_account упал бы
|
||||
// («account already in use») и заблокировал бы регистрацию этого логина навсегда.
|
||||
// Чтобы это исключить, при уже существующих на адресе лампортах создаём аккаунт
|
||||
// «поверх предзаполненного»: доводим ренту переводом, затем allocate + assign
|
||||
// под подписью PDA. Подсев чужих лампортов больше ничего не ломает.
|
||||
fn create_pda_account<'a>(payer: &AccountInfo<'a>, pda: &AccountInfo<'a>, system_program_ai: &AccountInfo<'a>, owner: &Pubkey, seeds: &[&[u8]], space: usize) -> ProgramResult {
|
||||
let rent = Rent::get()?;
|
||||
let lamports = rent.minimum_balance(space);
|
||||
let ix = system_instruction::create_account(payer.key, pda.key, lamports, space as u64, owner);
|
||||
invoke_signed(&ix, &[payer.clone(), pda.clone(), system_program_ai.clone()], &[seeds])
|
||||
let required_lamports = rent.minimum_balance(space);
|
||||
let current_lamports = pda.lamports();
|
||||
|
||||
if current_lamports == 0 {
|
||||
// Быстрый путь: адрес пуст — обычное создание аккаунта одной инструкцией.
|
||||
let ix = system_instruction::create_account(payer.key, pda.key, required_lamports, space as u64, owner);
|
||||
return invoke_signed(&ix, &[payer.clone(), pda.clone(), system_program_ai.clone()], &[seeds]);
|
||||
}
|
||||
|
||||
// На адресе уже лежат лампорты (вероятно, «подсев» атакующим). Доводим баланс
|
||||
// до рент-экземпта, выделяем место и назначаем владельцем нашу программу.
|
||||
let top_up = required_lamports.saturating_sub(current_lamports);
|
||||
transfer_lamports(payer, pda, system_program_ai, top_up)?;
|
||||
|
||||
let allocate_ix = system_instruction::allocate(pda.key, space as u64);
|
||||
invoke_signed(&allocate_ix, &[pda.clone(), system_program_ai.clone()], &[seeds])?;
|
||||
|
||||
let assign_ix = system_instruction::assign(pda.key, owner);
|
||||
invoke_signed(&assign_ix, &[pda.clone(), system_program_ai.clone()], &[seeds])
|
||||
}
|
||||
|
||||
fn ensure_pda_size_and_rent<'a>(pda: &AccountInfo<'a>, payer: &AccountInfo<'a>, system_program_ai: &AccountInfo<'a>, required_len: usize) -> ProgramResult {
|
||||
|
||||
Reference in New Issue
Block a user