MỚI: Helius mua lại Light Protocol
Cẩm nang quá giang về bảo mật chương trình Solana
Blog/Kiến thức nền tảng

Cẩm nang quá giang về bảo mật chương trình Solana

Developer Experience Engineer0xIchigo trên X0xIchigo trên LinkedIn0xIchigo trên GitHub
Đọc trong 39 phút
Mục lục

Bài viết này được đồng tác giả bởi bl0ckpain, một nhà nghiên cứu bảo mật và nhà phát triển hợp đồng thông minh từng làm việc với Kudelski Security và Halborn

Giới thiệu

Bảo mật chương trình Solana không chỉ là ngăn tin tặc đánh cắp tiền của dự án — mà còn là đảm bảo chương trình hoạt động đúng như dự kiến, tuân thủ đặc tả của dự án và kỳ vọng của người dùng. Bảo mật chương trình Solana có thể ảnh hưởng đến hiệu suất, khả năng mở rộng và khả năng tương tác của dApp. Vì vậy, nhà phát triển phải nhận thức được các hướng tấn công tiềm ẩn và lỗ hổng phổ biến trước khi xây dựng ứng dụng dành cho người dùng cuối.

Bài viết này tìm hiểu các lỗ hổng phổ biến mà nhà phát triển sẽ gặp khi tạo chương trình Solana. Trước tiên, chúng tôi giới thiệu tư duy của kẻ tấn công khi khai thác chương trình Solana, bao gồm các chủ đề như mô hình lập trình của Solana, cách thiết kế của Solana vốn nằm dưới sự kiểm soát của kẻ tấn công, các hướng tấn công tiềm ẩn và chiến lược giảm thiểu phổ biến. Sau đó, chúng tôi trình bày nhiều lỗ hổng khác nhau, giải thích từng lỗ hổng và cung cấp ví dụ mã không an toàn cũng như mã an toàn khi phù hợp. 

Lưu ý rằng bài viết này dành cho độc giả ở trình độ trung cấp hoặc nâng cao vì giả định người đọc đã hiểu mô hình lập trình và cách phát triển chương trình trên Solana.

Bài viết này sẽ không trình bày quy trình xây dựng chương trình hoặc các khái niệm dành riêng cho Solana — trọng tâm là xem xét các lỗ hổng phổ biến và tìm hiểu cách giảm thiểu chúng. Nếu bạn mới làm quen với Solana, chúng tôi khuyên bạn đọc các bài viết trước đây sau đây trước khi tiếp tục:

Tư duy của kẻ tấn công khi khai thác chương trình Solana

Mô hình lập trình của Solana

Mô hình lập trình của Solana định hình bối cảnh bảo mật của các ứng dụng được xây dựng trên mạng này. Trên Solana, tài khoản đóng vai trò là vùng chứa dữ liệu, tương tự như tệp trên máy tính. Có thể chia tài khoản thành hai loại tổng quát: có thể thực thi và không thể thực thi. Tài khoản có thể thực thi, hay chương trình, là những tài khoản có khả năng chạy mã. Tài khoản không thể thực thi được dùng để lưu trữ dữ liệu mà không có khả năng chạy mã (vì chúng không lưu trữ mã). Việc tách biệt mã và dữ liệu này đồng nghĩa với việc các chương trình không có trạng thái — chúng tương tác với dữ liệu được lưu trong các tài khoản khác, được truyền bằng tham chiếu trong giao dịch.

Solana nằm dưới sự kiểm soát của kẻ tấn công

Một giao dịch chỉ định chương trình cần gọi, danh sách tài khoản và một mảng byte chứa dữ liệu chỉ thị. Mô hình này dựa vào chương trình để phân tích cú pháp và diễn giải các tài khoản cũng như chỉ thị do một giao dịch cung cấp. Việc cho phép truyền bất kỳ tài khoản nào vào hàm của chương trình trao cho kẻ tấn công quyền kiểm soát đáng kể đối với dữ liệu mà chương trình sẽ xử lý. Hiểu rõ mô hình lập trình của Solana vốn nằm dưới sự kiểm soát của kẻ tấn công là điều thiết yếu để phát triển chương trình an toàn.

Do kẻ tấn công có thể truyền bất kỳ tài khoản nào vào hàm của chương trình, việc xác thực dữ liệu trở thành trụ cột nền tảng của bảo mật chương trình Solana. Nhà phát triển phải đảm bảo chương trình có thể phân biệt đầu vào hợp lệ với đầu vào độc hại. Điều này bao gồm xác minh quyền sở hữu tài khoản, đảm bảo tài khoản thuộc loại dự kiến và xác định tài khoản có phải là bên ký hay không.

Các hướng tấn công tiềm ẩn

Mô hình lập trình và môi trường thực thi độc đáo của Solana làm phát sinh các hướng tấn công cụ thể. Việc hiểu rõ các hướng này rất quan trọng để nhà phát triển bảo vệ chương trình trước những hành vi khai thác tiềm ẩn. Các hướng tấn công này bao gồm:

  • Lỗi logic: các khiếm khuyết trong logic chương trình có thể bị thao túng để gây ra hành vi ngoài dự kiến, chẳng hạn như mất tài sản hoặc truy cập trái phép. Điều này cũng bao gồm việc không triển khai đúng đặc tả của dự án — nếu một chương trình tuyên bố thực hiện x, thì chương trình đó phải thực hiện x cùng mọi đặc điểm riêng của nó
  • Khiếm khuyết trong xác thực dữ liệu: xác thực dữ liệu đầu vào không đầy đủ có thể cho phép kẻ tấn công truyền dữ liệu độc hại và thao túng trạng thái hoặc quá trình thực thi của chương trình
  • Vấn đề riêng của Rust: bất chấp các tính năng an toàn của Rust, khối mã không an toàn, vấn đề tương tranh và panic vẫn có thể tạo ra lỗ hổng
  • Lỗ hổng kiểm soát truy cập: không triển khai đúng các bước kiểm tra quyền truy cập, chẳng hạn như xác minh chủ sở hữu tài khoản, có thể dẫn đến hành động trái phép của tác nhân độc hại
  • Lỗi số học và độ chính xác: tràn số trên/tràn số dưới và lỗi độ chính xác có thể bị khai thác để trục lợi tài chính hoặc khiến chương trình hoạt động sai
  • Vấn đề với lời gọi liên chương trình (CPI): khiếm khuyết trong quá trình xử lý CPI có thể dẫn đến thay đổi trạng thái hoặc lỗi ngoài dự kiến nếu chương trình được gọi hoạt động độc hại hoặc bất thường
  • Sử dụng sai địa chỉ dẫn xuất từ chương trình (PDA): tạo hoặc xử lý PDA không đúng cách có thể dẫn đến các lỗ hổng cho phép kẻ tấn công chiếm quyền hoặc giả mạo PDA để truy cập trái phép hay thao túng các tài khoản do chương trình kiểm soát

Lưu ý rằng khả năng tái nhập vốn bị hạn chế trên Solana do mô hình thực thi của nền tảng. Môi trường chạy Solana giới hạn CPI ở độ sâu tối đa là bốn và thực thi các quy tắc nghiêm ngặt đối với tài khoản, chẳng hạn như chỉ cho phép chủ sở hữu tài khoản sửa đổi dữ liệu của tài khoản đó. Những ràng buộc này ngăn chặn các cuộc tấn công tái nhập bằng cách hạn chế đệ quy trực tiếp vào chính chương trình và đảm bảo chương trình không thể bị gọi ngoài ý muốn khi đang ở trạng thái trung gian.

Chiến lược giảm thiểu

Để giảm thiểu các cuộc tấn công tiềm ẩn này, nhà phát triển nên kết hợp kiểm thử nghiêm ngặt, kiểm toán mã và tuân thủ các phương pháp hay nhất:

  • Triển khai quy trình xác thực đầu vào và kiểm tra quyền truy cập toàn diện
  • Tận dụng tối đa hệ thống kiểu và các tính năng an toàn của Rust, tránh mã không an toàn trừ khi cần thiết
  • Tuân thủ các phương pháp bảo mật tốt nhất của Solana và Rust, đồng thời cập nhật những tiến triển mới
  • Thực hiện đánh giá mã nội bộ và sử dụng công cụ tự động để xác định các lỗ hổng phổ biến cũng như lỗi logic trong quá trình phát triển chương trình
  • Nhờ các bên thứ ba uy tín kiểm toán cơ sở mã, bao gồm các công ty bảo mật và nhà nghiên cứu bảo mật độc lập
  • Tạo nền tảng săn lỗi nhận thưởng cho chương trình để khuyến khích báo cáo lỗ hổng thay vì phụ thuộc vào hacker mũ xám

