新着:HeliusがLight Protocolを買収
Solanaプログラムセキュリティのためのヒッチハイク・ガイド
ブログ/基礎

Solanaプログラムセキュリティのためのヒッチハイク・ガイド

Developer Experience EngineerXの0xIchigoLinkedInの0xIchigoGitHubの0xIchigo
読了時間:39分
目次

この記事は、以前 Kudelski SecurityおよびHalbornに勤務していたセキュリティ研究者兼スマートコントラクト開発者のbl0ckpainとの共著です

はじめに

Solanaプログラムのセキュリティは、ハッカーによるプロジェクト資金の盗難を防ぐだけではありません。プログラムがプロジェクトの仕様とユーザーの期待に従い、意図どおりに動作することを保証するものです。Solanaプログラムのセキュリティは、dAppのパフォーマンス、スケーラビリティ、相互運用性に影響を及ぼす可能性があります。そのため、開発者は一般ユーザー向けのアプリケーションを構築する前に、潜在的な攻撃ベクトルと一般的な脆弱性を把握しておく必要があります。

この記事では、Solanaプログラムの作成時に開発者が直面する一般的な脆弱性を解説します。まず、Solanaプログラムを悪用する攻撃者の思考様式を紹介し、Solanaのプログラミングモデル、Solanaの設計が本質的に攻撃者に制御され得る仕組み、潜在的な攻撃ベクトル、一般的な軽減策などを取り上げます。続いて、さまざまな脆弱性について説明し、該当する場合は安全でないコードと安全なコードの例も示します。 

この記事はSolanaのプログラミングモデルとプログラム開発に関する知識を前提としているため、中級者または上級者を対象としています。

この記事では、プログラムの構築手順やSolana固有の概念は扱いません。一般的な脆弱性を検証し、その軽減方法を学ぶことに焦点を当てます。Solanaを初めて利用する場合は、この記事を読む前に以下のブログ記事をお読みになることをおすすめします。

Solanaプログラムを悪用する攻撃者の思考様式

Solanaのプログラミングモデル

Solanaのプログラミングモデルは、そのネットワーク上に構築されるアプリケーションのセキュリティ環境を形作っています。Solanaでは、アカウントはコンピューター上のファイルのように、データのコンテナとして機能します。アカウントは、実行可能と実行不可能の2種類に大別できます。実行可能アカウント、つまりプログラムは、コードを実行できるアカウントです。実行不可能アカウントは、コードを一切保存しないため、コードを実行する機能を持たず、データの保存に使用されます。このようにコードとデータが分離されているため、プログラムはステートレスです。トランザクション中に参照として渡される、別のアカウントに保存されたデータとやり取りします。

Solanaは攻撃者に制御され得ます

トランザクションでは、呼び出すプログラム、アカウントのリスト、命令データのバイト配列を指定します。このモデルでは、特定のトランザクションから提供されるアカウントと命令をプログラムが解析し、解釈します。任意のアカウントをプログラムの関数に渡せるため、プログラムが処理するデータに対して攻撃者が大きな制御力を持つことになります。Solanaのプログラミングモデルが本質的に攻撃者に制御され得ることを理解するのは、安全なプログラムの開発に不可欠です。

攻撃者はプログラムの関数にあらゆるアカウントを渡せるため、データ検証はSolanaプログラムのセキュリティを支える基本的な柱となります。開発者は、プログラムが正当な入力と悪意のある入力を区別できるようにしなければなりません。これには、アカウントの所有者の確認、アカウントが想定された型であることの確認、アカウントが署名者であるかどうかの確認が含まれます。

潜在的な攻撃ベクトル

