НОВОЕ: Helius приобретает Light Protocol
Путеводитель автостопщика по безопасности программ Solana
Блог/Основы

Путеводитель автостопщика по безопасности программ Solana

Developer Experience Engineer0xIchigo в X0xIchigo в LinkedIn0xIchigo на GitHub
Чтение: 39 мин
Содержание

Эта статья написана совместно с bl0ckpain — исследователем безопасности и разработчиком смарт-контрактов, ранее работавшим с Kudelski Security и Halborn

Введение

Безопасность программ Solana заключается не только в предотвращении кражи средств проекта хакерами. Она также гарантирует, что программа работает должным образом, соответствует спецификациям проекта и ожиданиям пользователей. Безопасность программ Solana может влиять на производительность, масштабируемость и совместимость dApp. Поэтому перед созданием приложений для конечных пользователей разработчики должны изучить потенциальные векторы атак и распространённые уязвимости.

В этой статье рассматриваются распространённые уязвимости, с которыми разработчики сталкиваются при создании программ Solana. Сначала мы познакомимся с мышлением злоумышленника при эксплуатации программ Solana. Мы рассмотрим модель программирования Solana, изначальный контроль злоумышленника над архитектурой Solana, потенциальные векторы атак и распространённые стратегии защиты. Затем мы разберём различные уязвимости, объясним их суть и, где это применимо, приведём примеры небезопасного и безопасного кода. 

Обратите внимание: эта статья предназначена для читателей среднего и продвинутого уровня и предполагает знание модели программирования Solana и разработки программ.

В этой статье не рассматривается процесс создания программы или специфические концепции Solana. Мы сосредоточимся на распространённых уязвимостях и способах их устранения. Если вы только начинаете работать с Solana, рекомендуем сначала прочитать предыдущие статьи нашего блога:

Мышление злоумышленника при эксплуатации программ Solana

Модель программирования Solana

Модель программирования Solana определяет особенности безопасности приложений, созданных в этой сети. В Solana аккаунты служат контейнерами для данных, подобно файлам на компьютере. Аккаунты можно разделить на два основных типа: исполняемые и неисполняемые. Исполняемые аккаунты, или программы, могут выполнять код. Неисполняемые аккаунты используются для хранения данных и не могут выполнять код, поскольку не содержат его. Такое разделение кода и данных означает, что программы не имеют собственного состояния. Они взаимодействуют с данными, хранящимися в других аккаунтах, которые передаются по ссылке во время транзакций.

Solana находится под контролем злоумышленника

Транзакция указывает вызываемую программу, список аккаунтов и массив байтов с данными инструкции. В этой модели программа сама анализирует и интерпретирует аккаунты и инструкции, переданные конкретной транзакцией. Возможность передать любой аккаунт в функцию программы даёт злоумышленникам значительный контроль над данными, с которыми будет работать программа. Понимание модели программирования Solana, изначально контролируемой злоумышленником, критически важно для разработки безопасных программ.

Поскольку злоумышленник может передать любой аккаунт в функцию программы, проверка данных становится основой безопасности программ Solana. Разработчики должны обеспечить способность программы отличать легитимные входные данные от вредоносных. Для этого необходимо проверять владельца аккаунта, соответствие аккаунта ожидаемому типу и наличие у аккаунта права подписи.

Потенциальные векторы атак

Уникальная модель программирования и среда выполнения Solana создают специфические векторы атак. Разработчикам важно понимать их, чтобы защищать программы от потенциальных эксплойтов. К таким векторам атак относятся:

  • Ошибки логики: недостатки в логике программы можно использовать для провоцирования непредусмотренного поведения, например потери активов или несанкционированного доступа. Сюда также относится неправильная реализация спецификаций проекта: если программа заявляет, что выполняет x, она должна выполнять x со всеми его особенностями
  • Недостатки проверки данных: неполная проверка входных данных может позволить злоумышленникам передать вредоносные данные и изменить состояние или ход выполнения программы
  • Проблемы, характерные для Rust: несмотря на механизмы безопасности Rust, небезопасные блоки кода, проблемы параллелизма и паники могут создавать уязвимости
  • Уязвимости контроля доступа: неправильная реализация проверок контроля доступа, например проверки владельца аккаунта, может позволить злоумышленнику выполнять несанкционированные действия
  • Арифметические ошибки и ошибки точности: переполнение, потеря значимости и ошибки точности могут использоваться для финансовой выгоды или приводить к некорректной работе программы
  • Проблемы межпрограммных вызовов (CPI): ошибки обработки CPI могут привести к неожиданным изменениям состояния или ошибкам, если вызываемая программа ведёт себя вредоносно или непредсказуемо
  • Неправильное использование адресов, производных от программы (PDA): ошибки при создании или обработке PDA могут привести к уязвимостям, позволяющим злоумышленникам перехватывать или подделывать PDA для получения несанкционированного доступа или управления аккаунтами, контролируемыми программой

Обратите внимание, что повторный вход в Solana изначально ограничен моделью выполнения. Среда выполнения Solana ограничивает максимальную глубину CPI четырьмя уровнями и применяет строгие правила для аккаунтов. Например, изменять данные аккаунта может только его владелец. Эти ограничения предотвращают атаки повторного входа, запрещая прямую саморекурсию и не позволяя принудительно вызвать программу в промежуточном состоянии.

Стратегии защиты

Для защиты от этих потенциальных атак разработчикам следует сочетать тщательное тестирование, аудит кода и соблюдение лучших практик:

  • Реализуйте комплексную проверку входных данных и контроль доступа
  • Максимально используйте систему типов и механизмы безопасности Rust, избегая небезопасного кода без необходимости
  • Соблюдайте лучшие практики безопасности Solana и Rust и следите за новыми разработками
  • Проводите внутренние ревью кода и используйте автоматизированные инструменты для выявления распространённых уязвимостей и логических ошибок во время разработки программы
  • Заказывайте аудит кодовой базы у авторитетных сторонних специалистов, включая компании по информационной безопасности и независимых исследователей безопасности
  • Создайте для своей программы платформу вознаграждения за обнаружение ошибок, чтобы стимулировать сообщения об уязвимостях, а не полагаться на серых хакеров

В следующих разделах различные уязвимости рассматриваются в алфавитном порядке. В каждом разделе описывается потенциальная уязвимость, объясняется способ её устранения и по возможности приводятся примеры.

Сопоставление данных аккаунта

Уязвимость

Уязвимость сопоставления данных аккаунта возникает, когда разработчики не проверяют соответствие хранящихся в аккаунте данных ожидаемому набору значений. Без надлежащих проверок данных программа может непреднамеренно работать с некорректными или подменёнными злоумышленником аккаунтами. Эта уязвимость особенно опасна в сценариях, связанных с проверкой разрешений.

Пример сценария

Рассмотрим программу с функциями управления административными настройками. В ней есть инструкция для обновления текущей административной конфигурации, например флагов функций или рабочих параметров. Инструкция должна проверять, что запрос поступил от уполномоченного администратора. Однако программа не проверяет, совпадает ли аккаунт, запросивший изменение, с аккаунтом администратора, сохранённым в данных конфигурации:

Код
pub fn update_admin_settings(ctx: Context<UpdateAdminSettings>, new_settings: AdminSettings) -> Result<()> {
  ctx.accounts.config_data.settings = new_settings;
  
  Ok(())
}

#[derive(Accounts)]
pub struct UpdateAdminSettings<'info> {
  #[account(mut)]
  pub config_data: Account<'info, ConfigData>,
  pub admin: Signer<'info>,
}

#[account]
pub struct ConfigData {
  admin: Pubkey,
  settings: AdminSettings
}

Рекомендуемая защита

Для устранения этой уязвимости разработчики могут реализовать явные проверки, которые сравнивают ключи аккаунтов и сохранённые данные с ожидаемыми значениями. Например, следует проверить, совпадает ли публичный ключ вкладчика с полем владельца токен-аккаунта, используемого для внесения средств:

Код
pub fn update_admin_settings(ctx: Context<UpdateAdminSettings>, new_settings: AdminSettings) -> Result<()> {
  if ctx.accounts.admin.key() != ctx.accounts.config_data.admin {
    return Err(ProgramError::Unauthorized);
  }  

  ctx.accounts.config_data.settings = new_settings;
  
  Ok(())
}

Разработчики также могут использовать атрибуты Anchor has_one и constraint, чтобы декларативно применять проверки данных. В приведённом выше примере атрибут constraint можно использовать для проверки эквивалентности публичного ключа вкладчика и владельца токен-аккаунта для внесения средств:

Код
pub struct UpdateAdminSettings<'info> {
  #[account(
    mut,
    constraint = config_data.admin == admin.key()
  )]
  pub config_data: Account<'info, ConfigData>,
  pub admin: Signer<'info>,
}

Перераспределение данных аккаунта

Уязвимость

В Anchor функция realloc, предоставляемая структурой AccountInfo, создаёт неочевидную уязвимость, связанную с управлением памятью. Эта функция позволяет изменять размер данных аккаунта, что может быть полезно для динамической работы с данными в программах. Однако неправильное использование realloc может привести к нежелательным последствиям, включая лишний расход вычислительных единиц или потенциальное раскрытие устаревших данных.

