На Solana класична reentrancy неможлива, але існують патерни, які призводять до аналогічних наслідків: виклик зовнішньої програми через CPI змінює стан акаунтів, а ваша програма продовжує працювати з застарілими даними. Нижче — інженерний розбір цих патернів, методи перевірки стану після CPI та інструменти для автоматичного виявлення таких вразливостей.
Чому Solana не має класичної reentrancy
У EVM-мережах reentrancy виникає через комбінацію трьох факторів: глобальне спільне стану, наявність fallback-функції та можливість повторного входу в той самий контракт до завершення попереднього виклику. Solana усуває всі три:
- Явна передача акаунтів. Кожна інструкція отримує лише ті акаунти, які явно передані в транзакції. Програма не може «знайти» акаунт глобально й звернутися до нього.
- Атомарність інструкції. Інструкція або повністю виконується й комітиться, або відкочується цілком. Немає проміжного стану, до якого можна повернутися ззовні.
- Відсутність fallback. Немає функції, яка викликається автоматично при отриманні SOL. Будь-який виклик через CPI — це явний виклик конкретної інструкції конкретної програми з конкретними акаунтами.
Але які патерни все ж небезпечні
Хоча класична reentrancy відсутня, Solana має власний клас вразливостей із подібним механізмом: stale-state після CPI. Суть полягає в тому, що ваша програма зчитує дані з акаунта, робить CPI-виклик, передаючи цей акаунт як writable, а потім використовує збережене до CPI значення, яке вже не відповідає фактичному стану.
Це не reentrancy у строгому сенсі, але наслідки ідентичні: зовнішня програма змінює стан між моментом зчитування й моментом використання даних.
CPI-виклики та зміна стану
Коли ваша програма викликає іншу програму через CPI і передає акаунт із правами запису (writable), викликана програма може:
- Змінити дані акаунта (data).
- Перевести lamports з акаунта на інший.
- Закрити акаунт (встановити lamports на нуль і встановити owner на систему).
- Перепризначити owner акаунта (якщо це дозволяє програма-власник).
Після повернення з CPI ваша програма продовжує виконання в межах тієї самої інструкції. Якщо вона покладається на значення, збережені до виклику, це потенційна вразливість.
Перевірка стану після CPI
Основне правило: ніколи не використовуйте значення, зчитані до CPI, після CPI, якщо акаунт, з якого зчитано ці значення, був переданий як writable. Замість цього перечитуйте стан безпосередньо перед використанням.
Розглянемо концептуальний приклад небезпечного патерна:
Небезпечно: зчитування до CPI, використання після
- Зчитуємо баланс акаунта A.
- Робимо CPI до програми B, передаючи A як writable.
- Використовуємо збережений баланс для розрахунку.
Безпечно: перечитування після CPI
- Робимо CPI до програми B, передаючи A як writable.
- Зчитуємо баланс акаунта A.
- Використовуємо свіжий баланс для розрахунку.
У Anchor це означає, що якщо ви працюєте з акаунтом через AccountInfo або UncheckedAccount і робите CPI з ним як writable, не покладайтеся на автоматичну десеріалізацію, яка відбулася на етапі входу в інструкцію. Перевірте актуальність даних після кожного CPI.
Критерії безпечного патерна:
- Усі зчитування, що впливають на логіку, виконуються після останнього CPI, який міг змінити відповідний акаунт.
- Якщо CPI не передає акаунт як writable, використання попередньо збережених значень допустиме — стан не міг змінитися.
- Інваріанти (наприклад, «баланс не менший за X») перевіряються після CPI, а не до.
Специфічні випадки, на які варто звернути увагу:
- Переведення SOL через system_program::transfer. Після переказу завжди перечитуйте lamports, якщо баланс впливає на подальшу логіку.
- CPI до токен-програми (spl-token). Після mint, burn, transfer перечитуйте баланс токен-акаунта, якщо він використовується в розрахунках.
- CPI до невідомих або оновлюваних програм. Якщо викликана програма може бути оновлена (upgradeable), її поведінка може змінитися. Це окремий вектор ризику, але він посилює небезпеку stale-state.
- Закриття акаунтів через CPI. Якщо CPI може закрити акаунт, будь-яке звернення до нього після CPI без перевірки lamports > 0 і owner призведе до помилки виконання або, гірше, до логічної вразливості, якщо помилка оброблена некоректно.
План відкату при виявленні вразливості:
- Визначте всі інструкції, де CPI передає writable-акаунт, дані якого використовуються до виклику.
- Перенесіть зчитування після CPI. Якщо це неможливо через архітектурні обмеження, додайте явну перевірку: порівняйте збережене значення з актуальним і відхиліть транзакцію при розбіжності.
- Розгорніть оновлену програму як upgrade (якщо програма upgradeable) або як нову версію з міграцією стану.
- Якщо програма не upgradeable — це критичний сценарій, який вимагає розгортання нової програми з новим program_id і міграції користувачів.
Виявлення небезпечних патернів
Небезпечні патерни можна виявити вручну під час код-рев'ю або автоматично за допомогою статичного аналізу. Ось фактичні ознаки, за якими їх ідентифікувати:
Патерн 1: Кешування даних перед CPI
- Програма зчитує поле з акаунта (наприклад, amount, balance, status) у локальну змінну.
- Далі в тій самій інструкції викликає CPI, передаючи цей акаунт як writable.
- Після CPI використовує локальну змінну для умовних переходів, арифметики або запису.
Патерн 2: Перевірка інваріантів до CPI
- Програма перевіряє умову (наприклад, balance >= required_amount).
- Робить CPI, який може змінити баланс.
- Не повторює перевірку перед фактичним використанням ресурсу.
Патерн 3: Багаторазовий CPI з тим самим writable-акаунтом
- Програма робить кілька CPI-викликів послідовно, передаючи один і той самий writable-акаунт.
- Стан акаунта між викликами не перевіряється.
- Кожен наступний виклик працює з припущенням про стан, який міг змінити попередній виклик.
Патерн 4: Використання UncheckedAccount після CPI
- Програма приймає акаунт як UncheckedAccount (без десеріалізації та перевірок Anchor).
- Робить CPI з цим акаунтом.
- Після CPI звертається до даних акаунта без повторної перевірки owner, data length чи фактичного вмісту.
При ручному аудиті для кожного CPI-виклику в програмі фіксуйте: які акаунти передані як writable, які дані з них зчитувалися до виклику й де ці дані використовуються після. Якщо є перетин — це кандидат на вразливість.
Інструменти для автоматичного виявлення
Статичний аналіз Solana-програм усе ще значно відстає від EVM-екосистеми за зрілістю інструментів, але кілька підходів вже практично застосовні:
1. Кастомні lint-правила для clippy
Можна створювати власні clippy-lint-правила, які шукають специфічні патерни в Rust-коді. Наприклад, правило, що попереджає, якщо змінна, прив'язана до акаунт-даних (через Account::try_from, AccountInfo::data або десеріалізацію Anchor), використовується після виклику invoke або invoke_signed з тим самим акаунтом у списку writable. Це вимагає написання власного lint-pass, але дає повний контроль над евристикою.
2. Статичні аналізатори на основі контрольних графів (CFG)
Інструменти, які будують контрольний граф функції й аналізують потоки даних між зчитуванням акаунт-даних і викликами invoke/invoke_signed. Якщо існує шлях від зчитування до використання, який проходить через CPI з writable-доступом до того ж акаунта, інструмент сигналізує про потенційну вразливість. Цей підхід реалізований у деяких комерційних аудиторських інструментах, але готових відкритих рішень для Solana наразі обмаль — перевірте актуальний стан репозиторіїв, оскільки ландшафт змінюється швидко.
3. Fuzzing з контрактом стану
Фаззери (наприклад, hfuzz з інтеграцією Solana BPF) можуть виявити stale-state-вразливості, якщо ви явно налаштуєте стратегію мутації, яка змінює поведінку викликаної через CPI програми між викликами. На практиці це складно: потрібно створити фіктивну програму-заглушку, яка приймає CPI й модифікує акаунт-дані непередбачувано, а потім перевіряє, чи основна програма коректно обробляє такі зміни.
4. Аналіз Anchor-програм через account constraints
Anchor частково пом'якшує проблему через автоматичну десеріалізацію й перевірки на вході в інструкцію. Проте Anchor не повторює десеріалізацію після CPI. Тому інструменти, які аналізують Anchor-код і шукають комбінації Account<T> + invoke з тим самим акаунтом у writable-списку, є корисним доповненням. Такий аналіз можна реалізувати як скрипт поверх AST Anchor-програми.
Обмеження автоматичних інструментів:
- Фalse positives високі, оскільки не кожне використання даних після CPI є вразливістю — іноді програма свідомо працює з застарілим значенням (наприклад, для логування).
- Аналіз взаємодій через CPI ускладнюється динамічною диспатчеризацією: програма-ціль CPI може визначатися під час виконання.
- Інструменти не замінюють експертний аудит — вони скорочують поверхню ручного перевірки.
Рекомендація для production: поєднуйте кастомні clippy-правила для виявлення очевидних патернів із ручним аудитуванням контрольних графів для критичних інструкцій. Для програм, що оперують значними коштами, обов'язковий незалежний аудит із фокусом на CPI-взаємодіях та stale-state. Безпековий аудит цього класу вразливостей вимагає експертного перегляду — автоматизовані інструменти дають допоміжний, а не вирішальний результат.