Các phần sau sẽ lần lượt tìm hiểu những lỗ hổng khác nhau theo thứ tự bảng chữ cái. Mỗi phần sẽ mô tả một lỗ hổng tiềm ẩn, giải thích cách giảm thiểu và đưa ra các tình huống ví dụ khi có thể.

Đối chiếu dữ liệu tài khoản

Lỗ hổng

Đối chiếu dữ liệu tài khoản là lỗ hổng phát sinh khi nhà phát triển không kiểm tra xem dữ liệu lưu trên tài khoản có khớp với tập hợp giá trị dự kiến hay không. Nếu không có các bước kiểm tra xác thực dữ liệu thích hợp, chương trình có thể vô tình hoạt động với tài khoản không chính xác hoặc bị thay thế bằng tài khoản độc hại. Lỗ hổng này đặc biệt nghiêm trọng trong các tình huống liên quan đến kiểm tra quyền.

Tình huống ví dụ

Hãy xem xét một chương trình có chức năng quản lý các thiết lập quản trị. Chương trình bao gồm một chỉ thị để cập nhật cấu hình quản trị hiện tại, chẳng hạn như cờ tính năng hoặc tham số vận hành. Chỉ thị phải xác thực rằng yêu cầu đến từ quản trị viên được ủy quyền. Tuy nhiên, chương trình không xác minh rằng tài khoản yêu cầu thay đổi có khớp với tài khoản quản trị viên được lưu trong dữ liệu cấu hình hay không:

Mã
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
}

Biện pháp giảm thiểu được đề xuất

Để giảm thiểu lỗ hổng này, nhà phát triển có thể triển khai các bước kiểm tra rõ ràng nhằm so sánh khóa tài khoản và dữ liệu đã lưu với các giá trị dự kiến. Ví dụ, hãy xác minh rằng khóa công khai của người gửi tiền khớp với trường chủ sở hữu của tài khoản token được dùng để gửi tiền:

Mã
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(())
}

Nhà phát triển cũng có thể dùng các thuộc tính has_one và constraint của Anchor để thực thi kiểm tra xác thực dữ liệu theo kiểu khai báo. Với ví dụ trên, chúng ta có thể dùng thuộc tính constraint để kiểm tra xem khóa công khai của người gửi tiền và chủ sở hữu tài khoản token dùng để gửi tiền có giống nhau hay không:

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

Phân bổ lại dữ liệu tài khoản

Lỗ hổng

Trong Anchor, hàm realloc do struct AccountInfo cung cấp tạo ra một lỗ hổng tinh vi liên quan đến quản lý bộ nhớ. Hàm này cho phép phân bổ lại kích thước dữ liệu của tài khoản, có thể hữu ích khi xử lý dữ liệu động trong chương trình. Tuy nhiên, sử dụng realloc không đúng cách có thể dẫn đến hậu quả ngoài dự kiến, bao gồm lãng phí đơn vị tính toán hoặc có khả năng làm lộ dữ liệu cũ.

Phương thức realloc có hai tham số:

  • new_len: một usize chỉ định độ dài mới của dữ liệu tài khoản
  • zero_init: một bool xác định vùng nhớ mới có cần được khởi tạo bằng số 0 hay không

realloc được định nghĩa như sau:

Mã
pub fn realloc(
    &self,
    new_len: usize,
    zero_init: bool
) -> Result<(), ProgramError>

Bộ nhớ được cấp phát cho dữ liệu tài khoản đã được khởi tạo bằng số 0 tại điểm vào của chương trình. Điều này có nghĩa là vùng nhớ mới đã được đặt về 0 khi dữ liệu được phân bổ lại với kích thước lớn hơn trong một giao dịch duy nhất. Việc đặt vùng nhớ này về 0 lần nữa là không cần thiết và làm tăng mức tiêu thụ đơn vị tính toán. Ngược lại, nếu phân bổ lại về kích thước nhỏ hơn rồi tăng trở lại trong cùng một giao dịch, dữ liệu cũ có thể bị lộ nếu zero_init là false.

Tình huống ví dụ

Hãy xem xét một chương trình danh sách việc cần làm động, trong đó người dùng có thể thêm, xóa hoặc sửa đổi các mục trong một giao dịch duy nhất. Chương trình này cần phân bổ lại kích thước dữ liệu một cách linh hoạt dựa trên hành động của người dùng:

Mã
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
}

Trong tình huống này, hàm modify_todo_list có thể phân bổ lại to_do_list_data nhiều lần để đáp ứng kích thước cần thiết cho các thay đổi. Nếu kích thước dữ liệu được giảm để xóa một mục việc cần làm rồi tăng trở lại để thêm mục mới trong cùng một giao dịch, việc đặt zero_init thành false có thể làm lộ dữ liệu cũ.

Biện pháp giảm thiểu được đề xuất

Để giảm thiểu vấn đề này, cần sử dụng tham số zero_init một cách thận trọng:

  • Đặt zero_init thành true khi tăng kích thước dữ liệu sau một lần giảm trước đó trong cùng một lệnh gọi giao dịch. Điều này đảm bảo mọi vùng nhớ mới đều được khởi tạo bằng số 0, ngăn dữ liệu cũ bị lộ
  • Đặt zero_init thành false khi tăng kích thước dữ liệu mà trước đó không giảm trong cùng một lệnh gọi giao dịch, vì bộ nhớ đã được khởi tạo bằng số 0

Thay vì phân bổ lại dữ liệu để đáp ứng các yêu cầu cụ thể về kích thước, nhà phát triển nên sử dụng Bảng tra cứu địa chỉ (ALT). ALT cho phép nhà phát triển nén dữ liệu của giao dịch bằng cách lưu trữ tối đa 256 địa chỉ trong một tài khoản on-chain duy nhất. Sau đó, mỗi địa chỉ trong bảng có thể được tham chiếu bằng một chỉ mục 1 byte, qua đó giảm đáng kể lượng dữ liệu cần thiết cho các tham chiếu địa chỉ trong một giao dịch nhất định. ALT hữu ích hơn nhiều trong các tình huống cần tương tác động với tài khoản mà không phải thường xuyên thay đổi kích thước bộ nhớ.

Tải lại tài khoản

Lỗ hổng

Tải lại tài khoản là lỗ hổng phát sinh khi nhà phát triển không cập nhật các tài khoản đã giải tuần tự hóa sau khi thực hiện CPI. Anchor không tự động làm mới trạng thái của tài khoản đã giải tuần tự hóa sau CPI. Điều này có thể dẫn đến các tình huống mà logic chương trình hoạt động trên dữ liệu cũ, gây ra lỗi logic hoặc phép tính không chính xác.

Tình huống ví dụ

Hãy xem xét một giao thức cho phép người dùng stake token để nhận phần thưởng theo thời gian. Chương trình hỗ trợ giao thức này có chức năng cập nhật phần thưởng staking của người dùng dựa trên một số điều kiện hoặc tác nhân kích hoạt bên ngoài. Phần thưởng của người dùng được tính toán và cập nhật thông qua CPI đến một chương trình phân phối phần thưởng. Tuy nhiên, chương trình không cập nhật tài khoản staking ban đầu sau CPI để phản ánh số dư phần thưởng mới:

Mã
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,
}

Trong ví dụ này, hàm update_rewards cố gắng cập nhật phần thưởng cho tài khoản staking của người dùng thông qua một lệnh gọi CPI đến chương trình phân phối phần thưởng. Ban đầu, chương trình ghi nhật ký ctx.accounts.staking_account.rewards (tức số dư phần thưởng) sau CPI, rồi tiếp tục thực thi logic sử dụng dữ liệu ctx.accounts.staking_account.rewards cũ. Vấn đề là trạng thái của tài khoản staking không được tự động cập nhật sau CPI, vì vậy dữ liệu đã lỗi thời.

Biện pháp giảm thiểu được đề xuất

Để giảm thiểu vấn đề này, hãy gọi rõ ràng phương thức reload của Anchor để tải lại một tài khoản nhất định từ bộ nhớ lưu trữ. Tải lại tài khoản sau CPI sẽ phản ánh chính xác trạng thái của tài khoản đó:

Mã
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 tùy ý

Lỗ hổng

CPI tùy ý xảy ra khi một chương trình gọi chương trình khác mà không xác minh danh tính của chương trình đích. Lỗ hổng này tồn tại vì môi trường chạy Solana cho phép bất kỳ chương trình nào gọi một chương trình khác nếu bên gọi có ID chương trình của bên được gọi và tuân thủ giao diện của bên được gọi. Nếu một chương trình thực hiện CPI dựa trên đầu vào của người dùng mà không xác thực ID chương trình của bên được gọi, chương trình đó có thể thực thi mã trong một chương trình do kẻ tấn công kiểm soát.