Метод realloc принимает два параметра:

  • new_len: значение usize, определяющее новую длину данных аккаунта
  • zero_init: значение bool, определяющее, следует ли инициализировать новую область памяти нулями

realloc определяется следующим образом:

Код
pub fn realloc(
    &self,
    new_len: usize,
    zero_init: bool
) -> Result<(), ProgramError>

Память, выделенная для данных аккаунта, уже инициализирована нулями в точке входа программы. Это означает, что при перераспределении данных с увеличением размера в рамках одной транзакции новая область памяти уже заполнена нулями. Повторное обнуление этой памяти не требуется и приводит к дополнительному расходу вычислительных единиц. И наоборот, уменьшение размера с последующим увеличением в рамках одной транзакции может раскрыть устаревшие данные, если zero_init имеет значение false.

Пример сценария

Рассмотрим программу динамического списка задач, в которой пользователи могут добавлять, удалять или изменять записи в рамках одной транзакции. Такая программа должна динамически перераспределять размер данных в зависимости от действий пользователя:

Код
pub fn modify_todo_list(ctx: Context<ModifyTodoList>, modifications: Vec<TodoModification>) -> ProgramResult {
    // Logic to process modifications
    for modification in modifications {
        match modification {
            TodoModification::Add(entry) => {
                // Add logic
            },
            TodoModification::Remove(index) => {
                // Remove logic, potentially requiring data reallocation
            },
            TodoModification::Edit(index, new_entry) => {
                // Edit logic
            },
        }
    }

    // Reallocation logic to adjust the data size based on modifications
    let required_data_len = calculate_required_data_len(&modifications);
    ctx.accounts.todo_list_data.realloc(required_data_len, false)?;

    Ok(())
}

#[derive(Accounts)]
pub struct ModifyTodoList<'info> {
    #[account(mut)]
    todo_list_data: AccountInfo<'info>,
    // Other relevant accounts
}

В этом сценарии функция modify_todo_list может несколько раз перераспределить to_do_list_data, чтобы предоставить объём, необходимый для изменений. Если размер данных уменьшается при удалении задачи, а затем снова увеличивается для добавления новых записей в рамках одной транзакции, значение zero_init, установленное в false, может привести к раскрытию устаревших данных.

Рекомендуемая защита

Для устранения этой проблемы важно осмотрительно использовать параметр zero_init:

  • Устанавливайте zero_init в true при увеличении размера данных после предшествующего уменьшения в рамках того же вызова транзакции. Это гарантирует инициализацию новой области памяти нулями и предотвращает раскрытие устаревших данных
  • Устанавливайте zero_init в false при увеличении размера данных без предшествующего уменьшения в рамках того же вызова транзакции, поскольку память уже будет инициализирована нулями

Вместо перераспределения данных под конкретные требования к размеру разработчикам следует использовать таблицы поиска адресов (ALT). ALT позволяют сжимать данные транзакции, сохраняя до 256 адресов в одном ончейн-аккаунте. Затем на каждый адрес в таблице можно ссылаться с помощью однобайтового индекса, значительно сокращая объём данных, необходимых для ссылок на адреса в конкретной транзакции. ALT гораздо полезнее в сценариях, требующих динамического взаимодействия с аккаунтами без частого изменения размера памяти.

Перезагрузка аккаунта

Уязвимость

Уязвимость перезагрузки аккаунта возникает, когда разработчики не обновляют десериализованные аккаунты после выполнения CPI. Anchor не обновляет состояние десериализованных аккаунтов автоматически после CPI. В результате логика программы может работать с устаревшими данными, что приводит к логическим ошибкам или неверным вычислениям.

Пример сценария

Рассмотрим протокол, в котором пользователи могут размещать токены в стейкинге и со временем получать вознаграждения. Обслуживающая его программа позволяет обновлять вознаграждения пользователя за стейкинг на основании определённых условий или внешних триггеров. Вознаграждения пользователя рассчитываются и обновляются через CPI к программе распределения вознаграждений. Однако после CPI программа не обновляет исходный аккаунт стейкинга, чтобы отразить новый баланс вознаграждений:

Код
pub fn update_rewards(ctx: Context<UpdateStakingRewards>, amount: u64) -> Result<()> {
    let staking_seeds = &[b"stake", ctx.accounts.staker.key().as_ref(), &[ctx.accounts.staking_account.bump]];

    let cpi_accounts = UpdateRewards {
        staking_account: ctx.accounts.staking_account.to_account_info(),
    };
    let cpi_program = ctx.accounts.rewards_distribution_program.to_account_info();
    let cpi_ctx = CpiContext::new_with_signer(cpi_program, cpi_accounts, staking_seeds);

    rewards_distribution::cpi::update_rewards(cpi_ctx, amount)?;

    // Attempt to log the "updated" reward balance
    msg!("Rewards: {}", ctx.accounts.staking_account.rewards);
    
    // Logic that uses the stale ctx.accounts.staking_account.rewards

    Ok(())
}

#[derive(Accounts)]
pub struct UpdateStakingRewards<'info> {
    #[account(mut)]
    pub staker: Signer<'info>,
    #[account(
        mut,
        seeds = [b"stake", staker.key().as_ref()],
        bump,
    )]
    pub staking_account: Account<'info, StakingAccount>,
    pub rewards_distribution_program: Program<'info, RewardsDistribution>,
}

#[account]
pub struct StakingAccount {
    pub staker: Pubkey,
    pub stake_amount: u64,
    pub rewards: u64,
    pub bump: u8,
}

В этом примере функция update_rewards пытается обновить вознаграждения для аккаунта стейкинга пользователя через вызов CPI к программе распределения вознаграждений. Сначала после CPI программа записывает в журнал ctx.accounts.staking_account.rewards, то есть баланс вознаграждений, а затем продолжает выполнять логику с устаревшими данными ctx.accounts.staking_account.rewards. Проблема заключается в том, что состояние аккаунта стейкинга не обновляется автоматически после CPI, поэтому данные остаются устаревшими.

Рекомендуемая защита

Для устранения этой проблемы явно вызывайте метод Anchor reload, чтобы повторно загрузить нужный аккаунт из хранилища. Перезагрузка аккаунта после CPI позволит точно отразить его состояние:

Код
pub fn update_rewards(ctx: Context<UpdateStakingRewards>, amount: u64) -> Result<()> {
    let staking_seeds = &[b"stake", ctx.accounts.staker.key().as_ref(), &[ctx.accounts.staking_account.bump]];

    let cpi_accounts = UpdateRewards {
        staking_account: ctx.accounts.staking_account.to_account_info(),
    };
    let cpi_program = ctx.accounts.rewards_distribution_program.to_account_info();
    let cpi_ctx = CpiContext::new_with_signer(cpi_program, cpi_accounts, staking_seeds);

    rewards_distribution::cpi::update_rewards(cpi_ctx, amount)?;

    // Reload the staking account to reflect the updated reward balance
    ctx.accounts.staking_account.reload()?;

    // Log the updated reward balance
    msg!("Rewards: {}", ctx.accounts.staking_account.rewards);
    
    // Logic that uses ctx.accounts.staking_account.rewards

    Ok(())
}

Произвольный CPI

Уязвимость

Произвольный CPI возникает, когда программа вызывает другую программу, не проверяя её идентификатор. Эта уязвимость существует потому, что среда выполнения Solana позволяет любой программе вызвать другую, если вызывающая сторона знает ID вызываемой программы и соблюдает её интерфейс. Если программа выполняет CPI на основе пользовательского ввода, не проверяя ID вызываемой программы, она может выполнить код в программе, контролируемой злоумышленником.

Пример сценария

Рассмотрим программу, которая распределяет вознаграждения между участниками в зависимости от их вклада в проект. После распределения вознаграждений программа записывает сведения в отдельную программу реестра для аудита и отслеживания. Программа реестра считается доверенной и предоставляет публичный интерфейс для отслеживания определённых записей от авторизованных программ. В программе есть функция распределения и регистрации вознаграждений, которая принимает программу реестра в качестве аккаунта. Однако перед выполнением CPI функция не проверяет предоставленный ledger_program:

Код
pub fn distribute_and_record_rewards(ctx: Context<DistributeAndRecord>, reward_amount: u64) -> ProgramResult {
    // Reward distribution logic

    let instruction = custom_ledger_program::instruction::record_transaction(
        &ctx.accounts.ledger_program.key(),
        &ctx.accounts.reward_account.key(),
        reward_amount,
    )?;

    invoke(
        &instruction,
        &[
            ctx.accounts.reward_account.clone(),
            ctx.accounts.ledger_program.clone(),
        ],
    )
}

#[derive(Accounts)]
pub struct DistributeAndRecord<'info> {
    reward_account: AccountInfo<'info>,
    ledger_program: AccountInfo<'info>,
}

Злоумышленник может воспользоваться этим, передав ID вредоносной программы в качестве ledger_program, что приведёт к непредусмотренным последствиям.

Рекомендуемая защита

Для защиты от этой проблемы разработчики могут добавить проверку идентификатора программы реестра перед выполнением CPI. Она гарантирует, что CPI обращается к нужной программе, и предотвращает произвольные CPI:

Код
pub fn distribute_and_record_rewards(ctx: Context<DistributeAndRecord>, reward_amount: u64) -> ProgramResult {
    // Reward distribution logic

    // Verify the ledger_program is the expected custom ledger program
    if ctx.accounts.ledger_program.key() != &custom_ledger_program::ID {
        return Err(ProgramError::IncorrectProgramId.into())
    }
    
    let instruction = custom_ledger_program::instruction::record_transaction(
        &ctx.accounts.ledger_program.key(),
        &ctx.accounts.reward_account.key(),
        reward_amount,
    )?;

    invoke(
        &instruction,
        &[
            ctx.accounts.reward_account.clone(),
            ctx.accounts.ledger_program.clone(),
        ],
    )
}

#[derive(Accounts)]
pub struct DistributeAndRecord<'info> {
    reward_account: AccountInfo<'info>,
    ledger_program: AccountInfo<'info>,
}

Программа, написанная с помощью Anchor, может иметь общедоступный модуль CPI. Он позволяет легко и безопасно вызывать программу из другой программы Anchor. Модуль CPI в Anchor автоматически проверяет, совпадает ли переданный адрес программы с адресом, сохранённым в модуле. Вместо передачи адреса пользователем также можно жёстко задать его в коде.

Функция передачи полномочий

Уязвимость

Программы Solana часто назначают определённые публичные ключи в качестве уполномоченных для критически важных функций, например обновления параметров программы или вывода средств. Однако невозможность передать эти полномочия другому адресу может создавать значительные риски. Это ограничение становится проблемой при изменениях в команде, продаже протокола или компрометации уполномоченного адреса.

Пример сценария

Рассмотрим программу, в которой глобальный администратор отвечает за настройку определённых параметров протокола через функцию set_params. В программе нет механизма смены глобального администратора:

Код
pub fn set_params(ctx: Context<SetParams>, /* parameters to be set */) -> Result<()> {
    require_keys_eq!(
        ctx.accounts.current_admin.key(),
        ctx.accounts.global_admin.authority,
    );

    // Logic to set parameters
}

Здесь уполномоченный адрес задан статически, и его нельзя заменить новым.

Рекомендуемая защита

Безопасный способ устранить эту проблему — создать двухэтапный процесс передачи полномочий. Он позволит текущему уполномоченному назначить нового pending_authority, который должен явно принять роль. Это не только обеспечит передачу полномочий, но и защитит от случайной передачи или вредоносного захвата. Процесс будет выглядеть следующим образом:

  • Назначение текущим уполномоченным: текущий уполномоченный назначает нового pending_authority, вызывая nominate_new_authority, который задаёт поле pending_authority в состоянии программы
  • Принятие новым уполномоченным: назначенный pending_authority вызывает accept_authority, чтобы принять новую роль и получить полномочия от текущего уполномоченного

Это будет выглядеть примерно так:

Код
pub fn nominate_new_authority(ctx: Context<NominateAuthority>, new_authority: Pubkey) -> Result<()> {
    let state = &mut ctx.accounts.state;
    require_keys_eq!(
        state.authority,
        ctx.accounts.current_authority.key()
    );

    state.pending_authority = Some(new_authority);
    Ok(())
}

pub fn accept_authority(ctx: Context<AcceptAuthority>) -> Result<()> {
    let state = &mut ctx.accounts.state;
    require_keys_eq!(
        Some(ctx.accounts.new_authority.key()),
        state.pending_authority
    );

    state.authority = ctx.accounts.new_authority.key();
    state.pending_authority = None;
    Ok(())
}

#[derive(Accounts)]
pub struct NominateAuthority<'info> {
    #[account(
        mut,
        has_one = authority,
    )]
    pub state: Account<'info, ProgramState>,
    pub current_authority: Signer<'info>,
    pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct AcceptAuthority<'info> {
    #[account(
        mut,
        constraint = state.pending_authority == Some(new_authority.key())
    )]
    pub state: Account<'info, ProgramState>,
    pub new_authority: Signer<'info>,
}

#[account]
pub struct ProgramState {
    pub authority: Pubkey,
    pub pending_authority: Option<Pubkey>,
    // Other relevant program state fields
}

В этом примере структура аккаунта ProgramState хранит текущий authority и необязательный pending_authority. Контекст NominateAuthority гарантирует, что текущий уполномоченный подписывает транзакцию и может назначить нового уполномоченного. Контекст AcceptAuthority проверяет, что pending_authority совпадает с подписантом транзакции, позволяя ему принять роль и стать новым уполномоченным. Такая схема обеспечивает безопасную и контролируемую передачу полномочий в программе.

Канонизация bump seed

Уязвимость

Канонизация bump seed означает использование наибольшего допустимого bump seed, то есть канонического bump, при получении PDA. Канонический bump позволяет детерминированно и безопасно найти адрес по набору seed. Отказ от его использования может создать уязвимости. Например, злоумышленники смогут создавать или изменять PDA, нарушая логику программы или целостность данных.

Пример сценария

Рассмотрим программу для создания уникальных профилей пользователей, каждому из которых соответствует PDA, явно полученный с помощью create_program_address. Программа позволяет создать профиль, принимая bump от пользователя. Однако такой подход создаёт риск использования неканонического bump:

Код
pub fn create_profile(ctx: Context<CreateProfile>, user_id: u64, attributes: Vec<u8>, bump: u8) -> Result<()> {
    // Explicitly derive the PDA using create_program_address and a user-provided bump
    let seeds: &[&[u8]] = &[b"profile", &user_id.to_le_bytes(),&[bump]];
    let (derived_address, _bump) = Pubkey::create_program_address(seeds, &ctx.program_id)?;

    if derived_address != ctx.accounts.profile.key() {
        return Err(ProgramError::InvalidSeeds);
    }

    let profile_pda = &mut ctx.accounts.profile;
    profile_pda.user_id = user_id;
    profile_pda.attributes = attributes;

    Ok(())
}

#[derive(Accounts)]
pub struct CreateProfile<'info> {
    #[account(mut)]
    pub user: Signer<'info>,
    /// The profile account, expected to be a PDA derived with the user_id and a user-provided bump seed
    #[account(mut)]
    pub profile: Account<'info, UserProfile>,
    pub system_program: Program<'info, System>,
}

#[account]
pub struct UserProfile {
    pub user_id: u64,
    pub attributes: Vec<u8>,
}

В этом сценарии программа получает PDA UserProfile с помощью create_program_address и seed, включающих предоставленный пользователем bump. Использовать bump от пользователя опасно, поскольку это не гарантирует применение канонического bump. Злоумышленник сможет создать несколько PDA с разными bump для одного ID пользователя.

Рекомендуемая защита

Для устранения этой проблемы можно переработать пример, получать PDA с помощью find_program_address и явно проверять bump seed:

Код
pub fn create_profile(ctx: Context<CreateProfile>, user_id: u64, attributes: Vec<u8>) -> Result<()> {
    // Securely derive the PDA using find_program_address to ensure the canonical bump is used
    let seeds: &[&[u8]] = &[b"profile", user_id.to_le_bytes()];
    let (derived_address, bump) = Pubkey::find_program_address(seeds, &ctx.program_id);

    // Store the canonical bump in the profile for future validations
    let profile_pda = &mut ctx.accounts.profile;
    profile_pda.user_id = user_id;
    profile_pda.attributes = attributes;
    profile_pda.bump = bump;

    Ok(())
}

#[derive(Accounts)]
#[instruction(user_id: u64)]
pub struct CreateProfile<'info> {
    #[account(
        init,
        payer = user,
        space = 8 + 1024 + 1,
        seeds = [b"profile", user_id.to_le_bytes().as_ref()],
        bump
    )]
    pub profile: Account<'info, UserProfile>,
    #[account(mut)]
    pub user: Signer<'info>,
    pub system_program: Program<'info, System>,
}

#[account]
pub struct UserProfile {
    pub user_id: u64,
    pub attributes: Vec<u8>,
    pub bump: u8,
}

Здесь find_program_address используется для получения PDA с каноническим bump seed, что обеспечивает детерминированное и безопасное создание PDA. Канонический bump хранится в аккаунте UserProfile, позволяя эффективно и безопасно проверять его в последующих операциях. Мы предпочитаем find_program_address вместо create_program_address, поскольку последний создаёт допустимый PDA без поиска bump seed. Поскольку поиск bump seed не выполняется, метод может непредсказуемо вернуть ошибку для любого заданного набора seed и обычно не подходит для создания PDA. find_program_address при создании PDA всегда использует канонический bump. Это связано с тем, что он последовательно выполняет различные вызовы create_program_address, начиная с bump 255 и уменьшая значение на каждой итерации. Найдя допустимый адрес, функция возвращает полученный PDA и использованный для его получения канонический bump.

Закрытие аккаунтов

Уязвимость

Некорректное закрытие аккаунтов в программе может привести к нескольким уязвимостям. Например, «закрытые» аккаунты можно повторно инициализировать или использовать не по назначению. Проблема возникает, если аккаунт не был должным образом помечен как закрытый или его повторное использование в последующих транзакциях не было запрещено. Злоумышленники могут воспользоваться таким аккаунтом для выполнения несанкционированных действий или получения доступа внутри программы.

Пример сценария

Рассмотрим программу, которая позволяет пользователям создавать и закрывать аккаунты для хранения данных. Программа закрывает аккаунт, переводя с него lamports:

Код
pub fn close_account(ctx: Context<CloseAccount>) -> ProgramResult {
    let account = ctx.accounts.data_account.to_account_info();
    let destination = ctx.accounts.destination.to_account_info();

    **destination.lamports.borrow_mut() = destination
        .lamports()
        .checked_add(account.lamports())
        .unwrap();
    **account.lamports.borrow_mut() = 0;
    
    Ok(())
}

#[derive(Accounts)]
pub struct CloseAccount<'info> {
    #[account(mut)]
    pub data_account: Account<'info, Data>,
    #[account(mut)]
    pub destination: AccountInfo<'info>,
}

#[account]
pub struct Data {
    data: u64,
}

Проблема в том, что программа не обнуляет данные аккаунта и не помечает его как закрытый. Простой перевод оставшихся lamports не закрывает аккаунт.

Рекомендуемая защита

Чтобы устранить эту проблему, программа должна не только перевести все lamports, но и обнулить данные аккаунта и пометить его дискриминатором (то есть "CLOSED_ACCOUNT_DISCRIMINATOR"). Программа также должна проверять, что закрытые аккаунты не используются повторно в будущих транзакциях:

Код
use anchor_lang::__private::CLOSED_ACCOUNT_DISCRIMINATOR;
use anchor_lang::prelude::*;
use std::io::Cursor;
use std::ops::DerefMut;

// Other code

pub fn close_account(ctx: Context<CloseAccount>) -> ProgramResult {
    let account = ctx.accounts.data_account.to_account_info();
    let destination = ctx.accounts.destination.to_account_info();

    **destination.lamports.borrow_mut() = destination
        .lamports()
        .checked_add(account.lamports())
        .unwrap();
    **account.lamports.borrow_mut() = 0;

    // Zero out the account data
    let mut data = account.try_borrow_mut_data()?;
    for byte in data.deref_mut().iter_mut() {
        *byte = 0;
    }

    // Mark the account as closed
    let dst: &mut [u8] = &mut data;
    let mut cursor = Cursor::new(dst);
    cursor.write_all(&CLOSED_ACCOUNT_DISCRIMINATOR).unwrap();

    Ok(())
}

pub fn force_defund(ctx: Context<ForceDefund>) -> ProgramResult {
    let account = &ctx.accounts.account;
    let data = account.try_borrow_data()?;

    if data.len() < 8 || data[0..8] != CLOSED_ACCOUNT_DISCRIMINATOR {
        return Err(ProgramError::InvalidAccountData);
    }

    let destination = ctx.accounts.destination.to_account_info();

    **destination.lamports.borrow_mut() = destination
        .lamports()
        .checked_add(account.lamports())
        .unwrap();
    **account.lamports.borrow_mut() = 0;

    Ok(())
}

#[derive(Accounts)]
pub struct ForceDefund<'info> {
    #[account(mut)]
    pub account: AccountInfo<'info>,
    #[account(mut)]
    pub destination: AccountInfo<'info>,
}

#[derive(Accounts)]
pub struct CloseAccount<'info> {
    #[account(mut)]
    pub data_account: Account<'info, Data>,
    #[account(mut)]
    pub destination: AccountInfo<'info>,
}

#[account]
pub struct Data {
    data: u64,
}

Однако обнулить данные и добавить дискриминатор закрытого аккаунта недостаточно. Пользователь может предотвратить удаление аккаунта сборщиком мусора, вернув на него lamports до завершения инструкции. В результате аккаунт окажется в неопределенном состоянии: его нельзя будет ни использовать, ни удалить сборщиком мусора. Поэтому для обработки этого граничного случая мы добавили функцию force_defund. Теперь любой пользователь может вывести средства с закрытых аккаунтов.

Anchor упрощает этот процесс с помощью ограничения #[account(close = destination)]. Оно безопасно закрывает аккаунт за одну операцию: переводит lamports, обнуляет данные и устанавливает дискриминатор закрытого аккаунта.

Дублирующиеся изменяемые аккаунты

Уязвимость

Дублирующиеся изменяемые аккаунты — это сценарий, при котором один и тот же аккаунт несколько раз передается инструкции как изменяемый параметр. Такое возможно, когда инструкции требуются два изменяемых аккаунта одного типа. Злоумышленник может дважды передать один аккаунт, из-за чего тот будет изменен непредусмотренным образом, например с перезаписью данных. Критичность этой уязвимости зависит от конкретного сценария.

Пример сценария

Рассмотрим программу, которая вознаграждает пользователей за участие в определенной ончейн-активности. В программе есть инструкция для обновления баланса двух аккаунтов: аккаунта вознаграждений и бонусного аккаунта. Пользователь должен получить стандартное вознаграждение на один аккаунт и возможный бонус на другой аккаунт согласно заранее заданным критериям:

Код
pub fn distribute_rewards(ctx: Context<DistributeRewards>, reward_amount: u64, bonus_amount: u64) -> Result<()> {
    let reward_account = &mut ctx.accounts.reward_account;
    let bonus_reward = &mut ctx.accounts.bonus_account;

    // Intended to increment the reward and bonus accounts separately
    reward_account.balance += reward_amount;
    bonus_account.balance += bonus_amount;

    Ok(())
}

#[derive(Accounts)]
pub struct DistributeRewards<'info> {
    #[account(mut)]
    reward_account: Account<'info, RewardAccount>,
    #[account(mut)]
    bonus_account: Account<'info, RewardAccount>,
}

#[account]
pub struct RewardAccount {
    pub balance: u64,
}

Если злоумышленник передаст один и тот же аккаунт в качестве reward_account и bonus_account, баланс аккаунта будет ошибочно обновлен дважды.

Рекомендуемая защита

Чтобы устранить эту проблему, добавьте в логику инструкции проверку того, что публичные ключи двух аккаунтов различаются:

Код
pub fn distribute_rewards(ctx: Context<DistributeRewards>, reward_amount: u64, bonus_amount: u64) -> Result<()> {
    if ctx.accounts.reward_account.key() == ctx.accounts.bonus_account.key() {
        return Err(ProgramError::InvalidArgument.into())
    }
    
    let reward_account = &mut ctx.accounts.reward_account;
    let bonus_reward = &mut ctx.accounts.bonus_account;

    // Intended to increment the reward and bonus accounts separately
    reward_account.balance += reward_amount;
    bonus_account.balance += bonus_amount;

    Ok(())
}

Разработчики могут использовать ограничения аккаунтов Anchor, чтобы добавить более явную проверку аккаунта. Для этого применяются атрибут #[account] и ключевое слово constraint:

Код
pub fn distribute_rewards(ctx: Context<DistributeRewards>, reward_amount: u64, bonus_amount: u64) -> Result<()> {
    let reward_account = &mut ctx.accounts.reward_account;
    let bonus_reward = &mut ctx.accounts.bonus_account;

    // Intended to increment the reward and bonus accounts separately
    reward_account.balance += reward_amount;
    bonus_account.balance += bonus_amount;

    Ok(())
}

#[derive(Accounts)]
pub struct DistributeRewards<'info> {
    #[account(
        mut,
        constraint = reward_account.key() != bonus_account.key()
    )]
    reward_account: Account<'info, RewardAccount>,
    #[account(mut)]
    bonus_account: Account<'info, RewardAccount>,
}

#[account]
pub struct RewardAccount {
    pub balance: u64,
}

Фронтраннинг

Уязвимость

Из-за растущей популярности сборщиков пакетов транзакций протоколы на базе Solana должны серьезно относиться к фронтраннингу. После удаления мемпула Jito под фронтраннингом здесь понимается способность злоумышленника манипулировать ожидаемыми и фактическими значениями с помощью тщательно подготовленных транзакций. 

Пример сценария

Представим протокол для покупки товаров и размещения ставок, который хранит информацию продавца о цене в аккаунте SellInfo:

Код
#[derive(Accounts)]
pub struct SellProduct<'info> {
  product_listing: Account<'info, ProductListing>,
  sale_token_mint: Account<'info, Mint>,
  sale_token_destination: Account<'info, TokenAccount>,
  product_owner: Signer<'info>,
  purchaser_token_source: Account<'info, TokenAccount>,
  product: Account<info, Product>
}

#[derive(Accounts)]
pub struct PurchaseProduct<'info> {
  product_listing: Account<'info, ProductListing>,
  token_destination: Account<'info, TokenAccount>,
  token_source: Account<'info, TokenAccount>,
  buyer: Signer<'info>,
  product_account: Account<'info, Product>,
  token_mint_sale: Account<'info, Mint>,
}

#[account]
pub struct ProductListing {
  sale_price: u64,
  token_mint: Pubkey,
  destination_token_account: Pubkey,
  product_owner: Pubkey,
  product: Pubkey,
}

Чтобы купить выставленный Product, покупатель должен передать связанный с нужным товаром аккаунт ProductListing. Но что произойдет, если продавец сможет изменить sale_price своего объявления?

Код
pub fn change_sale_price(ctx: Context<ChangeSalePrice>, new_price: u64) -> Result<()> {...}

Это даст продавцу возможность для фронтраннинга, особенно если транзакция покупателя не содержит проверок expected_price, гарантирующих, что покупатель заплатит за нужный товар не больше ожидаемой суммы. Если покупатель отправит транзакцию для покупки указанного Product, продавец сможет вызвать change_sale_price и с помощью Jito обеспечить включение этой транзакции раньше транзакции покупателя. Злоумышленник может незаметно для покупателя установить непомерно высокую цену в аккаунте ProductListing, вынудив его заплатить за Product! намного больше ожидаемого