Solana独自のプログラミングモデルと実行環境により、特有の攻撃ベクトルが生じます。潜在的な悪用からプログラムを保護するには、開発者がこれらのベクトルを理解することが不可欠です。攻撃ベクトルには以下が含まれます。

  • ロジックのバグ:プログラムロジックの欠陥が悪用され、資産の損失や不正アクセスなど、意図しない動作を引き起こす可能性があります。これには、プロジェクトの仕様を正しく実装できていない場合も含まれます。プログラムがxを実行すると明示しているなら、xとそれに付随するすべての固有動作を実行すべきです
  • データ検証の欠陥:入力データの検証が不十分だと、攻撃者が悪意のあるデータを渡し、プログラムの状態や実行を操作できる可能性があります
  • Rust固有の問題:Rustには安全機能がありますが、安全でないコードブロック、並行処理の問題、パニックによって脆弱性が生じる可能性があります
  • アクセス制御の脆弱性:アカウントの所有者確認など、アクセス制御チェックを正しく実装しないと、悪意のある攻撃者による不正な操作につながる可能性があります
  • 算術および精度エラー:オーバーフロー、アンダーフロー、精度エラーは、金銭的利益を得るために悪用されたり、プログラムの誤動作を引き起こしたりする可能性があります
  • クロスプログラム呼び出し(CPI)の問題:CPIの処理に欠陥があると、呼び出されたプログラムが悪意のある動作や予期しない動作をした場合に、想定外の状態変更やエラーが発生する可能性があります
  • Program Derived Address(PDA)の誤用:PDAの生成や処理が不適切だと、攻撃者がPDAを乗っ取ったり偽装したりして、不正アクセスやプログラムが制御するアカウントの操作を行える脆弱性につながる可能性があります

Solanaでは、その実行モデルによってリエントランシーが本質的に制限されている点に注意してください。SolanaランタイムはCPIの最大深度を4に制限し、アカウントのデータを変更できるのはその所有者のみとするなど、厳格なアカウント規則を適用します。こうした制約は、直接的な自己再帰を制限し、中間状態のプログラムが意図せず呼び出されないようにすることで、リエントランシー攻撃を防ぎます。

軽減策

こうした潜在的な攻撃を軽減するため、開発者は厳格なテスト、コード監査、ベストプラクティスの順守を組み合わせる必要があります。

  • 包括的な入力検証とアクセス制御チェックを実装します
  • 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では、AccountInfo構造体が提供するrealloc関数に、メモリ管理に関する微妙な脆弱性があります。この関数を使用するとアカウントのデータサイズを再割り当てできるため、プログラム内での動的なデータ処理に役立つ場合があります。しかし、reallocを不適切に使用すると、コンピュートユニットを浪費したり、古いデータが露出したりするなど、意図しない結果を招く可能性があります。

reallocメソッドには2つのパラメーターがあります。

  • new_len:アカウントデータの新しい長さを指定するusizeです
  • zero_init:新しいメモリ領域をゼロ初期化するかどうかを決定するboolです

reallocは次のように定義されています。

コード
pub fn realloc(
    &self,
    new_len: usize,
    zero_init: bool
) -> Result<(), ProgramError>

アカウントデータ用に割り当てられたメモリは、プログラムのエントリーポイントですでにゼロ初期化されています。つまり、単一のトランザクション内でデータをより大きなサイズに再割り当てする場合、新しいメモリ領域はすでにゼロで埋められています。このメモリを再びゼロ初期化する必要はなく、追加のコンピュートユニットを消費するだけです。一方、同じトランザクション内でより小さいサイズに再割り当てしてから、再び大きいサイズに戻すと、zero_initがfalseの場合に古いデータが露出する可能性があります。

シナリオ例

ユーザーが1つのトランザクション内で項目を追加、削除、変更できる、動的なToDoリストプログラムを考えてみます。このプログラムでは、ユーザーの操作に応じてデータサイズを動的に再割り当てする必要があります。

コード
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を複数回再割り当てする可能性があります。同じトランザクション内でToDo項目を削除するためにデータサイズを縮小し、その後、新しい項目を追加するために再び拡大する場合、zero_initをfalseに設定すると、古いデータが露出する可能性があります。

推奨される軽減策

この問題を軽減するには、zero_initパラメーターを慎重に使用することが重要です。

  • 同じトランザクション呼び出し内で一度データサイズを縮小した後に拡大する場合は、zero_initをtrueに設定します。これにより、新しいメモリ領域がすべてゼロ初期化され、古いデータの露出を防げます
  • 同じトランザクション呼び出し内で事前にデータサイズを縮小せずに拡大する場合は、メモリがすでにゼロ初期化されているため、zero_initをfalseに設定します