Tình huống ví dụ

Hãy xem xét một chương trình phân phối phần thưởng cho những người tham gia dựa trên đóng góp của họ cho một dự án. Sau khi phân phối phần thưởng, chương trình ghi lại thông tin chi tiết trong một chương trình sổ cái riêng biệt nhằm phục vụ mục đích kiểm toán và theo dõi. Chương trình sổ cái được giả định là đáng tin cậy và cung cấp giao diện công khai để theo dõi các mục cụ thể từ những chương trình được ủy quyền. Chương trình bao gồm một hàm để phân phối và ghi nhận phần thưởng, nhận chương trình sổ cái làm tài khoản. Tuy nhiên, hàm này không xác minh ledger_program được cung cấp trước khi thực hiện CPI đến đó:

Mã
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>,
}

Kẻ tấn công có thể khai thác điều này bằng cách truyền ID của một chương trình độc hại làm ledger_program, dẫn đến hậu quả ngoài dự kiến.

Biện pháp giảm thiểu được đề xuất

Để bảo vệ trước vấn đề này, nhà phát triển có thể thêm một bước kiểm tra nhằm xác minh danh tính của chương trình sổ cái trước khi thực hiện CPI. Bước kiểm tra này sẽ đảm bảo lệnh gọi CPI được gửi đến đúng chương trình dự kiến, qua đó ngăn chặn CPI tùy ý:

Mã
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>,
}

Một chương trình có thể có mô-đun CPI công khai nếu được viết bằng Anchor. Điều này giúp việc gọi chương trình từ một chương trình Anchor khác trở nên dễ dàng và an toàn. Mô-đun CPI của Anchor tự động kiểm tra xem địa chỉ chương trình được truyền vào có khớp với địa chỉ chương trình được lưu trong mô-đun hay không. Ngoài ra, mã hóa cứng địa chỉ có thể là một giải pháp thay vì để người dùng truyền địa chỉ vào.

Chức năng chuyển giao quyền hạn

Lỗ hổng

Các chương trình Solana thường chỉ định những khóa công khai cụ thể làm chủ thể có quyền hạn đối với các chức năng quan trọng, chẳng hạn như cập nhật tham số chương trình hoặc rút tiền. Tuy nhiên, việc không thể chuyển giao quyền hạn này sang một địa chỉ khác có thể gây ra rủi ro đáng kể. Hạn chế này trở nên đặc biệt nghiêm trọng trong các tình huống như thay đổi đội ngũ, bán giao thức hoặc khi chủ thể có quyền hạn bị xâm phạm.

Tình huống ví dụ

Hãy xem xét một chương trình trong đó quản trị viên toàn cục chịu trách nhiệm thiết lập các tham số giao thức cụ thể thông qua hàm set_params. Chương trình không có cơ chế thay đổi quản trị viên toàn cục:

Mã
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
}

Ở đây, chủ thể có quyền hạn được định nghĩa tĩnh và không thể cập nhật sang địa chỉ mới.

Biện pháp giảm thiểu được đề xuất

Một cách tiếp cận an toàn để giảm thiểu vấn đề này là tạo quy trình chuyển giao quyền hạn gồm hai bước. Quy trình này cho phép chủ thể có quyền hạn hiện tại đề cử một pending_authority mới, người phải chấp nhận vai trò một cách rõ ràng. Cách này không chỉ cung cấp chức năng chuyển giao quyền hạn mà còn bảo vệ trước việc chuyển giao do nhầm lẫn hoặc hành vi chiếm quyền độc hại. Quy trình sẽ như sau:

  • Đề cử bởi chủ thể có quyền hạn hiện tại: chủ thể có quyền hạn hiện tại đề cử một pending_authority mới bằng cách gọi nominate_new_authority, thao tác này đặt trường pending_authority trong trạng thái chương trình
  • Chấp nhận bởi chủ thể có quyền hạn mới: pending_authority được đề cử gọi accept_authority để tiếp nhận vai trò mới, chuyển quyền hạn từ chủ thể hiện tại sang pending_authority

Quy trình này sẽ có dạng như sau:

Mã
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
}

Trong ví dụ này, cấu trúc tài khoản ProgramState lưu giữ authority hiện tại và một pending_authority tùy chọn. Ngữ cảnh NominateAuthority đảm bảo chủ thể có quyền hạn hiện tại ký giao dịch, cho phép họ đề cử chủ thể mới. Ngữ cảnh AcceptAuthority kiểm tra rằng pending_authority khớp với bên ký giao dịch, cho phép họ chấp nhận và trở thành chủ thể có quyền hạn mới. Thiết lập này đảm bảo quá trình chuyển giao quyền hạn trong chương trình diễn ra an toàn và có kiểm soát.

Chuẩn hóa bump seed

Lỗ hổng

Chuẩn hóa bump seed là việc sử dụng bump seed hợp lệ cao nhất (tức canonical bump) khi dẫn xuất PDA. Sử dụng canonical bump là cách xác định và an toàn để tìm địa chỉ từ một tập hợp seed. Không sử dụng canonical bump có thể dẫn đến các lỗ hổng, chẳng hạn như tác nhân độc hại tạo hoặc thao túng PDA làm tổn hại đến logic chương trình hoặc tính toàn vẹn dữ liệu.

Tình huống ví dụ

Hãy xem xét một chương trình được thiết kế để tạo hồ sơ người dùng duy nhất, mỗi hồ sơ có một PDA liên kết được dẫn xuất rõ ràng bằng create_program_address. Chương trình cho phép tạo hồ sơ bằng cách nhận bump do người dùng cung cấp. Tuy nhiên, điều này có vấn đề vì gây ra nguy cơ sử dụng bump không chuẩn:

Mã
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>,
}

Trong tình huống này, chương trình dẫn xuất PDA UserProfile bằng create_program_address với các seed bao gồm bump do người dùng cung cấp. Việc sử dụng bump do người dùng cung cấp có vấn đề vì không đảm bảo sử dụng canonical bump. Điều này cho phép tác nhân độc hại tạo nhiều PDA với các bump khác nhau cho cùng một ID người dùng.

Biện pháp giảm thiểu được đề xuất

Để giảm thiểu vấn đề này, chúng ta có thể tái cấu trúc ví dụ nhằm dẫn xuất PDA bằng find_program_address và xác thực bump seed một cách rõ ràng:

Mã
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,
}

Ở đây, find_program_address được dùng để dẫn xuất PDA với canonical bump seed, đảm bảo việc tạo PDA có tính xác định và an toàn. Canonical bump được lưu trong tài khoản UserProfile, cho phép xác thực hiệu quả và an toàn trong các thao tác tiếp theo. Chúng ta ưu tiên find_program_address hơn create_program_address vì hàm sau tạo một PDA hợp lệ mà không tìm kiếm bump seed. Vì không tìm kiếm bump seed, hàm này có thể trả về lỗi theo cách không thể dự đoán đối với bất kỳ tập hợp seed nào và thường không phù hợp để tạo PDA. find_program_address sẽ luôn sử dụng canonical bump khi tạo PDA. Đó là vì hàm này lặp qua nhiều lệnh gọi create_program_address, bắt đầu với bump bằng 255 và giảm dần sau mỗi lần lặp. Khi tìm thấy một địa chỉ hợp lệ, hàm trả về PDA đã dẫn xuất và canonical bump được dùng để dẫn xuất địa chỉ đó.

Đóng tài khoản

Lỗ hổng

Việc đóng tài khoản không đúng cách trong một chương trình có thể dẫn đến nhiều lỗ hổng, bao gồm khả năng các tài khoản đã "đóng" bị khởi tạo lại hoặc sử dụng sai mục đích. Vấn đề phát sinh khi tài khoản không được đánh dấu đúng là đã đóng hoặc không có biện pháp ngăn tài khoản được tái sử dụng trong các giao dịch tiếp theo. Sơ suất này có thể cho phép tác nhân độc hại khai thác tài khoản, dẫn đến hành động hoặc quyền truy cập trái phép trong chương trình.

Tình huống ví dụ

Hãy xem xét một chương trình cho phép người dùng tạo và đóng các tài khoản lưu trữ dữ liệu. Chương trình đóng một tài khoản bằng cách chuyển toàn bộ lamport ra khỏi tài khoản đó:

Mã
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,
}