Рекомендуемая защита

Простое решение — добавить на стороне покупателя проверку expected_price. Она не позволит покупателю заплатить за нужный Product больше ожидаемой суммы:

Код
pub fn purchase_product(ctx: Context<PurchaseProduct>, expected_price: u64) -> Result<()> {
  assert!(ctx.accounts.product_listing.sale_price <= expected_price);
  ...
}

Небезопасная инициализация

В отличие от контрактов, развернутых в EVM, программы Solana не развертываются с конструктором для задания переменных состояния. Вместо этого их инициализируют вручную, обычно функцией initialize или аналогичной. Функции инициализации обычно задают такие данные, как полномочия программы, или создают аккаунты, составляющие основу развертываемой программы, например центральный аккаунт состояния.

Поскольку функцию инициализации вызывают вручную, а не автоматически при развертывании программы, эту инструкцию должен вызвать известный адрес под контролем команды разработчиков. Иначе злоумышленник может опередить инициализацию и настроить программу с помощью контролируемых им аккаунтов.

Распространенная практика — использовать upgrade_authority программы как адрес, уполномоченный вызывать функцию initialize, если у программы есть полномочия на обновление.

Небезопасный пример и способ защиты

Код
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
  ctx.accounts.central_state.authority = authority.key();
  ...  
}

#[derive(Accounts)]
pub struct Initialize<'info> {
  authority: Signer<'info>,
  #[account(mut,
    init,
    payer = authority,
    space = CentralState::SIZE,
    seeds = [b"central_state"],
    bump
  )]
  central_state: Account<'info, CentralState>,
  ...
}

#[account]
pub struct CentralState {
  authority: Pubkey,
  ...
}

В приведенном выше упрощенном примере функция initialize назначает вызывающего инструкцию пользователя владельцем полномочий аккаунта CentralState. Но вызвать initialize может любой аккаунт! Как уже упоминалось, распространенный способ защитить функцию инициализации — использовать известный при развертывании upgrade_authority программы.

Ниже приведен пример из документации Anchor, в котором constraint гарантирует, что вызвать initialize может только владелец полномочий на обновление программы:

Код
use anchor_lang::prelude::*;
use crate::program::MyProgram;

declare_id!("Cum9tTyj5HwcEiAmhgaS7Bbj4UczCwsucrCkxRECzM4e");

#[program]
pub mod my_program {
    use super::*;

    pub fn set_initial_admin(
        ctx: Context<SetInitialAdmin>,
        admin_key: Pubkey
    ) -> Result<()> {
        ctx.accounts.admin_settings.admin_key = admin_key;
        Ok(())
    }

    pub fn set_admin(...){...}

    pub fn set_settings(...){...}
}

#[account]
#[derive(Default, Debug)]
pub struct AdminSettings {
    admin_key: Pubkey
}

#[derive(Accounts)]
pub struct SetInitialAdmin<'info> {
    #[account(init, payer = authority, seeds = [b"admin"], bump)]
    pub admin_settings: Account<'info, AdminSettings>,
    #[account(mut)]
    pub authority: Signer<'info>,
    #[account(constraint = program.programdata_address()? == Some(program_data.key()))]
    pub program: Program<'info, MyProgram>,
    #[account(constraint = program_data.upgrade_authority_address == Some(authority.key()))]
    pub program_data: Account<'info, ProgramData>,
    pub system_program: Program<'info, System>,
}

Потеря точности

Уязвимость

Потеря точности может казаться незначительной, но она представляет серьезную угрозу для программы. Она может приводить к неверным вычислениям, возможностям для арбитража и непредсказуемому поведению программы.

Потеря точности при арифметических операциях — распространенный источник ошибок. В программах Solana рекомендуется по возможности использовать арифметику с фиксированной точкой. Это связано с тем, что программы поддерживают лишь ограниченное подмножество операций Rust с числами с плавающей точкой. Если программа попытается выполнить неподдерживаемую операцию с плавающей точкой, среда выполнения вернет ошибку неразрешенного символа. Кроме того, операции с плавающей точкой требуют больше инструкций, чем их целочисленные аналоги. Использование арифметики с фиксированной точкой и необходимость точно обрабатывать большое количество токенов и дробные значения могут усугубить потерю точности.

Умножение после деления

Хотя ассоциативное свойство выполняется для большинства математических операций, его применение в компьютерной арифметике может привести к неожиданной потере точности. Классический пример — умножение после деления, которое может дать иной результат, чем умножение перед делением. Рассмотрим выражения (a / c) * b и (a * b) / c. Математически эти выражения ассоциативны и должны давать одинаковый результат. Однако в контексте Solana и арифметики с фиксированной точкой порядок операций имеет большое значение. Если сначала выполнить деление (a / c), точность может снизиться из-за округления частного вниз перед умножением на b. Результат может оказаться меньше ожидаемого. Напротив, умножение (a * b) перед делением на c может сохранить больше исходной точности. Это различие может привести к неверным вычислениям, непредвиденному поведению программы и возможностям для арбитража.

Арифметические функции saturating_*

Арифметические функции saturating_* предотвращают переполнение и потерю значимости, ограничивая значения максимально или минимально допустимыми пределами. Однако неожиданное достижение такого предела может вызвать скрытые ошибки и потерю точности. Это происходит, когда логика программы предполагает, что насыщение само по себе гарантирует точный результат, и не обрабатывает потенциальную потерю точности или достоверности.

Например, представим программу, которая рассчитывает и распределяет вознаграждения между пользователями на основе количества токенов, проданных ими за определенный период:

Код
pub fn calculate_reward(transaction_amount: u64, reward_multiplier: u64) -> u64 {
    transaction_amount.saturating_mul(reward_multiplier)
}

Рассмотрим сценарий, в котором transaction_amount составляет 100 000 токенов, а reward_multiplier — 100 токенов за транзакцию. Результат их умножения превысит максимальное значение, которое может хранить u64. Произведение будет ограничено предельным значением, что приведет к существенной потере точности и занижению вознаграждения пользователя.

Ошибки округления

Операции округления — распространенная причина потери точности в программировании. Выбор метода округления может существенно влиять на точность вычислений и поведение программ Solana. Функция try_round_u64() округляет десятичные значения до ближайшего целого числа. Округление вверх создает проблему: оно может искусственно завышать значения и приводить к расхождениям между фактическими и ожидаемыми результатами вычислений.

Рассмотрим программу Solana, которая преобразует обеспечение в ликвидность в зависимости от рыночных условий. Программа использует try_round_u64() для округления результата деления:

Код
pub fn collateral_to_liquidity(&self, collateral_amount: u64) -> Result<u64, ProgramError> {
    Decimal::from(collateral_amount)
        .try_div(self.0)?
        .try_round_u64()
}

В этом сценарии округление вверх может привести к выпуску большего количества токенов ликвидности, чем оправдано объемом обеспечения. Злоумышленники могут воспользоваться расхождением и провести арбитражные атаки, извлекая ценность из протокола за счет выгодных результатов округления. Для защиты используйте try_floor_u64, чтобы округлять вниз до ближайшего целого числа. Такой подход снижает риск искусственного завышения значений и гарантирует, что округление не даст пользователю преимущество за счет системы. Альтернативный вариант — реализовать логику для явной обработки сценариев, в которых округление может повлиять на результат. Например, можно установить определенные пороговые значения для выбора способа округления или применять разную логику в зависимости от величины обрабатываемых значений.

Отсутствие проверки владельца

Уязвимость

Проверки владельца необходимы, чтобы убедиться, что аккаунт, участвующий в транзакции или операции, принадлежит ожидаемой программе. Аккаунты содержат поле owner, которое указывает программу с правом записи данных аккаунта. Это поле гарантирует, что состояние аккаунта могут изменять только авторизованные программы. Оно также позволяет проверять, что переданные инструкции аккаунты принадлежат ожидаемой программе. Отсутствие проверок владельца может привести к серьезным уязвимостям, включая несанкционированные переводы средств и выполнение привилегированных операций.

Пример сценария

Рассмотрим функцию программы, которая позволяет только администратору выводить средства из хранилища. Функция принимает аккаунт конфигурации, то есть config, и использует его поле admin, чтобы проверить, совпадает ли публичный ключ предоставленного аккаунта администратора с ключом в аккаунте config. Однако функция не проверяет владельца аккаунта config, считая этот аккаунт доверенным:

Код
pub fn admin_token_withdraw(program_id: &Pubkey, accounts: &[AccountInfo], amount: u64) -> ProgramResult {
    // Account setup

    if config.admin != admin.pubkey() {
        return Err(ProgramError::InvalidAdminAccount)
    }

    // Transfer funds logic
}

Злоумышленник может воспользоваться этим, передав контролируемый им аккаунт config с соответствующим полем admin. Так он обманет программу и заставит ее выполнить вывод средств.

Рекомендуемая защита

Чтобы устранить уязвимость, проверьте поле owner аккаунта:

Код
pub fn admin_token_withdraw(program_id: &Pubkey, accounts: &[AccountInfo], amount: u64) -> ProgramResult {
    // Account setup

    if config.admin != admin.pubkey() {
        return Err(ProgramError::InvalidAdminAccount)
    }

    if config.owner != program_id {
        return Err(ProgramError::InvalidConfigAccount)
    }

    // Transfer funds logic
}

