На 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.

Критерії безпечного патерна:

  1. Усі зчитування, що впливають на логіку, виконуються після останнього CPI, який міг змінити відповідний акаунт.
  2. Якщо CPI не передає акаунт як writable, використання попередньо збережених значень допустиме — стан не міг змінитися.
  3. Інваріанти (наприклад, «баланс не менший за X») перевіряються після CPI, а не до.

Специфічні випадки, на які варто звернути увагу:

  • Переведення SOL через system_program::transfer. Після переказу завжди перечитуйте lamports, якщо баланс впливає на подальшу логіку.
  • CPI до токен-програми (spl-token). Після mint, burn, transfer перечитуйте баланс токен-акаунта, якщо він використовується в розрахунках.
  • CPI до невідомих або оновлюваних програм. Якщо викликана програма може бути оновлена (upgradeable), її поведінка може змінитися. Це окремий вектор ризику, але він посилює небезпеку stale-state.
  • Закриття акаунтів через CPI. Якщо CPI може закрити акаунт, будь-яке звернення до нього після CPI без перевірки lamports > 0 і owner призведе до помилки виконання або, гірше, до логічної вразливості, якщо помилка оброблена некоректно.

План відкату при виявленні вразливості:

  1. Визначте всі інструкції, де CPI передає writable-акаунт, дані якого використовуються до виклику.
  2. Перенесіть зчитування після CPI. Якщо це неможливо через архітектурні обмеження, додайте явну перевірку: порівняйте збережене значення з актуальним і відхиліть транзакцію при розбіжності.
  3. Розгорніть оновлену програму як upgrade (якщо програма upgradeable) або як нову версію з міграцією стану.
  4. Якщо програма не 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. Безпековий аудит цього класу вразливостей вимагає експертного перегляду — автоматизовані інструменти дають допоміжний, а не вирішальний результат.

Джерела