Cách này có vấn đề vì chương trình không xóa dữ liệu của tài khoản về 0 hoặc đánh dấu tài khoản là đã đóng. Chỉ chuyển số lamport còn lại ra ngoài không đồng nghĩa với việc đóng tài khoản.

Biện pháp giảm thiểu được khuyến nghị

Để giảm thiểu vấn đề này, chương trình không chỉ nên chuyển toàn bộ lamport ra ngoài mà còn phải xóa dữ liệu của tài khoản về 0 và đánh dấu tài khoản bằng một discriminator (tức là "CLOSED_ACCOUNT_DISCRIMINATOR"). Chương trình cũng nên triển khai các bước kiểm tra để ngăn tài khoản đã đóng được tái sử dụng trong các giao dịch sau này:

Mã
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,
}

Tuy nhiên, xóa dữ liệu về 0 và thêm discriminator đóng tài khoản vẫn chưa đủ. Người dùng có thể ngăn một tài khoản bị thu gom rác bằng cách hoàn lại lamport cho tài khoản trước khi một instruction kết thúc. Điều này sẽ khiến tài khoản rơi vào trạng thái lấp lửng, không thể sử dụng cũng không thể thu gom rác. Vì vậy, chúng tôi đã thêm hàm force_defund để xử lý trường hợp biên này; giờ đây, bất kỳ ai cũng có thể rút hết tiền khỏi tài khoản đã đóng.

Anchor đơn giản hóa quy trình này bằng constraint #[account(close = destination)], tự động đóng tài khoản một cách an toàn bằng cách chuyển lamport, xóa dữ liệu về 0 và thiết lập discriminator của tài khoản đã đóng, tất cả trong một thao tác.

Tài khoản mutable trùng lặp

Lỗ hổng

Tài khoản mutable trùng lặp là tình huống cùng một tài khoản được truyền nhiều lần dưới dạng tham số mutable cho một instruction. Điều này xảy ra khi một instruction yêu cầu hai tài khoản mutable cùng loại. Tác nhân độc hại có thể truyền cùng một tài khoản hai lần, khiến tài khoản bị thay đổi ngoài ý muốn, chẳng hạn như ghi đè dữ liệu. Mức độ nghiêm trọng của lỗ hổng này phụ thuộc vào từng tình huống cụ thể.

Tình huống ví dụ

Hãy xem xét một chương trình được thiết kế để thưởng cho người dùng dựa trên mức độ tham gia của họ vào một hoạt động on-chain nhất định. Chương trình có một instruction để cập nhật số dư của hai tài khoản: tài khoản phần thưởng và tài khoản tiền thưởng. Người dùng sẽ nhận phần thưởng tiêu chuẩn trong một tài khoản và có thể nhận thêm tiền thưởng trong tài khoản còn lại dựa trên các tiêu chí cụ thể được xác định trước:

Mã
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,
}

Nếu tác nhân độc hại truyền cùng một tài khoản cho reward_account và bonus_account, số dư của tài khoản sẽ bị cập nhật sai hai lần.

Biện pháp giảm thiểu được khuyến nghị

Để giảm thiểu vấn đề này, hãy thêm bước kiểm tra vào logic của instruction để xác minh rằng khóa công khai của hai tài khoản không giống nhau:

Mã
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(())
}

Nhà phát triển có thể dùng các constraint tài khoản của Anchor để thêm bước kiểm tra rõ ràng hơn cho tài khoản. Có thể thực hiện việc này bằng thuộc tính #[account] và từ khóa constraint:

Mã
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,
}

Chạy trước giao dịch

Lỗ hổng

Với sự phổ biến ngày càng tăng của các trình đóng gói giao dịch, chạy trước giao dịch là một mối lo ngại mà các giao thức xây dựng trên Solana cần xem xét nghiêm túc. Sau khi mempool của Jito bị loại bỏ, ở đây chúng tôi dùng thuật ngữ chạy trước giao dịch để chỉ khả năng tác nhân độc hại thao túng chênh lệch giữa giá trị dự kiến và giá trị thực tế thông qua các giao dịch được xây dựng cẩn thận. 

Tình huống ví dụ

Hãy hình dung một giao thức xử lý việc mua và đấu giá sản phẩm, đồng thời lưu thông tin định giá của người bán trong một tài khoản có tên SellInfo:

Mã
#[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,
}

Để mua một Product đã niêm yết, người mua phải truyền vào tài khoản ProductListing liên quan đến sản phẩm họ muốn mua. Nhưng điều gì xảy ra nếu người bán có thể thay đổi sale_price của mục niêm yết?

Mã
pub fn change_sale_price(ctx: Context<ChangeSalePrice>, new_price: u64) -> Result<()> {...}

Điều này sẽ tạo cơ hội chạy trước giao dịch cho người bán, đặc biệt nếu giao dịch mua của người mua không có các bước kiểm tra expected_price để bảo đảm họ không phải trả nhiều hơn mức dự kiến cho sản phẩm mong muốn. Nếu người mua gửi giao dịch để mua Product đó, người bán có thể gọi change_sale_price và sử dụng Jito để bảo đảm giao dịch này được đưa vào trước giao dịch của người mua. Người bán độc hại có thể âm thầm thay đổi giá trong tài khoản ProductListing thành một mức cắt cổ, buộc người mua phải trả nhiều hơn đáng kể so với dự kiến cho Product!

Biện pháp giảm thiểu được khuyến nghị

Một giải pháp đơn giản là thêm bước kiểm tra expected_price ở phía mua, ngăn người mua trả nhiều hơn mức dự kiến cho Product họ muốn mua:

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

Khởi tạo không an toàn

Không giống các hợp đồng được triển khai lên EVM, chương trình Solana không được triển khai cùng constructor để thiết lập các biến trạng thái. Thay vào đó, chúng được khởi tạo thủ công, thường bằng một hàm có tên initialize hoặc tên tương tự. Các hàm khởi tạo thường thiết lập dữ liệu như authority của chương trình hoặc tạo các tài khoản làm nền tảng cho chương trình được triển khai, chẳng hạn như tài khoản trạng thái trung tâm hoặc tài khoản tương tự.

Vì hàm khởi tạo được gọi thủ công chứ không tự động khi triển khai chương trình, instruction này phải được gọi bởi một địa chỉ đã biết và nằm dưới quyền kiểm soát của nhóm phát triển chương trình. Nếu không, kẻ tấn công có thể chạy trước quá trình khởi tạo và thiết lập chương trình bằng các tài khoản do chúng kiểm soát.

Một phương pháp phổ biến là sử dụng upgrade_authority của chương trình làm địa chỉ được phép gọi hàm initialize, nếu chương trình có upgrade authority.

Ví dụ không an toàn và cách giảm thiểu

Mã
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,
  ...
}

Ví dụ trên là một hàm initialize tối giản, thiết lập authority của tài khoản CentralState thành người gọi instruction. Tuy nhiên, bất kỳ tài khoản nào gọi initialize cũng có thể trở thành authority! Như đã đề cập, một cách phổ biến để bảo vệ hàm khởi tạo là sử dụng upgrade_authority của chương trình, vốn đã được biết tại thời điểm triển khai.

Dưới đây là một ví dụ từ tài liệu Anchor, sử dụng constraint để bảo đảm chỉ upgrade authority của chương trình mới có thể gọi initialize:

Mã
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>,
}

Mất độ chính xác

Lỗ hổng

Dù có vẻ rất nhỏ, việc mất độ chính xác vẫn có thể gây ra mối đe dọa đáng kể cho chương trình. Nó có thể dẫn đến tính toán sai, tạo cơ hội chênh lệch giá và khiến chương trình hoạt động ngoài dự kiến.

Mất độ chính xác trong các phép toán số học là một nguồn lỗi phổ biến. Với chương trình Solana, nên sử dụng số học dấu phẩy cố định bất cứ khi nào có thể. Lý do là chương trình chỉ hỗ trợ một tập hợp con hạn chế các phép toán dấu phẩy động của Rust. Nếu chương trình cố sử dụng một phép toán dấu phẩy động không được hỗ trợ, runtime sẽ trả về lỗi ký hiệu chưa được phân giải. Ngoài ra, các phép toán dấu phẩy động cần nhiều instruction hơn so với phép toán số nguyên tương đương. Việc sử dụng số học dấu phẩy cố định cùng yêu cầu xử lý chính xác lượng token lớn và các giá trị phân số có thể làm trầm trọng thêm tình trạng mất độ chính xác.

Nhân sau khi chia