Anchor упрощает эту проверку с помощью типа Account. Account<'info, T> — это оболочка над AccountInfo, которая проверяет владельца программы и десериализует базовые данные в T, то есть в указанный тип аккаунта. Поэтому разработчики могут легко проверять владельца аккаунта с помощью Account<'info, T>. Они также могут использовать атрибут #[account], чтобы добавить к аккаунту типаж Owner. Этот типаж определяет адрес предполагаемого владельца аккаунта. Кроме того, разработчики могут использовать ограничение owner, чтобы указать программу, которая должна владеть аккаунтом, если это не исполняемая в данный момент программа. Это полезно, например, при написании инструкции, которая ожидает аккаунт в виде PDA, полученного из другой программы. Ограничение owner задается как #[account(owner = <expr>)], где <expr> — произвольное выражение.

Аккаунты только для чтения

Не менее важно проверять допустимость аккаунтов, помеченных как доступные только для чтения в контексте выполнения программы. Злоумышленник может передать аккаунты с произвольными или специально сформированными данными вместо легитимных аккаунтов. Это может привести к непредвиденному или опасному поведению программы. Разработчики должны проверять подлинность и целостность аккаунтов, из которых программа читает данные. Для этого можно сверять адрес аккаунта с известными значениями или проверять ожидаемого владельца аккаунта. Это особенно важно для sysvars, то есть системных аккаунтов только для чтения, таких как Clock или EpochSchedule. Получайте доступ к sysvars с помощью метода get(), который не требует ручной проверки адреса или владельца. Это более безопасный способ доступа к таким аккаунтам. Однако не все sysvars поддерживают метод get(). В таком случае обращайтесь к ним по публичному адресу.

Отсутствие проверки подписанта

Уязвимость

Транзакции подписываются приватным ключом кошелька. Это обеспечивает аутентификацию, целостность, невозможность отказа от авторства и авторизацию конкретной транзакции конкретным кошельком. Требуя подписи транзакции приватным ключом отправителя, среда выполнения Solana может проверить, что транзакцию инициировал нужный аккаунт и что она не была изменена. Этот механизм лежит в основе модели доверия децентрализованных сетей. Без такой проверки любой аккаунт, передавший правильный аккаунт в качестве аргумента, может выполнить транзакцию. Это может привести к несанкционированному доступу к конфиденциальной информации, средствам или функциям. Уязвимость возникает, если перед выполнением привилегированной операции программа не проверяет, подписана ли она приватным ключом соответствующего аккаунта.

Пример сценария

Рассмотрим следующую функцию:

Код
pub fn update_admin(program_id: &Pubkey, accounts &[AccountInfo]) -> ProgramResult {
    let account_iter = &mut accounts.iter();
    let config = ConfigAccount::unpack(next_account_info(account_iter)?)?;
    let admin = next_account_info(account_iter)?;
    let new_admin = next_account_info (account_iter)?;

    if admin.pubkey() != config.admin {
        return Err(ProgramError::InvalidAdminAccount);
    }

    config.admin = new_admin.pubkey();

    Ok(())
}

Эта функция предназначена для смены администратора программы. Она проверяет, что операцию инициировал текущий администратор, что является хорошей практикой контроля доступа. Однако функция не проверяет, подписана ли транзакция приватным ключом текущего администратора. Поэтому любой вызывающий функцию пользователь может передать правильный аккаунт admin, чтобы выполнялось условие admin.pubkey() = config.admin, независимо от того, является ли вызывающий аккаунт текущим администратором. Злоумышленник может выполнить инструкцию, передав свой аккаунт в качестве нового администратора, и полностью обойти необходимость авторизации текущим администратором.

Рекомендуемая защита

Программы должны проверять, что аккаунт подписан соответствующим кошельком. Для этого можно проверить поле AccountInfo::is_signer аккаунтов, участвующих в транзакции. Проверяя, что у аккаунта, выполняющего привилегированную операцию, флаг is_signer установлен в true, программа может разрешить определенные действия только авторизованным аккаунтам.

Обновленный пример кода будет выглядеть так:

Код
pub fn update_admin(program_id: &Pubkey, accounts &[AccountInfo]) -> ProgramResult {
    let account_iter = &mut accounts.iter();
    let config = ConfigAccount::unpack(next_account_info(account_iter)?)?;
    let admin = next_account_info(account_iter)?;
    let new_admin = next_account_info (account_iter)?;

    if admin.pubkey() != config.admin {
        return Err(ProgramError::InvalidAdminAccount);
    }

    // Add in a check for the admin's signature
    if !admin.is_signer {
        return Err(ProgramError::NotSigner);
    }

    config.admin = new_admin.pubkey();

    Ok(())
}

Anchor упрощает весь этот процесс с помощью типа аккаунта Signer<’info>.

Переполнение и антипереполнение

Уязвимость

Целое число — это число без дробной части. Rust хранит целые числа в переменных фиксированного размера. Эти переменные определяются их знаковостью (то есть бывают знаковыми или беззнаковыми) и объемом занимаемой памяти. Например, тип u8 обозначает беззнаковое целое число, занимающее 8 бит. Он может хранить значения от 0 до 255. Сохранение значения за пределами этого диапазона приведет к переполнению или антипереполнению целого числа. Переполнение целого числа происходит, когда переменная превышает максимальное значение и циклически переходит к минимальному. Антипереполнение происходит, когда переменная становится меньше минимального значения и циклически переходит к максимальному.

При компиляции в режиме отладки Rust проверяет целые числа на переполнение и антипереполнение. При обнаружении такого условия программа вызовет панику во время выполнения. Однако при компиляции в режиме выпуска с флагом --release Rust не добавляет проверки, вызывающие панику при переполнении или антипереполнении целых чисел. Это может привести к неочевидным уязвимостям, поскольку переполнение или антипереполнение происходит незаметно. Набор инструментов Berkley Packet Filter (BPF) — неотъемлемая часть среды разработки Solana, поскольку он компилирует программы Solana. Команда cargo build-bpf компилирует проекты Rust в байт-код BPF для развертывания. Проблема в том, что по умолчанию она компилирует программы в режиме выпуска. Поэтому программы Solana уязвимы к переполнению и антипереполнению целых чисел.

Пример сценария

Злоумышленник может воспользоваться этой уязвимостью, используя незаметное переполнение или антипереполнение в режиме выпуска, особенно в функциях, обрабатывающих балансы токенов. Рассмотрим следующий пример:

Код
pub fn process_instruction(
    _program_id: & Pubkey,
    accounts: [&AccountInfo],
    _instruction_data: &[u8],
) -> ProgramResult {
    let account_info_iter = &mut accounts.iter();
    let account = next_account_info(account_info_iter)?;

    let mut balance: u8 = account.data.borrow()[0];
    let tokens_to_subtract: u8 = 100;

    balance = balance - tokens_to_subtract;

    account.data.borrow_mut()[0] = balance;
    msg!("Updated balance to {}", balance);
    
    Ok(())
}

Для простоты эта функция предполагает, что баланс хранится в первом байте. Она берет баланс аккаунта и вычитает из него tokens_to_subtract. Если баланс пользователя меньше tokens_to_subtract, произойдет антипереполнение. Например, баланс пользователя с 10 токенами после антипереполнения составит 165 токенов.

Рекомендуемые меры

overflow-checks

Самый простой способ устранить эту уязвимость — задать ключу overflow-checks значение true в файле Cargo.toml проекта. Тогда Rust добавит в компилятор проверки переполнения и антипереполнения. Однако такие проверки увеличивают вычислительную стоимость транзакции. Если важно оптимизировать вычисления, может быть выгоднее задать overflow-checks значение false.

Арифметические функции checked_*

Используйте арифметические функции Rust checked_* для каждого целочисленного типа, чтобы выборочно проверять переполнение и антипереполнение в программе. При переполнении или антипереполнении эти функции возвращают None. Это позволяет программе корректно обработать ошибку. Например, предыдущий код можно изменить следующим образом:

Код
pub fn process_instruction(
    _program_id: & Pubkey,
    accounts: [&AccountInfo],
    _instruction_data: &[u8],
) -> ProgramResult {
    let account_info_iter = &mut accounts.iter();
    let account = next_account_info(account_info_iter)?;

    let mut balance: u8 = account.data.borrow()[0];
    let tokens_to_subtract: u8 = 100;

    match balance.checked_sub(tokens_to_subtract) {
        Some(new_balance) => {
            account.data.borrow_mut()[0] = new_balance;
            msg!("Updated balance to {}", new_balance);
        },
        None => {
            return Err(ProgramErrorr::InsufficientFunds);
        }
    }

    Ok(())
}

В измененном примере checked_sub используется для вычитания tokens_to_subtract из balance. Если balance достаточно для вычитания, checked_sub вернет Some(new_balance). Программа безопасно обновит баланс аккаунта и запишет его в журнал. Однако если вычитание приведет к антипереполнению, checked_sub вернет None, что можно обработать возвратом ошибки.

Макрос Checked Math

Checked Math — это процедурный макрос, который позволяет изменять параметры проверки математических выражений, в основном не меняя сами выражения. Недостаток арифметических функций checked_* — потеря привычной математической записи. Вместо a + b приходится использовать громоздкие методы, например a.checked_add(b).unwrap(). Например, чтобы записать (x * y) + z с помощью проверяемых арифметических функций, потребуется выражение x.checked_mul(y).unwrap().checked_add(z).unwrap().

