From fb0c5ad3f80bad03512c45cc9a82c5efda96a232371799cf41a74c98dcb5aa17 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Tue, 9 Jun 2026 23:09:21 +0400 Subject: [PATCH] =?UTF-8?q?audit:=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BE=D1=82=D1=87=D1=91=D1=82=20=D0=BF?= =?UTF-8?q?=D0=BE=20Solana?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Solana-audit-by-Claude-File5-9июня2026.md | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/Dev_Docs/audit/Solana-audit-by-Claude-File5-9июня2026.md b/Dev_Docs/audit/Solana-audit-by-Claude-File5-9июня2026.md index 473a0f4..82b280f 100644 --- a/Dev_Docs/audit/Solana-audit-by-Claude-File5-9июня2026.md +++ b/Dev_Docs/audit/Solana-audit-by-Claude-File5-9июня2026.md @@ -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 (строки 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(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 через предсказуемые адреса тикетов + +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. Изменений в код я не вносил — это только анализ; готов подготовить патчи на оба критических пункта, если подтвердите. \ No newline at end of file