特定のサイズ要件を満たすためにデータを再割り当てする代わりに、開発者は Address Lookup Table(ALT)を使用すべきです。ALTを使用すると、最大256個のアドレスを単一のオンチェーンアカウントに保存して、トランザクションのデータを圧縮できます。テーブル内の各アドレスは1バイトのインデックスで参照できるため、特定のトランザクションでアドレス参照に必要なデータを大幅に削減できます。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は、プログラムが対象プログラムの身元を確認せずに別のプログラムを呼び出す場合に発生します。この脆弱性が存在するのは、呼び出し元が呼び出し先のプログラムIDを持ち、そのインターフェースに従っていれば、Solanaランタイムが任意のプログラムから別のプログラムを呼び出すことを許可しているためです。プログラムが呼び出し先のプログラムIDを検証せず、ユーザー入力に基づいてCPIを実行すると、攻撃者が制御するプログラム内のコードを実行する可能性があります。

シナリオ例

プロジェクトへの貢献度に基づいて参加者に報酬を分配するプログラムを考えてみます。報酬を分配した後、プログラムは監査と追跡のため、その詳細を別の台帳プログラムに記録します。この台帳プログラムは信頼できるプログラムであり、承認済みプログラムからの特定の記録を追跡するための公開インターフェースを提供すると想定されています。プログラムには、台帳プログラムをアカウントとして受け取り、報酬を分配して記録する関数があります。しかし、この関数は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プログラムから簡単かつ安全にそのプログラムを呼び出せます。AnchorのCPIモジュールは、渡されたプログラムのアドレスがモジュール内に保存されたプログラムのアドレスと一致することを自動的に確認します。別の解決策として、ユーザーにアドレスを渡させる代わりに、アドレスをハードコードすることもできます。

権限移譲機能

脆弱性

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
}

ここでは、権限者が静的に定義されており、新しいアドレスに更新できません。

推奨される軽減策

この問題を安全に軽減する方法は、権限移譲を2段階のプロセスにすることです。このプロセスでは、現在の権限者が新しいpending_authorityを指名し、指名された権限者がその役割を明示的に承諾します。これにより権限移譲機能を提供できるだけでなく、誤操作による移譲や悪意のある乗っ取りも防止できます。フローは次のようになります。

  • 現在の権限者による指名:現在の権限者がnominate_new_authorityを呼び出して新しいpending_authorityを指名し、プログラム状態のpending_authorityフィールドを設定します
  • 新しい権限者による承諾:指名されたpending_authorityがaccept_authorityを呼び出して新しい役割を引き受け、現在の権限者からpending_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がトランザクションの署名者と一致することを確認し、その署名者が承諾して新しい権限者になれるようにします。この構成により、プログラム内で権限を安全かつ制御された方法で移譲できます。

バンプシードの正規化

脆弱性

バンプシードの正規化とは、PDAを導出する際に、有効なバンプシードのうち最大のもの、つまり正規バンプを使用することです。正規バンプを使用すると、特定のシードセットから決定論的かつ安全にアドレスを見つけられます。正規バンプを使用しないと、悪意のある攻撃者がPDAを作成または操作し、プログラムロジックやデータの完全性を侵害するなどの脆弱性につながる可能性があります。

シナリオ例

それぞれにcreate_program_addressを明示的に使用して導出されたPDAが関連付けられる、一意のユーザープロフィールを作成するプログラムを考えてみます。このプログラムでは、ユーザーが指定したバンプを受け取ってプロフィールを作成できます。しかし、正規ではないバンプを使用するリスクが生じるため、これは問題です。

コード
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>,
}

このシナリオでは、プログラムはユーザー指定のバンプを含むシードとcreate_program_addressを使用して、UserProfile PDAを導出します。ユーザー指定のバンプを使用すると、正規バンプの使用を保証できないため問題になります。これにより、悪意のある攻撃者が同じユーザーIDに対し、異なるバンプを持つ複数のPDAを作成できるようになります。

推奨される軽減策