С макросом Checked Math это выражение будет выглядеть так:

Код
use checked_math::checked_math as cm;

cm!((x * y) + z).unwrap()

Такую запись удобнее использовать, она сохраняет математическую форму выражения и требует только одного .unwrap(). Это возможно потому, что макрос преобразует обычные математические выражения в выражение, которое возвращает None, если любой из проверяемых шагов вернул None. При успешном выполнении возвращается Some(_), поэтому в конце выражения используется распаковка.

Приведение типов

Аналогично, приведение целочисленных типов с помощью ключевого слова as без надлежащих проверок может привести к уязвимости, связанной с переполнением или антипереполнением. Причина в том, что приведение может непреднамеренно усекать или расширять значения. При приведении большего целочисленного типа к меньшему (например, u64 к u32) Rust усекает старшие биты исходного значения, которые не помещаются в целевой тип. Это создает проблему, если исходное значение превышает максимум, который может хранить целевой тип. При приведении меньшего целочисленного типа к большему (например, i16 к i32) Rust расширяет значение. Для беззнаковых типов это не вызывает сложностей. Однако для знаковых целых чисел может произойти расширение знака, что приведет к появлению непредусмотренных отрицательных значений.

Рекомендуемые меры

Чтобы устранить эту уязвимость, используйте безопасные методы приведения типов Rust. К ним относятся try_from и from. Метод try_from возвращает тип Result, позволяя явно и корректно обрабатывать случаи, когда значение не помещается в целевой тип. Метод Rust from можно использовать для безопасного неявного преобразования, которое гарантированно не приводит к потере данных (например, из u8 в u32). Предположим, программе требуется безопасно преобразовать количество токенов типа u64 в тип u32 для обработки. Это можно сделать следующим образом:

Код
pub fn convert_token_amount(amount: u64) -> Result<u32, ProgramError> {
    u32::try_from(amount).map_err(|_| ProgramError::InvalidArgument)
}

В этом примере, если amount превышает максимальное значение, которое может хранить u32 (то есть 4 294 967 295), преобразование завершается с ошибкой, и программа возвращает ошибку. Это предотвращает потенциальное переполнение или антипереполнение.

Совместное использование PDA

Уязвимость

Совместное использование PDA — распространенная уязвимость, которая возникает, когда один и тот же PDA используется в нескольких доменах полномочий или для нескольких ролей. Злоумышленник может получить доступ к чужим данным или средствам, если PDA используется как подписант без надлежащих проверок контроля доступа.

Пример сценария

Рассмотрим программу для стейкинга токенов и распределения вознаграждений. Программа использует один PDA для перевода токенов в заданный пул и вывода вознаграждений. PDA выводится из статического сида (например, названия пула стейкинга), поэтому он используется во всех операциях:

Код
pub fn stake_tokens(ctx: Context<StakeTokens>, amount: u64) -> ProgramResult {
    // Logic to stake tokens
    Ok(())
}

pub fn withdraw_rewards(ctx: Context<WithdrawRewards>, amount: u64) -> ProgramResult {
    // Logic to withdraw rewards
    Ok(())
}

#[derive(Accounts)]
pub struct StakeTokens<'info> {
    #[account(
        mut,
        seeds = [b"staking_pool_pda"],
        bump
    )]
    staking_pool: AccountInfo<'info>,
    // Other staking-related accounts
}

#[derive(Accounts)]
pub struct WithdrawRewards<'info> {
    #[account(
        mut,
        seeds = [b"staking_pool_pda"],
        bump
    )]
    rewards_pool: AccountInfo<'info>,
    // Other rewards withdrawal-related accounts
}

Проблема в том, что функции стейкинга и вывода вознаграждений используют один и тот же PDA, выведенный из staking_pool_pda. Это может позволить пользователям манипулировать контрактом, чтобы несанкционированно выводить вознаграждения или изменять стейкинг.

Рекомендуемые меры

Чтобы устранить эту уязвимость, используйте отдельные PDA для разных функций. Каждый PDA должен применяться в определенном контексте и выводиться из уникальных сидов, предназначенных для конкретной операции:

Код
pub fn stake_tokens(ctx: Context<StakeTokens>, amount: u64) -> ProgramResult {
    // Logic to stake tokens
    Ok(())
}

pub fn withdraw_rewards(ctx: Context<WithdrawRewards>, amount: u64) -> ProgramResult {
    // Logic to withdraw rewards
    Ok(())
}

#[derive(Accounts)]
pub struct StakeTokens<'info> {
    #[account(
        mut,
        seeds = [b"staking_pool", &staking_pool.key().as_ref()],
        bump
    )]
    staking_pool: AccountInfo<'info>,
    // Other staking-related accounts
}

#[derive(Accounts)]
pub struct WithdrawRewards<'info> {
    #[account(
        mut,
        seeds = [b"rewards_pool", &rewards_pool.key().as_ref()],
        bump
    )]
    rewards_pool: AccountInfo<'info>,
    // Other rewards withdrawal-related accounts
}

В примере выше PDA для стейкинга токенов и вывода вознаграждений выводятся из разных сидов (staking_pool и rewards_pool соответственно) в сочетании с ключом конкретного аккаунта. Благодаря этому каждый PDA однозначно связан со своей функцией, что снижает риск несанкционированных действий.

Дополнительные аккаунты

Уязвимость

ctx.remaining_accounts позволяет передавать в функцию дополнительные аккаунты, которые изначально не были указаны в структуре Accounts. Это дает разработчику больше гибкости и позволяет обрабатывать сценарии с динамическим количеством аккаунтов (например, обрабатывать переменное число пользователей или взаимодействовать с разными программами). Однако у этой гибкости есть недостаток: аккаунты, переданные через ctx.remaining_accounts, не проходят ту же проверку, что и аккаунты, определенные в структуре Accounts. Поскольку ctx.remaining_accounts не проверяет переданные аккаунты, злоумышленник может передать аккаунты, с которыми программа не должна взаимодействовать. Это может привести к несанкционированным действиям или доступу.

Пример сценария

Рассмотрим программу вознаграждений, которая использует ctx.remaining_accounts, чтобы получать PDA пользователей и динамически рассчитывать вознаграждения:

Код
pub fn calculate_rewards(ctx: Context<CalculateRewards>) -> Result<()> {
    let rewards_account = &ctx.accounts.rewards_account;
    let authority = &ctx.accounts.authority;

    // Iterate over accounts passed in via ctx.remaining_accounts
    for user_pda_info in ctx.remaining_accounts.iter() {
        // logic to check user activity and calculate rewards
    }

    // Logic to distribute calculated rewards

    Ok(())
}

#[derive(Accounts)]
pub struct CalculateRewards<'info> {
    #[account(mut)]
    pub rewards_account: Account<'info, RewardsAccount>,
    pub authority : Signer<'info>,
}

#[account]
pub struct RewardsAccount {
    pub total_rewards: u64,
    // Other relevant fields
}

Проблема в отсутствии явных проверок аккаунтов, переданных через ctx.remaining_accounts. Поэтому программа не гарантирует, что при расчете и распределении вознаграждений обрабатываются только действительные аккаунты пользователей, имеющих на них право. Злоумышленник может передать чужие или созданные им самим аккаунты, чтобы получить больше вознаграждений, чем ему положено.

Рекомендуемые меры

Чтобы устранить эту уязвимость, разработчики должны вручную проверять действительность каждого аккаунта внутри функции. Следует проверить владельца аккаунта, убедиться, что он соответствует ожидаемому пользователю, а также проверить все необходимые данные аккаунта. Такие ручные проверки позволяют использовать гибкость ctx.remaining_acocunts, одновременно снижая риск несанкционированного доступа или манипуляций.

Ошибки, характерные для Rust

Rust — это лингва франка разработки программ в Solana. Разработка на Rust сопряжена с особым набором сложностей и требований, особенно в отношении небезопасного кода и ошибок, характерных для Rust. Понимание особенностей Rust помогает создавать безопасные, эффективные и надежные программы.

Небезопасный Rust