Mặc dù tính chất kết hợp đúng với hầu hết các phép toán, việc áp dụng tính chất này trong số học máy tính có thể gây mất độ chính xác ngoài dự kiến. Một ví dụ kinh điển xảy ra khi thực hiện phép nhân sau phép chia, vốn có thể cho kết quả khác so với việc nhân trước rồi mới chia. Chẳng hạn, hãy xem xét các biểu thức sau: (a / c) * b và (a * b) / c. Về mặt toán học, các biểu thức này có tính kết hợp — chúng phải cho cùng một kết quả. Tuy nhiên, trong bối cảnh Solana và số học dấu phẩy cố định, thứ tự thực hiện phép toán có ý nghĩa rất lớn. Thực hiện phép chia trước (a / c) có thể làm mất độ chính xác nếu thương bị làm tròn xuống trước khi nhân với b. Điều này có thể tạo ra kết quả nhỏ hơn dự kiến. Ngược lại, nhân (a * b) trước khi chia cho c có thể giữ lại nhiều độ chính xác ban đầu hơn. Sự khác biệt này có thể dẫn đến tính toán sai, khiến chương trình hoạt động ngoài dự kiến và/hoặc tạo cơ hội chênh lệch giá.

Các hàm số học saturating_*

Mặc dù các hàm số học saturating_* ngăn tràn số và hụt số bằng cách giới hạn giá trị ở mức tối đa hoặc tối thiểu có thể, chúng vẫn có thể gây ra các lỗi khó nhận biết và làm mất độ chính xác nếu bất ngờ chạm ngưỡng này. Điều đó xảy ra khi logic của chương trình giả định rằng riêng cơ chế bão hòa sẽ bảo đảm kết quả chính xác và bỏ qua việc xử lý khả năng mất độ chính xác.

Ví dụ, hãy hình dung một chương trình được thiết kế để tính toán và phân phối phần thưởng cho người dùng dựa trên lượng token họ giao dịch trong một khoảng thời gian cụ thể:

Mã
pub fn calculate_reward(transaction_amount: u64, reward_multiplier: u64) -> u64 {
    transaction_amount.saturating_mul(reward_multiplier)
}

Hãy xem xét tình huống trong đó transaction_amount là 100.000 token và reward_multiplier là 100 token cho mỗi giao dịch. Tích của hai giá trị sẽ vượt quá giá trị tối đa mà u64 có thể lưu trữ. Điều này có nghĩa là tích sẽ bị giới hạn, gây mất độ chính xác đáng kể và khiến người dùng nhận ít phần thưởng hơn mức đáng ra được nhận.

Lỗi làm tròn

Các phép làm tròn là một nguyên nhân phổ biến gây mất độ chính xác trong lập trình. Việc lựa chọn phương pháp làm tròn có thể ảnh hưởng đáng kể đến độ chính xác của phép tính và hành vi của chương trình Solana. Hàm try_round_u64() làm tròn giá trị thập phân thành số nguyên gần nhất. Làm tròn lên có thể gây vấn đề vì nó làm tăng giá trị một cách giả tạo, dẫn đến chênh lệch giữa kết quả tính toán thực tế và dự kiến.

Hãy xem xét một chương trình Solana chuyển đổi tài sản thế chấp thành thanh khoản dựa trên điều kiện thị trường. Chương trình sử dụng try_round_u64() để làm tròn kết quả của phép chia:

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

Trong tình huống này, việc làm tròn lên có thể khiến chương trình phát hành nhiều token thanh khoản hơn mức được bảo đảm bởi lượng tài sản thế chấp. Tác nhân độc hại có thể khai thác chênh lệch này để thực hiện các cuộc tấn công chênh lệch giá và rút giá trị khỏi giao thức thông qua kết quả làm tròn có lợi cho chúng. Để giảm thiểu, hãy dùng try_floor_u64 để làm tròn xuống số nguyên gần nhất. Cách tiếp cận này giảm thiểu nguy cơ làm tăng giá trị một cách giả tạo và bảo đảm thao tác làm tròn không mang lại lợi thế cho người dùng gây thiệt hại cho hệ thống. Một phương án khác là triển khai logic để xử lý các tình huống mà việc làm tròn có thể tác động trực tiếp đến kết quả. Có thể đặt các ngưỡng cụ thể cho quyết định làm tròn hoặc áp dụng logic khác nhau tùy theo độ lớn của các giá trị liên quan.

Thiếu bước kiểm tra quyền sở hữu

Lỗ hổng

Kiểm tra quyền sở hữu là bước thiết yếu để xác thực rằng chương trình dự kiến sở hữu một tài khoản tham gia vào giao dịch hoặc thao tác. Tài khoản có trường owner, cho biết chương trình có quyền ghi vào dữ liệu của tài khoản. Trường này bảo đảm chỉ các chương trình được ủy quyền mới có thể sửa đổi trạng thái tài khoản. Ngoài ra, trường này còn hữu ích để bảo đảm rằng các tài khoản được truyền vào instruction thuộc sở hữu của chương trình dự kiến. Thiếu bước kiểm tra quyền sở hữu có thể dẫn đến các lỗ hổng nghiêm trọng, bao gồm chuyển tiền trái phép và thực thi các thao tác đặc quyền.

Tình huống ví dụ

Hãy xem xét một hàm chương trình được định nghĩa để chỉ cho phép quản trị viên rút tiền khỏi vault. Hàm nhận vào một tài khoản cấu hình, tức là config, rồi dùng trường admin của tài khoản này để kiểm tra xem khóa công khai của tài khoản quản trị viên được cung cấp có giống khóa được lưu trong tài khoản config hay không. Tuy nhiên, hàm không xác minh quyền sở hữu của tài khoản config mà mặc nhiên coi tài khoản này là đáng tin cậy:

Mã
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
}

Tác nhân độc hại có thể khai thác điều này bằng cách cung cấp một tài khoản config do chúng kiểm soát với trường admin khớp, qua đó đánh lừa chương trình thực hiện lệnh rút tiền.

Biện pháp giảm thiểu được khuyến nghị

Để giảm thiểu vấn đề này, hãy thực hiện bước kiểm tra quyền sở hữu nhằm xác minh trường owner của tài khoản:

Mã
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 đơn giản hóa bước kiểm tra này bằng kiểu Account. Account<'info, T> là một wrapper quanh AccountInfo, có chức năng xác minh quyền sở hữu của chương trình và deserialize dữ liệu bên dưới thành T, tức là kiểu tài khoản được chỉ định. Nhờ đó, nhà phát triển có thể dùng Account<'info, T> để dễ dàng xác thực quyền sở hữu tài khoản. Nhà phát triển cũng có thể dùng thuộc tính #[account] để thêm trait Owner vào một tài khoản nhất định. Trait này định nghĩa địa chỉ được kỳ vọng là chủ sở hữu của tài khoản. Ngoài ra, nhà phát triển có thể dùng constraint owner để xác định chương trình nào nên sở hữu một tài khoản nếu đó không phải chương trình đang thực thi. Điều này hữu ích, chẳng hạn khi viết một instruction yêu cầu tài khoản phải là PDA được dẫn xuất từ một chương trình khác. Constraint owner được định nghĩa là #[account(owner = <expr>)], trong đó <expr> là một biểu thức bất kỳ.

Tài khoản chỉ đọc

Việc xác minh tính hợp lệ của các tài khoản được chỉ định là chỉ đọc trong ngữ cảnh thực thi của chương trình cũng quan trọng không kém. Đây là bước thiết yếu vì tác nhân độc hại có thể truyền các tài khoản chứa dữ liệu tùy ý hoặc được tạo có chủ đích thay cho tài khoản hợp lệ. Điều này có thể khiến chương trình hoạt động ngoài dự kiến hoặc gây hại. Nhà phát triển vẫn phải thực hiện các bước kiểm tra để bảo đảm những tài khoản mà chương trình cần đọc là chính hãng và không bị can thiệp. Việc này có thể bao gồm đối chiếu địa chỉ tài khoản với các giá trị đã biết hoặc xác nhận owner của tài khoản đúng như dự kiến, đặc biệt đối với sysvar, tức các tài khoản hệ thống chỉ đọc như Clock hoặc EpochSchedule. Hãy truy cập sysvar bằng phương thức get(), phương thức này không yêu cầu kiểm tra địa chỉ hoặc quyền sở hữu theo cách thủ công. Đây là cách an toàn hơn để truy cập các tài khoản này; tuy nhiên, không phải mọi sysvar đều hỗ trợ phương thức get(). Trong trường hợp đó, hãy truy cập chúng bằng địa chỉ công khai.

Thiếu bước kiểm tra người ký

Lỗ hổng