この問題を軽減するため、find_program_addressを使用してPDAを導出し、バンプシードを明示的に検証するように例をリファクタリングできます。

コード
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,
}

ここでは、正規バンプシードを使用してPDAを導出するためにfind_program_addressを使用し、決定論的で安全なPDA作成を保証しています。正規バンプはUserProfileアカウントに保存されるため、後続の操作で効率的かつ安全に検証できます。create_program_addressではなくfind_program_addressを使用するのは、前者がバンプシードを検索せずに有効なPDAを作成するためです。バンプシードを検索しないため、任意のシードセットに対して予測不能にエラーを返す可能性があり、通常はPDAの作成に適していません。find_program_addressは、PDAを作成する際に常に正規バンプを使用します。これは、バンプを255から開始し、反復ごとに減らしながら、複数のcreate_program_address呼び出しを繰り返すためです。有効なアドレスが見つかると、この関数は導出されたPDAと、その導出に使用した正規バンプを返します。

アカウントのクローズ

脆弱性

プログラム内でアカウントを不適切にクローズすると、「クローズ済み」のアカウントが再初期化されたり、不正利用されたりするなど、複数の脆弱性につながる可能性があります。この問題は、アカウントをクローズ済みとして適切にマークしないことや、後続のトランザクションでの再利用を防止しないことから生じます。この見落としにより、悪意のある人物が対象のアカウントを悪用し、プログラム内で不正な操作やアクセスを行う可能性があります。

シナリオ例

ユーザーがデータストレージ用アカウントを作成およびクローズできるプログラムを考えてみます。このプログラムは、アカウントから 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 の転送、データのゼロクリア、クローズ済みアカウントのディスクリミネーター設定を1回の操作で行い、安全なアカウントのクローズを自動化します。

重複する可変アカウント

脆弱性

重複する可変アカウントとは、同じアカウントが可変パラメーターとして1つの命令に複数回渡される状況を指します。これは、命令が同じ型の可変アカウントを2つ必要とする場合に発生します。悪意のある人物が同じアカウントを2回渡すと、アカウントが意図しない形で変更される可能性があります(データの上書きなど)。この脆弱性の深刻度は、具体的なシナリオによって異なります。

シナリオ例

特定のオンチェーンアクティビティへの参加状況に応じて、ユーザーに報酬を付与するプログラムを考えてみます。このプログラムには、報酬アカウントとボーナスアカウントという2つのアカウントの残高を更新する命令があります。ユーザーは、一方のアカウントで標準報酬を受け取り、あらかじめ定められた特定の基準に基づいて、もう一方のアカウントでボーナスを受け取る可能性があります。

コード
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 に同じアカウントを渡すと、そのアカウントの残高が誤って2回更新されます。

推奨される対策

この問題を軽減するには、命令ロジック内にチェックを追加し、2つのアカウントの公開鍵が同一でないことを確認します。

コード
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 の mempool が廃止されたため、ここでいうフロントランニングとは、悪意のある人物が慎重に構成したトランザクションを通じて、期待値と実際の値の差を操作できることを指します。 

シナリオ例

商品の購入と入札を処理し、販売者の価格情報を 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 などの名前の関数を使用します)。初期化関数では通常、プログラムの権限などのデータを設定したり、デプロイするプログラムの基盤となるアカウント(中央状態アカウントなど)を作成したりします。

初期化関数はプログラムのデプロイ時に自動で呼び出されるのではなく、手動で呼び出されるため、この命令はプログラムの開発チームが管理する既知のアドレスから呼び出す必要があります。そうしないと、攻撃者が初期化をフロントランニングし、攻撃者の管理下にあるアカウントを使ってプログラムをセットアップする可能性があります。

プログラムにアップグレード権限がある場合、initialize 関数の呼び出しを許可するアドレスとして、プログラムの upgrade_authority を使用するのが一般的です。

安全でない例と軽減方法

コード
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,
  ...
}