Rust известен своими гарантиями безопасности памяти, которые обеспечиваются строгой системой владения и заимствования. Однако иногда эти гарантии могут мешать, поэтому Rust предоставляет ключевое слово unsafe, позволяющее обойти проверки безопасности. unsafe в Rust используется в четырех основных случаях:

  • Небезопасные функции: функции, выполняющие операции, которые могут нарушить гарантии безопасности Rust, должны быть отмечены ключевым словом unsafe. Например, unsafe fn dangerous_function() {}
  • Небезопасные блоки: блоки кода, в которых разрешены небезопасные операции. Например, unsafe { // Небезопасные операции }
  • Небезопасные трейты: трейты, подразумевающие определенные инварианты, которые компилятор не может проверить. Например, unsafe trait BadTrait {}
  • Реализация небезопасных трейтов: реализации трейтов unsafe также должны быть отмечены как unsafe. Например, unsafe impl UnsafeTrait for UnsafeType {}

Небезопасный Rust существует потому, что статический анализ консервативен. Когда компилятор определяет, соблюдает ли код определенный набор гарантий, лучше отклонить несколько вариантов допустимого кода, чем принять несколько вариантов недопустимого. Код может работать без ошибок, но компилятор Rust отклонит его, если у него недостаточно информации, чтобы убедиться в соблюдении гарантий безопасности Rust. Небезопасный код позволяет разработчикам обходить эти проверки на свой страх и риск. Кроме того, компьютерное оборудование по своей природе небезопасно. Для низкоуровневого программирования на Rust разработчикам необходима возможность выполнять небезопасные операции.

Ключевое слово unsafe позволяет разработчикам:

  • Разыменовывать сырые указатели: обеспечивает прямой доступ к памяти через сырые указатели, которые могут указывать на любую область памяти, не обязательно содержащую допустимые данные
  • Вызывать небезопасные функции: такие функции могут не соблюдать гарантии безопасности Rust и приводить к потенциально неопределенному поведению
  • Обращаться к изменяемым статическим переменным: глобальное изменяемое состояние может вызывать гонки данных

Лучший способ снизить риски небезопасного Rust — свести к минимуму использование блоков unsafe. Если код unsafe абсолютно необходим по какой-либо причине, подробно документируйте и регулярно проверяйте его. По возможности инкапсулируйте его в безопасную абстракцию, которую можно предоставить остальной части программы.

Паники и обработка ошибок

Паника возникает, когда программа Rust сталкивается с неисправимой ошибкой и прекращает выполнение. Паники используются для неожиданных ошибок, которые не предполагается перехватывать. В программах Solana паника может привести к непредвиденному поведению, поскольку среда выполнения ожидает, что программы будут корректно обрабатывать ошибки без аварийного завершения.

При панике Rust начинает раскручивать и очищать стек. В результате создается трассировка стека с подробной информацией об ошибке. Она может раскрыть злоумышленнику сведения о внутренней структуре файлов. Хотя это не относится напрямую к программам Solana, зависимости программы могут быть уязвимы для такой атаки. Своевременно обновляйте зависимости и используйте версии без известных уязвимостей.

Распространенные сценарии паники:

  • Деление на ноль: Rust вызовет панику при попытке деления на ноль. Поэтому перед делением всегда проверяйте, что делитель не равен нулю
  • Выход индекса массива за границы: обращение к массиву по индексу за его пределами вызовет панику. Для защиты используйте методы, возвращающие тип Option (например, get), чтобы безопасно обращаться к элементам массива
  • Распаковка значений None: вызов .unwrap() для Option, содержащего значение None, вызовет панику. Всегда используйте сопоставление с образцом, методы unwrap_or, unwrap_or_else или оператор ? в функциях, возвращающих Result

Чтобы снизить риски, связанные с паниками, избегайте вызывающих их операций, проверяйте все входные данные и условия, которые могут привести к проблемным операциям, а для обработки ошибок используйте типы Result и Option. Кроме того, комплексные тесты программы помогут обнаружить и устранить потенциальные сценарии паники до развертывания.

Коллизии сидов

Уязвимость

Коллизии сидов возникают, когда разные входные данные (то есть сиды и идентификаторы программ), используемые для создания PDA, приводят к одному адресу PDA. Это создает проблему, когда PDA используются в одной программе для разных целей, поскольку может привести к непредвиденному поведению, включая атаки типа «отказ в обслуживании» или полную компрометацию.

Пример сценария

Рассмотрим программу децентрализованной платформы для голосования по различным предложениям и инициативам. Каждая сессия голосования по определенному предложению или инициативе создается с уникальным идентификатором, после чего пользователи отдают голоса. Программа использует PDA как для сессий голосования, так и для отдельных голосов:

Код
// Creating a Voting Session PDA
#[derive(Accounts)]
#[instruction(session_id: String)]
pub struct CreateVotingSession<'info> {
    #[account(mut)]
    pub organizer: Signer<'info>,
    #[account(
        init,
        payer = organizer,
        space = 8 + Product::SIZE,
        seeds = [b"session", session_id.as_bytes()],
    )]
    pub voting_session: Account<'info, VotingSession>,
    pub system_program: Program<'info, System>,
}

// Submitting a Vote PDA
#[derive(Accounts)]
#[instruction(session_id: String)]
pub struct SubmitVote<'info> {
    #[account(mut)]
    pub voter: Signer<'info>,
    #[account(
        init,
        payer = voter,
        space = 8 + Vote::SIZE,
        seeds = [session_id.as_bytes(), voter.key().as_ref()]
    )]
    pub vote: Account<'info, Vote>,
    pub system_program: Program<'info, System>,
}

В этом сценарии злоумышленник попытается тщательно подобрать сессию голосования, которая в сочетании со статическим сидом "session" создаст PDA, случайно совпадающий с PDA другой сессии голосования. Намеренное создание PDA, конфликтующего с PDA другой сессии, может нарушить работу платформы. Например, оно может помешать законному голосованию по предложениям или добавлению новых инициатив на платформу, поскольку среда выполнения Solana не может различить конфликтующие PDA.

Рекомендуемые меры

Чтобы снизить риск коллизий сидов, разработчики могут:

  • Использовать уникальные префиксы сидов для разных PDA в одной программе. Это поможет обеспечить различие между PDA
  • Использовать уникальные идентификаторы (например, временные метки, идентификаторы пользователей или значения nonce), чтобы каждый раз гарантированно создавать уникальный PDA
  • Программно проверять, что созданный PDA не конфликтует с существующими PDA

Подмена типов

Уязвимость

Подмена типов — это уязвимость, при которой один тип аккаунта ошибочно представляется как другой из-за отсутствия проверки типов при десериализации. Это может привести к выполнению несанкционированных действий или повреждению данных, поскольку программа будет исходить из неверного представления о роли или разрешениях аккаунта. При десериализации всегда явно проверяйте предполагаемый тип аккаунта.

Пример сценария

Рассмотрим программу, которая управляет доступом к административным операциям на основе роли пользователя. Каждый аккаунт пользователя содержит дискриминатор роли, позволяющий отличать обычных пользователей от администраторов. Программа содержит функцию обновления настроек администратора, предназначенную только для администраторов. Однако программа не проверяет дискриминатор аккаунта и десериализует данные аккаунта пользователя, не подтверждая, что аккаунт принадлежит администратору:

Код
pub fn update_admin_settings(ctx: Context<UpdateSettings>) -> ProgramResult {
    // Deserialize without checking the discriminator
    let user = User::try_from_slice(&ctx.accounts.user.data.borrow()).unwrap();

    // Sensitive update logic

    msg!("Admin settings updated by: {}", user.authority)
    Ok(())
}

#[derive(Accounts)]
pub struct UpdateSettings<'info> {
    user: AccountInfo<'info>
}

#[derive(BorshSerialize, BorshDeserialize)]
pub struct User {
    authority: Pubkey,
}

Проблема в том, что update_admin_settings десериализует переданный аккаунт пользователя без проверки дискриминатора роли аккаунта. Отчасти это происходит потому, что в структуре User вообще отсутствует поле дискриминатора!

Рекомендуемые меры

Чтобы устранить эту проблему, разработчики могут добавить поле дискриминатора в структуру User и проверять его при десериализации:

Код
pub fn update_admin_settings(ctx: Context<UpdateSettings>) -> ProgramResult {
    let user = User::try_from_slice(&ctx.accounts.user.data.borrow()).unwrap();

    // Verify the user's discriminator
    if user.discriminant != AccountDiscriminant::Admin {
        return Err(ProgramError::InvalidAccountData.into())
    }
    
    // Sensitive update logic

    msg!("Admin settings updated by: {}", user.authority)
    Ok(())
}

#[derive(Accounts)]
pub struct UpdateSettings<'info> {
    user: AccountInfo<'info>
}

#[derive(BorshSerialize, BorshDeserialize)]
pub struct User {
    discriminant: AccountDiscriminant,
    authority: Pubkey,
}

#[derive(BorshSerialize, BorshDeserialize, PartialEq)]
pub enum AccountDiscriminant {
    Admin,
    // Other account types
}

Anchor упрощает устранение уязвимостей, связанных с подменой типов, автоматически управляя дискриминаторами типов аккаунтов. Для этого используется обертка Account<'info, T>, с которой Anchor обеспечивает безопасность типов, автоматически проверяя дискриминатор при десериализации. Это позволяет разработчикам сосредоточиться на бизнес-логике программы, а не реализовывать различные проверки типов вручную.

Заключение

Важность безопасности программ невозможно переоценить. В этой статье мы рассмотрели широкий спектр распространённых уязвимостей — от ошибок, характерных для Rust, до сложностей метода realloc в Anchor. Освоение каждой из этих уязвимостей и безопасности программ в целом — непрерывный процесс, который требует постоянного обучения, адаптации и сотрудничества. Как разработчики, мы не просто защищаем активы. Мы укрепляем доверие, обеспечиваем целостность приложений и способствуем развитию и стабильности Solana.

Если вы дочитали до этого места, спасибо, анон! Обязательно укажите свой адрес электронной почты ниже, чтобы не пропускать новости Solana. Готовы погрузиться глубже? Изучите последние статьи в блоге Helius и продолжите знакомство с Solana уже сегодня.

Дополнительные ресурсы

Подпишитесь на Helius

Следите за новостями разработки на Solana и получайте уведомления о наших публикациях

Увеличенное изображение