Giao dịch được ký bằng khóa riêng tư của ví để bảo đảm tính xác thực, tính toàn vẹn, tính chống chối bỏ và việc một giao dịch cụ thể đã được một ví cụ thể ủy quyền. Bằng cách yêu cầu giao dịch được ký bằng khóa riêng tư của người gửi, runtime của Solana có thể xác minh rằng đúng tài khoản đã khởi tạo giao dịch và giao dịch không bị can thiệp. Cơ chế này là nền tảng cho tính không cần đặt niềm tin của các mạng phi tập trung. Nếu không có bước xác minh này, bất kỳ tài khoản nào cung cấp đúng tài khoản dưới dạng đối số đều có thể thực thi giao dịch. Điều này có thể dẫn đến quyền truy cập trái phép vào thông tin, tiền hoặc chức năng đặc quyền. Lỗ hổng này phát sinh khi chương trình không xác thực rằng một thao tác đã được ký bằng khóa riêng tư của tài khoản phù hợp trước khi thực thi chức năng đặc quyền.

Tình huống ví dụ

Hãy xem xét hàm sau:

Mã
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(())
}

Hàm này dùng để cập nhật quản trị viên của chương trình. Hàm có bước kiểm tra nhằm bảo đảm quản trị viên hiện tại khởi tạo thao tác, đây là cơ chế kiểm soát truy cập phù hợp. Tuy nhiên, hàm không xác minh rằng khóa riêng tư của quản trị viên hiện tại đã ký giao dịch. Do đó, bất kỳ ai gọi hàm này cũng có thể truyền đúng tài khoản admin sao cho admin.pubkey() = config.admin, bất kể tài khoản gọi hàm có thực sự là quản trị viên hiện tại hay không. Điều này cho phép tác nhân độc hại thực thi instruction với tài khoản của chúng được truyền vào làm quản trị viên mới, trực tiếp bỏ qua yêu cầu ủy quyền từ quản trị viên hiện tại.

Biện pháp giảm thiểu được khuyến nghị

Chương trình phải có các bước kiểm tra để xác minh rằng tài khoản đã được ví phù hợp ký. Có thể thực hiện việc này bằng cách kiểm tra trường AccountInfo::is_signer của các tài khoản tham gia giao dịch. Chương trình có thể bảo đảm chỉ các tài khoản được ủy quyền mới được thực hiện một số hành động nhất định bằng cách kiểm tra xem tài khoản thực thi thao tác đặc quyền có cờ is_signer được đặt thành true hay không.

Ví dụ mã sau khi cập nhật sẽ như sau:

Mã
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 đơn giản hóa toàn bộ quy trình này bằng kiểu tài khoản Signer<’info>.

Tràn số và hụt số

Lỗ hổng

Số nguyên là số không có phần thập phân. Rust lưu trữ số nguyên dưới dạng các biến có kích thước cố định. Các biến này được xác định bởi tính có dấu (tức là có dấu hoặc không dấu) và dung lượng mà chúng chiếm trong bộ nhớ. Ví dụ, kiểu u8 biểu thị một số nguyên không dấu chiếm 8 bit. Kiểu này có thể lưu các giá trị từ 0 đến 255. Việc lưu một giá trị nằm ngoài phạm vi đó sẽ gây tràn số hoặc hụt số nguyên. Tràn số nguyên xảy ra khi một biến vượt quá giới hạn tối đa và quay vòng về giá trị tối thiểu. Hụt số nguyên xảy ra khi một biến giảm xuống dưới giới hạn tối thiểu và quay vòng về giá trị tối đa.

Rust có các phép kiểm tra tràn số và hụt số nguyên khi biên dịch ở chế độ debug. Các phép kiểm tra này sẽ khiến chương trình panic trong thời gian chạy nếu phát hiện điều kiện như vậy. Tuy nhiên, Rust không có các phép kiểm tra gây panic đối với tình trạng tràn số và hụt số nguyên khi biên dịch ở chế độ release bằng cờ --release. Hành vi này có thể tạo ra các lỗ hổng khó phát hiện vì tình trạng tràn số hoặc hụt số diễn ra âm thầm. Chuỗi công cụ Berkley Packet Filter (BPF) là một phần không thể thiếu trong môi trường phát triển Solana vì nó biên dịch các chương trình Solana. Lệnh cargo build-bpf biên dịch các dự án Rust thành bytecode BPF để triển khai. Vấn đề là lệnh này mặc định biên dịch chương trình ở chế độ release. Do đó, các chương trình Solana dễ gặp lỗ hổng tràn số và hụt số nguyên.

Tình huống ví dụ

Kẻ tấn công có thể khai thác lỗ hổng này bằng cách lợi dụng hành vi tràn số/hụt số âm thầm trong chế độ release, đặc biệt là ở các hàm xử lý số dư token. Hãy xem ví dụ sau:

Mã
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(())
}

Để đơn giản, hàm này giả định số dư được lưu trong byte đầu tiên. Hàm lấy số dư của tài khoản rồi trừ đi tokens_to_subtract. Nếu số dư của người dùng nhỏ hơn tokens_to_subtract, phép tính sẽ gây hụt số. Ví dụ, số dư của người dùng có 10 token sẽ bị hụt số và trở thành tổng số dư 165 token

Biện pháp giảm thiểu được đề xuất

overflow-checks

Cách dễ nhất để giảm thiểu lỗ hổng này là đặt khóa overflow-checks thành true trong tệp Cargo.toml của dự án. Khi đó, Rust sẽ bổ sung các phép kiểm tra tràn số và hụt số vào trình biên dịch. Tuy nhiên, việc bổ sung các phép kiểm tra này làm tăng chi phí tính toán của một giao dịch. Trong những trường hợp cần tối ưu tài nguyên tính toán, việc đặt overflow-checks thành false có thể phù hợp hơn.

Phép toán checked_*

Sử dụng các hàm số học checked_* của Rust trên từng kiểu số nguyên để kiểm tra tràn số và hụt số một cách có chủ đích trong toàn bộ chương trình. Các hàm này sẽ trả về None nếu xảy ra tràn số hoặc hụt số. Nhờ đó, chương trình có thể xử lý lỗi một cách phù hợp. Ví dụ, bạn có thể tái cấu trúc đoạn mã trước đó thành:

Mã
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(())
}

Trong ví dụ đã sửa đổi, checked_sub được dùng để trừ tokens_to_subtract khỏi balance. Vì vậy, nếu balance đủ để thực hiện phép trừ, checked_sub sẽ trả về Some(new_balance). Chương trình tiếp tục cập nhật số dư tài khoản một cách an toàn và ghi lại số dư đó. Tuy nhiên, nếu phép trừ gây hụt số, checked_sub sẽ trả về None, và ta có thể xử lý bằng cách trả về lỗi.

Macro Checked Math

Checked Math là một macro thủ tục dùng để thay đổi thuộc tính kiểm tra các biểu thức toán học mà phần lớn không cần sửa đổi chính các biểu thức đó. Vấn đề với các hàm số học checked_* là chúng làm mất ký hiệu toán học. Thay vì a + b, phải dùng các phương thức rườm rà như a.checked_add(b).unwrap(). Ví dụ, nếu muốn viết (x * y) + z bằng các hàm số học có kiểm tra, ta sẽ viết x.checked_mul(y).unwrap().checked_add(z).unwrap().

Thay vào đó, khi dùng macro Checked Math, biểu thức sẽ có dạng sau:

Mã
use checked_math::checked_math as cm;

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

Cách này thuận tiện hơn khi viết, giữ nguyên ký hiệu toán học của biểu thức và chỉ cần một .unwrap(). Lý do là macro chuyển các biểu thức toán học thông thường thành một biểu thức trả về None nếu bất kỳ bước kiểm tra nào trả về None. Nếu thành công, Some(_) sẽ được trả về, vì vậy ta unwrap biểu thức ở cuối.

Ép kiểu

Tương tự, việc ép kiểu giữa các kiểu số nguyên bằng từ khóa as mà không kiểm tra đúng cách có thể tạo ra lỗ hổng tràn số hoặc hụt số nguyên. Nguyên nhân là quá trình ép kiểu có thể cắt bớt hoặc mở rộng giá trị ngoài ý muốn. Khi ép từ một kiểu số nguyên lớn hơn sang kiểu nhỏ hơn (ví dụ: u64 sang u32), Rust sẽ cắt bỏ các bit cao của giá trị gốc không vừa với kiểu đích. Điều này gây ra vấn đề khi giá trị gốc vượt quá giá trị tối đa mà kiểu đích có thể lưu trữ. Khi ép từ một kiểu số nguyên nhỏ hơn sang kiểu lớn hơn (ví dụ: i16 sang i32), Rust sẽ mở rộng giá trị. Quá trình này đơn giản đối với các kiểu không dấu. Tuy nhiên, với số nguyên có dấu, nó có thể dẫn đến mở rộng dấu, tạo ra các giá trị âm ngoài ý muốn.