上記の例は、命令の呼び出し元を CentralState アカウントの権限として設定する、簡略化された initialize 関数です。しかし、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 を乗算する前に商が切り捨てられ、精度が失われる場合があります。その結果、想定より小さい値になる可能性があります。反対に、c で除算する前に (a * b) を計算すると、元の精度をより多く維持できる可能性があります。この違いによって計算ミスが生じ、予期しないプログラム動作やアービトラージの機会につながることがあります。

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 が1トランザクションあたり100トークンであるシナリオを考えてみます。この2つを乗算すると、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
}

悪意のある人物は、一致する admin フィールドを持つ、自身が管理する config アカウントを渡すことでこれを悪用し、プログラムを騙して資金の引き出しを実行させる可能性があります。

推奨される対策

この問題を軽減するには、アカウントの 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> は任意の式です。

読み取り専用アカウント

プログラムの実行コンテキスト内で読み取り専用として指定されたアカウントの有効性を検証することも、同様に重要です。悪意のある人物が正規のアカウントの代わりに、任意または細工されたデータを持つアカウントを渡す可能性があるためです。これにより、予期しない、または有害なプログラム動作につながる可能性があります。開発者は、プログラムが読み取る必要のあるアカウントが正規のものであり、改ざんされていないことを確認するチェックを引き続き行う必要があります。特に sysvar(Clock や EpochSchedule などの読み取り専用システムアカウント)については、既知の値と照合してアカウントのアドレスを検証したり、アカウントの所有者が想定どおりであることを確認したりします。sysvar には get() メソッドを使用してアクセスしてください。この方法では、アドレスや所有権を手動でチェックする必要がありません。これらのアカウントへアクセスする方法としてより安全ですが、すべての sysvar が 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.pubkey() = config.admin となる適切な 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 では、デバッグモードでのコンパイル時に整数オーバーフローとアンダーフローをチェックします。このような状態が検出されると、実行時にプログラムが panic します。ただし、--release フラグを指定してリリースモードでコンパイルする場合、Rust には整数オーバーフローやアンダーフローによって panic するチェックが含まれません。オーバーフローやアンダーフローが暗黙的に発生するため、この動作によって見つけにくい脆弱性が生じる可能性があります。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

この脆弱性を軽減する最も簡単な方法は、プロジェクトの Cargo.toml ファイルで overflow-checks キーを true に設定することです。これにより、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(())
}

修正後の例では、balance から tokens_to_subtract を減算するために checked_sub を使用しています。したがって、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() も 1 つだけです。これは、チェック対象のいずれかのステップが None を返した場合に None を返す式へ、マクロが通常の数式を変換するためです。成功した場合は Some(_) が返されるため、最後に式を unwrap します。

キャスト

同様に、適切なチェックを行わずに 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
}

ステーキング機能と報酬引き出し機能が、どちらも staking_pool_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", &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 と報酬引き出し用 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 での開発には、特に unsafe コードや Rust 固有のエラーに関して、独自の課題と考慮事項があります。Rust の注意点を理解することは、安全で効率的かつ信頼性の高いプログラムの開発に役立ちます。

Unsafe Rust

Rust は、厳格な所有権と借用の仕組みによって実現されるメモリ安全性の保証で高く評価されています。しかし、こうした保証が妨げになる場合もあるため、Rust には安全性チェックを回避するための unsafe キーワードが用意されています。unsafe Rust は、主に次の 4 つのコンテキストで使用されます。

  • Unsafe 関数:Rust の安全性保証に違反する可能性がある操作を実行する関数には、unsafe キーワードを付ける必要があります。例:unsafe fn dangerous_function() {}
  • Unsafe ブロック:安全でない操作が許可されるコードブロックです。例:unsafe { // Unsafe operations }
  • Unsafe トレイト:コンパイラが検証できない特定の不変条件を前提とするトレイトです。例:unsafe trait BadTrait {}
  • Unsafe トレイトの実装:unsafe トレイトの実装にも unsafe を付ける必要があります。例:unsafe impl UnsafeTrait for UnsafeType {}

Unsafe Rust が存在するのは、静的解析が保守的であるためです。コンパイラがコードによって特定の保証が維持されているかを判断する場合、無効なコードをいくつか受け入れるより、有効なコードをいくつか拒否する方が安全です。コードが問題なく動作する可能性があっても、Rust の安全性保証を維持していると確信するのに十分な情報がなければ、Rust コンパイラはそのコードを拒否します。Unsafe コードを使用すると、開発者は自己責任でこれらのチェックを回避できます。また、コンピュータハードウェアは本質的に安全ではありません。Rust で低レベルプログラミングを行うには、開発者が安全でない操作を実行できる必要があります。

unsafe キーワードを使用すると、開発者は次の操作を実行できます。

  • 生ポインタの参照外し:任意のメモリ位置を指す可能性があり、有効なデータを保持しているとは限らない生ポインタへ直接メモリアクセスできます
  • Unsafe 関数の呼び出し:これらの関数は Rust の安全性保証に準拠していない可能性があり、未定義動作につながることがあります
  • 可変静的変数へのアクセス:グローバルな可変状態はデータ競合を引き起こす可能性があります

Unsafe Rust に対する最善の対策は、unsafe ブロックの使用を最小限に抑えることです。理由を問わず unsafe コードがどうしても必要な場合は、十分に文書化して定期的に監査し、可能であれば、プログラムの他の部分へ提供できる安全な抽象化の内部にカプセル化してください。

Panic とエラー管理

panic は、Rust プログラムで回復不能なエラーが発生し、実行が終了するときに起こります。panic は、捕捉を意図していない予期しないエラーに使用されます。Solana プログラムでは、ランタイムがプログラムにクラッシュせず適切にエラーを処理することを期待するため、panic が予期しない動作につながる可能性があります。

panic が発生すると、Rust はスタックの巻き戻しを開始し、処理しながらクリーンアップします。これにより、関連するエラーの詳細情報を含むスタックトレースが返されます。この情報から、攻撃者に内部のファイル構造を知られる可能性があります。これは Solana プログラムへ直接当てはまるものではありませんが、プログラムが使用する依存関係がこのような攻撃に対して脆弱な場合があります。依存関係を最新の状態に保ち、既知の脆弱性を含まないバージョンを使用してください。

panic が発生する一般的なシナリオは次のとおりです。

  • ゼロ除算:Rust では、ゼロで除算しようとすると panic が発生します。そのため、除算を実行する前に、除数がゼロでないことを必ず確認してください
  • 配列インデックスの範囲外アクセス:配列の範囲を超えるインデックスでアクセスすると、panic が発生します。対策として、Option 型を返すメソッド(get など)を使用し、配列要素へ安全にアクセスしてください
  • None 値の unwrap:None 値を保持する Option に対して .unwrap() を呼び出すと、panic が発生します。Result を返す関数では、必ずパターンマッチング、unwrap_or や unwrap_or_else などのメソッド、または ? 演算子を使用してください

panic に関連する問題を軽減するには、panic を引き起こす操作を避け、問題のある操作につながる可能性があるすべての入力と条件を検証し、エラー処理に Result 型と Option 型を使用することが重要です。また、包括的なプログラムテストを作成すると、デプロイ前に潜在的な panic シナリオを発見して対処できます。

シードの衝突

脆弱性

シードの衝突は、PDA の生成に使用される異なる入力(シードやプログラム ID など)から、同じ 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 を互いに区別しやすくなります
  • 一意の識別子(タイムスタンプ、ユーザー ID、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固有のエラーからAnchorのreallocメソッドの複雑さまで、一般的な脆弱性を幅広く取り上げました。これらの脆弱性やプログラムセキュリティ全般を習得する道のりに終わりはなく、継続的な学習、適応、連携が必要です。開発者がセキュリティに取り組む目的は、資産を守ることだけではありません。信頼を築き、アプリケーションの完全性を確保し、Solanaの成長と安定性に貢献することでもあります。

ここまでお読みいただき、ありがとうございます、anon!以下にメールアドレスを入力して、Solanaの最新情報を見逃さないようにしましょう。さらに詳しく知りたいですか?Heliusブログの最新記事を読み、今すぐSolanaの旅を続けましょう。

その他のリソース

Heliusを購読

Solana開発の最新情報や新しい記事の公開通知を受け取れます

拡大画像