Biện pháp giảm thiểu được đề xuất

Sử dụng các phương thức ép kiểu an toàn của Rust để giảm thiểu lỗ hổng này. Các phương thức này bao gồm try_from và from. try_from trả về kiểu Result, cho phép xử lý rõ ràng và phù hợp các trường hợp giá trị không vừa với kiểu đích. Phương thức from của Rust có thể được dùng để chuyển đổi ngầm định an toàn đối với những phép chuyển đổi được đảm bảo không mất dữ liệu (ví dụ: u8 sang u32). Ví dụ, giả sử một chương trình cần chuyển đổi an toàn lượng token kiểu u64 sang kiểu u32 để xử lý. Khi đó, chương trình có thể thực hiện như sau:

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

Trong ví dụ này, nếu amount vượt quá giá trị tối đa mà u32 có thể lưu trữ (tức là 4 294 967 295), phép chuyển đổi sẽ thất bại và chương trình trả về lỗi. Điều này ngăn chặn nguy cơ tràn số/hụt số.

Dùng chung PDA

Lỗ hổng

Dùng chung PDA là một lỗ hổng phổ biến phát sinh khi cùng một PDA được sử dụng trên nhiều miền thẩm quyền hoặc vai trò. Điều này có thể cho phép kẻ xấu truy cập dữ liệu hoặc tiền không thuộc về họ bằng cách lạm dụng PDA làm bên ký mà không có các phép kiểm tra quyền truy cập phù hợp.

Tình huống ví dụ

Hãy xem xét một chương trình được thiết kế để hỗ trợ staking token và phân phối phần thưởng. Chương trình sử dụng một PDA duy nhất để chuyển token vào một pool nhất định và rút phần thưởng. PDA được dẫn xuất bằng một seed tĩnh (ví dụ: tên của pool staking), nên được dùng chung trong mọi thao tác:

Mã
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
}

Điều này gây ra vấn đề vì chức năng staking và rút phần thưởng đều dựa vào cùng một PDA được dẫn xuất từ staking_pool_pda. Người dùng có thể lợi dụng điều này để thao túng hợp đồng nhằm rút phần thưởng trái phép hoặc can thiệp vào hoạt động staking.

Biện pháp giảm thiểu được đề xuất

Để giảm thiểu lỗ hổng này, hãy sử dụng các PDA riêng biệt cho từng chức năng. Đảm bảo mỗi PDA phục vụ một ngữ cảnh cụ thể và được dẫn xuất bằng các seed duy nhất, dành riêng cho từng thao tác:

Mã
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
}

Trong ví dụ trên, các PDA dùng để staking token và rút phần thưởng được dẫn xuất bằng các seed riêng biệt (lần lượt là staking_pool và rewards_pool) kết hợp với khóa của tài khoản cụ thể. Điều này đảm bảo các PDA được liên kết duy nhất với chức năng dự kiến, qua đó giảm nguy cơ xảy ra hành động trái phép.

Các tài khoản còn lại

Lỗ hổng

ctx.remaining_accounts cung cấp cách truyền thêm tài khoản vào một hàm khi các tài khoản đó chưa được chỉ định ban đầu trong struct Accounts. Điều này mang lại cho nhà phát triển sự linh hoạt cao hơn, cho phép họ xử lý các tình huống cần số lượng tài khoản động (tức là xử lý số lượng người dùng thay đổi hoặc tương tác với nhiều chương trình khác nhau). Tuy nhiên, sự linh hoạt này đi kèm một lưu ý: các tài khoản được truyền qua ctx.remaining_accounts không trải qua cùng quy trình xác thực áp dụng cho các tài khoản được định nghĩa trong struct Accounts. Vì ctx.remaining_accounts không xác thực các tài khoản được truyền vào, kẻ xấu có thể khai thác điều này bằng cách truyền vào những tài khoản mà chương trình không dự định tương tác, dẫn đến hành động hoặc quyền truy cập trái phép.

Tình huống ví dụ

Hãy xem xét một chương trình phần thưởng sử dụng ctx.remaining_accounts để nhận PDA của người dùng và tính toán phần thưởng một cách linh hoạt:

Mã
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
}

Vấn đề ở đây là không có phép kiểm tra rõ ràng nào để xác thực các tài khoản được truyền vào qua ctx.remaining_accounts. Do đó, chương trình không thể đảm bảo rằng chỉ tài khoản của những người dùng hợp lệ và đủ điều kiện mới được đưa vào quá trình tính toán và phân phối phần thưởng. Vì vậy, kẻ xấu có thể truyền vào các tài khoản mà họ không sở hữu hoặc những tài khoản do chính họ tạo để nhận nhiều phần thưởng hơn mức thực tế được hưởng.

Biện pháp giảm thiểu được đề xuất

Để giảm thiểu lỗ hổng này, nhà phát triển nên xác minh thủ công tính hợp lệ của từng tài khoản trong hàm. Quy trình này bao gồm kiểm tra chủ sở hữu tài khoản để đảm bảo khớp với người dùng dự kiến và xác thực mọi dữ liệu liên quan trong tài khoản. Bằng cách bổ sung các phép kiểm tra thủ công này, nhà phát triển có thể tận dụng tính linh hoạt của ctx.remaining_acocunts đồng thời giảm nguy cơ truy cập hoặc thao túng trái phép.

Các lỗi đặc thù của Rust

Rust là ngôn ngữ chung trong hoạt động phát triển chương trình trên Solana. Phát triển bằng Rust đặt ra một tập hợp thách thức và vấn đề cần cân nhắc riêng, đặc biệt liên quan đến mã không an toàn và các lỗi đặc thù của Rust. Hiểu rõ những điểm cần lưu ý của Rust giúp xây dựng các chương trình an toàn, hiệu quả và đáng tin cậy.

Rust không an toàn

Rust nổi tiếng nhờ khả năng đảm bảo an toàn bộ nhớ thông qua một hệ thống sở hữu và mượn nghiêm ngặt. Tuy nhiên, những cơ chế đảm bảo này đôi khi có thể gây cản trở, vì vậy Rust cung cấp từ khóa unsafe để bỏ qua các phép kiểm tra an toàn. Rust unsafe được dùng trong bốn ngữ cảnh chính:

  • Hàm không an toàn: các hàm thực hiện thao tác có thể vi phạm cơ chế đảm bảo an toàn của Rust phải được đánh dấu bằng từ khóa unsafe. Ví dụ: unsafe fn dangerous_function() {}
  • Khối không an toàn: các khối mã cho phép thực hiện thao tác không an toàn. Ví dụ: unsafe { // Unsafe operations }
  • Trait không an toàn: các trait ngụ ý những bất biến nhất định mà trình biên dịch không thể xác minh. Ví dụ: unsafe trait BadTrait {}
  • Triển khai trait không an toàn: phần triển khai các trait unsafe cũng phải được đánh dấu là unsafe. Ví dụ: unsafe impl UnsafeTrait for UnsafeType {}

Rust không an toàn tồn tại vì phân tích tĩnh có tính thận trọng. Khi trình biên dịch cố gắng xác định liệu mã có duy trì một tập hợp cơ chế đảm bảo nhất định hay không, việc từ chối một vài trường hợp mã hợp lệ vẫn tốt hơn chấp nhận một vài trường hợp mã không hợp lệ. Dù mã có thể chạy hoàn toàn bình thường, trình biên dịch Rust vẫn sẽ từ chối nếu không có đủ thông tin để chắc chắn rằng mã duy trì các cơ chế đảm bảo an toàn của Rust. Mã không an toàn cho phép nhà phát triển tự chịu rủi ro khi bỏ qua những phép kiểm tra này. Hơn nữa, bản chất phần cứng máy tính vốn không an toàn. Nhà phát triển phải được phép thực hiện các thao tác không an toàn để lập trình cấp thấp bằng Rust.

Với từ khóa unsafe, nhà phát triển có thể:

  • Giải tham chiếu con trỏ thô: cho phép truy cập trực tiếp vào bộ nhớ thông qua các con trỏ thô có thể trỏ đến bất kỳ vị trí bộ nhớ nào, kể cả nơi có thể không chứa dữ liệu hợp lệ
  • Gọi hàm không an toàn: các hàm này có thể không tuân thủ các cơ chế đảm bảo an toàn của Rust và có khả năng dẫn đến hành vi không xác định
  • Truy cập biến tĩnh có thể thay đổi: trạng thái toàn cục có thể thay đổi có thể gây ra tranh chấp dữ liệu

Cách tốt nhất để giảm thiểu rủi ro từ Rust không an toàn là hạn chế tối đa việc sử dụng các khối unsafe. Nếu mã unsafe thực sự cần thiết vì bất kỳ lý do nào, hãy đảm bảo mã đó được lập tài liệu đầy đủ, kiểm tra thường xuyên và, nếu có thể, được đóng gói trong một lớp trừu tượng an toàn để cung cấp cho phần còn lại của chương trình.

Panic và quản lý lỗi

Panic xảy ra khi một chương trình Rust gặp lỗi không thể khôi phục và chấm dứt quá trình thực thi. Panic được dùng cho những lỗi không mong muốn và không được thiết kế để bắt giữ. Trong bối cảnh các chương trình Solana, panic có thể dẫn đến hành vi ngoài dự kiến vì runtime yêu cầu chương trình xử lý lỗi phù hợp mà không bị sự cố.

Khi panic xảy ra, Rust bắt đầu tháo ngăn xếp và dọn dẹp trong quá trình đó. Thao tác này trả về dấu vết ngăn xếp, bao gồm thông tin chi tiết về lỗi liên quan. Điều này có thể cung cấp cho kẻ tấn công thông tin về cấu trúc tệp bên dưới. Mặc dù vấn đề này không áp dụng trực tiếp cho các chương trình Solana, những phần phụ thuộc mà chương trình sử dụng có thể dễ bị tấn công theo cách đó. Hãy đảm bảo các phần phụ thuộc luôn được cập nhật và sử dụng những phiên bản không chứa lỗ hổng đã biết.

Các tình huống panic phổ biến bao gồm:

  • Chia cho 0: Rust sẽ panic khi cố gắng chia cho 0. Vì vậy, luôn kiểm tra số chia có bằng 0 hay không trước khi thực hiện phép chia
  • Chỉ mục mảng nằm ngoài giới hạn: truy cập mảng bằng một chỉ mục vượt quá giới hạn sẽ gây panic. Để giảm thiểu, hãy dùng các phương thức trả về kiểu Option (như get) nhằm truy cập phần tử mảng một cách an toàn
  • Unwrap giá trị None: gọi .unwrap() trên một Option chứa giá trị None sẽ gây panic. Luôn sử dụng so khớp mẫu hoặc các phương thức như unwrap_or, unwrap_or_else, hay toán tử ? trong những hàm trả về Result

Để giảm thiểu các vấn đề liên quan đến panic, cần tránh những thao tác gây panic, xác thực mọi đầu vào và điều kiện có thể dẫn đến thao tác có vấn đề, đồng thời sử dụng các kiểu Result và Option để xử lý lỗi. Ngoài ra, việc viết các bài kiểm thử chương trình toàn diện sẽ giúp phát hiện và xử lý những tình huống panic tiềm ẩn trước khi triển khai.

Xung đột seed

Lỗ hổng

Xung đột seed xảy ra khi các đầu vào khác nhau (tức là seed và ID chương trình) dùng để tạo PDA lại cho ra cùng một địa chỉ PDA. Điều này gây ra vấn đề khi các PDA được sử dụng trong cùng một chương trình cho nhiều mục đích khác nhau, vì nó có thể dẫn đến hành vi ngoài dự kiến, bao gồm tấn công từ chối dịch vụ hoặc xâm phạm hoàn toàn.

Tình huống ví dụ

Hãy xem xét một chương trình dành cho nền tảng bỏ phiếu phi tập trung đối với nhiều đề xuất và sáng kiến. Mỗi phiên bỏ phiếu cho một đề xuất hoặc sáng kiến nhất định được tạo bằng một mã định danh duy nhất, sau đó người dùng gửi phiếu bầu. Chương trình sử dụng PDA cho cả phiên bỏ phiếu và từng phiếu bầu:

Mã
// 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>,
}

Trong tình huống này, kẻ tấn công sẽ cố gắng xây dựng cẩn thận một phiên bỏ phiếu sao cho khi kết hợp với seed tĩnh "session", nó tạo ra một PDA vô tình trùng với PDA được tạo cho một phiên bỏ phiếu khác. Việc cố ý tạo một PDA xung đột với PDA của phiên bỏ phiếu khác có thể làm gián đoạn hoạt động của nền tảng, chẳng hạn như ngăn chặn các phiếu bầu hợp lệ cho đề xuất hoặc ngăn thêm sáng kiến mới vào nền tảng, vì runtime của Solana không thể phân biệt các PDA bị xung đột.

Biện pháp giảm thiểu được đề xuất

Để giảm nguy cơ xung đột seed, nhà phát triển có thể:

  • Sử dụng tiền tố seed duy nhất cho các PDA khác nhau trong cùng một chương trình. Cách này giúp đảm bảo các PDA luôn khác biệt
  • Sử dụng mã định danh duy nhất (ví dụ: dấu thời gian, ID người dùng, giá trị nonce) để đảm bảo luôn tạo ra một PDA duy nhất
  • Xác thực bằng chương trình rằng PDA được tạo không xung đột với các PDA hiện có

Giả mạo kiểu

Lỗ hổng

Giả mạo kiểu là lỗ hổng trong đó một kiểu tài khoản bị biểu diễn sai thành kiểu khác do thiếu kiểm tra kiểu trong quá trình giải tuần tự hóa. Điều này có thể dẫn đến việc thực thi hành động trái phép hoặc làm hỏng dữ liệu, vì chương trình sẽ hoạt động dựa trên giả định sai về vai trò hoặc quyền của tài khoản. Luôn kiểm tra rõ ràng kiểu dự kiến của tài khoản trong quá trình giải tuần tự hóa.

Tình huống ví dụ

Hãy xem xét một chương trình quản lý quyền truy cập vào các thao tác quản trị dựa trên vai trò của người dùng. Mỗi tài khoản người dùng có một discriminator vai trò để phân biệt người dùng thông thường với quản trị viên. Chương trình chứa một hàm cập nhật cài đặt quản trị chỉ dành cho quản trị viên. Tuy nhiên, chương trình không kiểm tra discriminator của tài khoản và giải tuần tự hóa dữ liệu tài khoản người dùng mà không xác nhận tài khoản đó có thuộc về quản trị viên hay không:

Mã
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,
}

Vấn đề là update_admin_settings giải tuần tự hóa tài khoản người dùng được truyền vào mà không kiểm tra discriminator vai trò của tài khoản, một phần vì struct User không có trường discriminator!

Biện pháp giảm thiểu được đề xuất

Để giảm thiểu vấn đề này, nhà phát triển có thể thêm một trường discriminator vào struct User và xác minh trường đó trong quá trình giải tuần tự hóa:

Mã
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 đơn giản hóa việc giảm thiểu lỗ hổng giả mạo kiểu bằng cách tự động quản lý discriminator cho các kiểu tài khoản. Việc này được thực hiện thông qua wrapper Account<'info, T>, trong đó Anchor đảm bảo an toàn kiểu bằng cách tự động kiểm tra discriminator trong quá trình giải tuần tự hóa. Nhờ đó, nhà phát triển có thể tập trung nhiều hơn vào logic nghiệp vụ của chương trình thay vì phải triển khai thủ công nhiều phép kiểm tra kiểu.

Kết luận

Không thể xem nhẹ tầm quan trọng của bảo mật chương trình. Bài viết này đã trình bày nhiều lỗ hổng phổ biến, từ các lỗi đặc thù của Rust đến những điểm phức tạp trong phương thức realloc của Anchor. Hành trình làm chủ từng lỗ hổng này cũng như bảo mật chương trình nói chung vẫn luôn tiếp diễn, đòi hỏi quá trình học hỏi, thích nghi và cộng tác không ngừng. Là nhà phát triển, cam kết của chúng ta đối với bảo mật không chỉ là bảo vệ tài sản mà còn là xây dựng niềm tin, đảm bảo tính toàn vẹn của ứng dụng và đóng góp vào sự phát triển cũng như ổn định của Solana.

Nếu bạn đã đọc đến đây, cảm ơn nhé, anon! Hãy nhập địa chỉ email của bạn bên dưới để không bỏ lỡ bất kỳ thông tin cập nhật nào về những điều mới trên Solana. Sẵn sàng tìm hiểu sâu hơn? Khám phá các bài viết mới nhất trên blog Helius và tiếp tục hành trình Solana của bạn ngay hôm nay.

Tài nguyên bổ sung

Đăng ký nhận tin từ Helius

Luôn cập nhật những thông tin mới nhất về phát triển Solana và nhận thông báo khi chúng tôi đăng bài

Hình ảnh phóng to