신규: 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에서 계정은 컴퓨터의 파일처럼 데이터를 담는 컨테이너 역할을 합니다. 계정은 크게 실행 가능 계정과 실행 불가능 계정으로 나눌 수 있습니다. 실행 가능 계정, 즉 프로그램은 코드를 실행할 수 있는 계정입니다. 실행 불가능 계정은 코드를 저장하지 않으므로 코드를 실행할 수 없으며 데이터 저장에 사용됩니다. 코드와 데이터가 분리되어 있으므로 프로그램에는 상태가 없습니다. 프로그램은 트랜잭션 중에 참조로 전달된 다른 계정의 데이터와 상호작용합니다.

Solana는 공격자가 통제할 수 있습니다

트랜잭션은 호출할 프로그램, 계정 목록, 명령어 데이터의 바이트 배열을 지정합니다. 이 모델에서는 프로그램이 주어진 트랜잭션에서 제공하는 계정과 명령어를 파싱하고 해석해야 합니다. 어떤 계정이든 프로그램의 함수에 전달할 수 있으므로 공격자는 프로그램이 처리할 데이터를 상당 부분 통제할 수 있습니다. 본질적으로 공격자가 통제할 수 있는 Solana의 프로그래밍 모델을 이해하는 것은 안전한 프로그램 개발에 매우 중요합니다.

공격자가 어떤 계정이든 프로그램의 함수에 전달할 수 있으므로 데이터 검증은 Solana 프로그램 보안의 핵심 기반입니다. 개발자는 프로그램이 정상 입력과 악성 입력을 구분할 수 있도록 해야 합니다. 여기에는 계정 소유권 확인, 계정이 예상한 유형인지 확인, 계정이 서명자인지 확인하는 작업이 포함됩니다.

잠재적인 공격 벡터

Solana의 고유한 프로그래밍 모델과 실행 환경에서는 특정 공격 벡터가 발생합니다. 개발자가 잠재적인 악용으로부터 프로그램을 보호하려면 이러한 벡터를 이해해야 합니다. 공격 벡터는 다음과 같습니다.

  • 로직 버그: 프로그램 로직의 결함을 조작해 자산 손실이나 무단 액세스 같은 의도하지 않은 동작을 일으킬 수 있습니다. 프로젝트 사양을 올바르게 구현하지 못하는 경우도 포함됩니다. 프로그램이 x를 수행한다고 명시했다면 x와 그에 수반되는 모든 특수 동작을 수행해야 합니다
  • 데이터 검증 결함: 입력 데이터를 충분히 검증하지 않으면 공격자가 악성 데이터를 전달해 프로그램 상태나 실행을 조작할 수 있습니다
  • Rust 관련 문제: Rust의 안전 기능에도 불구하고 안전하지 않은 코드 블록, 동시성 문제, 패닉으로 인해 취약점이 생길 수 있습니다
  • 액세스 제어 취약점: 계정 소유자 확인과 같은 액세스 제어 검사를 올바르게 구현하지 않으면 악의적인 행위자가 무단 작업을 수행할 수 있습니다
  • 산술 및 정밀도 오류: 오버플로/언더플로와 정밀도 오류는 금전적 이익을 위해 악용되거나 프로그램 오작동을 유발할 수 있습니다
  • 프로그램 간 호출(CPI) 문제: CPI 처리의 결함으로 인해 호출된 프로그램이 악의적으로 또는 예상과 다르게 동작할 때 예기치 않은 상태 변경이나 오류가 발생할 수 있습니다
  • 프로그램 파생 주소(PDA) 오용: PDA를 잘못 생성하거나 처리하면 공격자가 PDA를 탈취하거나 위조해 무단 액세스 권한을 얻거나 프로그램이 제어하는 계정을 조작할 수 있습니다

Solana에서는 실행 모델로 인해 재진입이 본질적으로 제한됩니다. Solana 런타임은 CPI의 최대 깊이를 4로 제한하며 계정 소유자만 해당 데이터를 수정할 수 있도록 하는 등 엄격한 계정 규칙을 적용합니다. 이러한 제약은 직접적인 자기 재귀를 제한하고 프로그램이 중간 상태에서 비자발적으로 호출되지 않도록 하여 재진입 공격을 방지합니다.

완화 전략

이러한 잠재적 공격을 완화하려면 개발자는 엄격한 테스트, 코드 감사, 모범 사례 준수를 함께 적용해야 합니다.

  • 포괄적인 입력 검증과 액세스 제어 검사를 구현하세요
  • Rust의 타입 시스템과 안전 기능을 최대한 활용하고, 필요한 경우가 아니면 안전하지 않은 코드를 피하세요
  • Solana와 Rust의 보안 모범 사례를 따르고 새로운 개발 동향을 계속 확인하세요
  • 내부 코드 리뷰를 수행하고 자동화 도구를 사용해 프로그램 개발 중 일반적인 취약점과 로직 오류를 찾아내세요
  • 보안 업체와 독립 보안 연구자를 포함해 평판이 좋은 제3자에게 코드베이스 감사를 받으세요
  • 그레이 햇에 의존하지 말고 취약점 신고를 장려하도록 프로그램을 위한 버그 바운티 플랫폼을 만드세요

다음 섹션에서는 여러 취약점을 알파벳순으로 살펴봅니다. 각 섹션에서는 잠재적인 취약점을 설명하고 완화 방법을 안내하며 가능한 경우 예시 시나리오를 제시합니다.

계정 데이터 일치

취약점

계정 데이터 일치 취약점은 개발자가 계정에 저장된 데이터가 예상 값 집합과 일치하는지 확인하지 않을 때 발생합니다. 적절한 데이터 검증이 없으면 프로그램이 잘못되거나 악의적으로 대체된 계정을 의도치 않게 사용할 수 있습니다. 이 취약점은 권한 관련 검사가 포함된 상황에서 특히 심각합니다.

예시 시나리오

관리 설정을 관리하는 기능이 있는 프로그램을 생각해 보겠습니다. 이 프로그램에는 기능 플래그나 운영 매개변수 같은 현재 관리 구성을 업데이트하는 명령어가 포함되어 있습니다. 이 명령어는 권한이 있는 관리자가 요청했는지 검증해야 합니다. 하지만 프로그램은 변경을 요청한 계정이 구성 데이터에 저장된 관리자 계정과 일치하는지 확인하지 않습니다.

코드
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 메서드에는 두 가지 매개변수가 있습니다.

  • new_len: 계정 데이터의 새 길이를 지정하는 usize입니다
  • zero_init: 새 메모리 공간을 0으로 초기화할지 결정하는 bool입니다

realloc은 다음과 같이 정의됩니다.

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

계정 데이터에 할당된 메모리는 프로그램 진입점에서 이미 0으로 초기화됩니다. 즉, 단일 트랜잭션 내에서 데이터를 더 큰 크기로 재할당하면 새 메모리 공간은 이미 0으로 채워져 있습니다. 이 메모리를 다시 0으로 초기화할 필요는 없으며 추가 컴퓨트 유닛만 소비합니다. 반대로 동일한 트랜잭션에서 더 작은 크기로 재할당했다가 다시 더 큰 크기로 재할당할 때 zero_init이 false라면 오래된 데이터가 노출될 수 있습니다.

예시 시나리오

사용자가 단일 트랜잭션 내에서 항목을 추가, 제거 또는 수정할 수 있는 동적 할 일 목록 프로그램을 생각해 보겠습니다. 이 프로그램은 사용자 작업에 따라 데이터 크기를 동적으로 재할당해야 합니다.

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

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

    Ok(())
}

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

이 시나리오에서 modify_todo_list 함수는 변경에 필요한 크기를 확보하기 위해 to_do_list_data을 여러 번 재할당할 수 있습니다. 할 일 항목을 제거하기 위해 데이터 크기를 줄인 뒤 동일한 트랜잭션 내에서 새 항목을 추가하려고 다시 늘릴 때 zero_init을 false로 설정하면 오래된 데이터가 노출될 수 있습니다.

권장 완화 방법

이 문제를 완화하려면 zero_init 매개변수를 신중하게 사용해야 합니다.

  • 동일한 트랜잭션 호출 내에서 데이터 크기를 줄였다가 다시 늘릴 때는 zero_init을 true로 설정하세요. 새 메모리 공간이 0으로 초기화되므로 오래된 데이터가 노출되지 않습니다
  • 동일한 트랜잭션 호출 내에서 이전에 크기를 줄이지 않고 데이터 크기를 늘릴 때는 메모리가 이미 0으로 초기화되어 있으므로 zero_init을 false로 설정하세요

특정 크기 요구사항을 충족하기 위해 데이터를 재할당하는 대신 개발자는 주소 조회 테이블(Address Lookup Tables, 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를 확인하지 않고 다른 프로그램을 호출할 때 발생합니다. 이 취약점은 호출자가 피호출자의 프로그램 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를 수행하기 전에 원장 프로그램의 ID를 확인하는 검사를 추가할 수 있습니다. 이 검사를 통해 의도한 프로그램에 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,
}

이 방식에는 문제가 있습니다. 프로그램이 계정 데이터를 0으로 초기화하거나 계정을 닫힌 상태로 표시하지 않기 때문입니다. 남은 lamports를 전송하는 것만으로는 계정이 닫히지 않습니다.

권장 완화 방법

이 문제를 완화하려면 프로그램이 모든 lamports를 전송할 뿐만 아니라 계정 데이터를 0으로 초기화하고 판별자(예: "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,
}

하지만 데이터를 0으로 초기화하고 닫힘 판별자를 추가하는 것만으로는 충분하지 않습니다. 사용자는 명령이 끝나기 전에 계정의 lamports를 다시 채워 계정이 가비지 컬렉션되지 않도록 할 수 있습니다. 그러면 해당 계정은 사용하거나 가비지 컬렉션할 수 없는 비정상적인 중간 상태에 놓입니다. 이 엣지 케이스를 해결하기 위해 force_defund 함수를 추가했습니다. 이제 누구나 닫힌 계정의 자금을 회수할 수 있습니다.

Anchor는 #[account(close = destination)] 제약 조건으로 이 과정을 간소화합니다. 단일 작업으로 lamports 전송, 데이터 0 초기화, 닫힌 계정 판별자 설정을 자동화하여 계정을 안전하게 닫습니다.

중복 가변 계정

취약점

중복 가변 계정은 동일한 계정이 명령에 가변 매개변수로 두 번 이상 전달되는 상황을 의미합니다. 명령에 같은 유형의 가변 계정 두 개가 필요할 때 발생합니다. 악의적인 행위자가 동일한 계정을 두 번 전달하면 계정이 의도하지 않은 방식으로 변경될 수 있습니다(예: 데이터 덮어쓰기). 이 취약점의 심각도는 구체적인 상황에 따라 달라집니다.

예시 시나리오

특정 온체인 활동에 참여한 정도에 따라 사용자에게 보상을 지급하는 프로그램을 가정해 보겠습니다. 이 프로그램에는 보상 계정과 보너스 계정, 총 두 계정의 잔액을 업데이트하는 명령이 있습니다. 사용자는 한 계정에서 기본 보상을 받고, 미리 정해진 특정 기준에 따라 다른 계정에서 추가 보너스를 받을 수 있습니다.

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

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

    Ok(())
}

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

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

악의적인 행위자가 reward_account와 bonus_account에 동일한 계정을 전달하면 계정 잔액이 두 번 잘못 업데이트됩니다.

권장 완화 방법

이 문제를 완화하려면 명령 로직에 검사를 추가하여 두 계정의 공개 키가 동일하지 않은지 확인하세요.

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

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

    Ok(())
}

개발자는 Anchor의 계정 제약 조건을 사용하여 계정에 더 명시적인 검사를 추가할 수 있습니다. #[account] 속성과 constraint 키워드를 사용하면 됩니다.

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

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

    Ok(())
}

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

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

프런트러닝

취약점

트랜잭션 번들러의 인기가 높아지면서 Solana 기반 프로토콜은 프런트러닝을 심각하게 고려해야 합니다. Jito의 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 또는 이와 유사한 이름의 함수를 사용합니다. 초기화 함수는 보통 프로그램의 권한과 같은 데이터를 설정하거나, 배포되는 프로그램의 기반이 되는 계정(예: 중앙 상태 계정 등)을 생성합니다.

초기화 함수는 프로그램 배포 시 자동으로 실행되지 않고 수동으로 호출되므로 프로그램 개발팀이 관리하는 알려진 주소에서 이 명령을 호출해야 합니다. 그렇지 않으면 공격자가 초기화를 프런트러닝하여 자신이 제어하는 계정으로 프로그램을 설정할 수 있습니다.

프로그램에 업그레이드 권한이 있다면 프로그램의 upgrade_authority를 initialize 함수 호출이 허용된 주소로 사용하는 것이 일반적입니다.

안전하지 않은 예시와 완화 방법

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

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

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

위 예시는 명령 호출자를 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가 트랜잭션당 100개 토큰인 상황을 가정해 보겠습니다. 두 값을 곱하면 u64가 저장할 수 있는 최댓값을 초과합니다. 따라서 곱한 값이 상한으로 제한되고, 사용자에게 지급되는 보상이 줄어들어 상당한 정밀도 손실이 발생합니다.

반올림 오류

반올림 연산은 프로그래밍에서 정밀도 손실을 일으키는 흔한 원인입니다. 반올림 방식에 따라 계산 정확도와 Solana 프로그램의 동작이 크게 달라질 수 있습니다. try_round_u64() 함수는 소수 값을 가장 가까운 정수로 반올림합니다. 올림 처리는 값을 인위적으로 부풀릴 수 있어 실제 계산과 예상 계산 사이에 차이가 발생합니다.

시장 상황에 따라 담보를 유동성으로 전환하는 Solana 프로그램을 가정해 보겠습니다. 이 프로그램은 **try_round_u64()**를 사용하여 나눗셈 연산 결과를 반올림합니다.

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

이 시나리오에서 올림 처리하면 담보 금액으로 정당화되는 수량보다 더 많은 유동성 토큰이 발행될 수 있습니다. 악의적인 행위자는 이 차이를 악용해 유리하게 조작된 반올림 결과로 차익거래 공격을 수행하고 프로토콜에서 가치를 탈취할 수 있습니다. 이를 완화하려면 try_floor_u64를 사용하여 가장 가까운 정수로 내림 처리하세요. 이 방식은 값이 인위적으로 부풀려질 위험을 최소화하며, 반올림으로 사용자가 시스템의 손해를 대가로 이득을 얻지 않도록 합니다. 또는 반올림이 결과에 명시적으로 영향을 줄 수 있는 상황을 처리하는 로직을 구현하세요. 반올림 판단을 위한 특정 임곗값을 설정하거나 관련 값의 크기에 따라 다른 로직을 적용할 수 있습니다.

소유권 검사 누락

취약점

소유권 검사는 트랜잭션이나 작업에 관련된 계정을 예상된 프로그램이 소유하는지 확인하는 데 중요합니다. 계정에는 계정 데이터에 쓸 권한이 있는 프로그램을 나타내는 owner 필드가 있습니다. 이 필드는 승인된 프로그램만 계정 상태를 변경할 수 있도록 보장합니다. 또한 명령에 전달된 계정을 예상된 프로그램이 소유하는지 확인하는 데 유용합니다. 소유권 검사가 누락되면 승인되지 않은 자금 이체나 권한이 필요한 작업의 실행을 비롯한 심각한 취약점이 발생할 수 있습니다.

예시 시나리오

관리자만 볼트에서 출금할 수 있도록 정의된 프로그램 함수를 가정해 보겠습니다. 이 함수는 설정 계정(즉, config)을 받고 해당 계정의 admin 필드를 사용하여 제공된 관리자 계정의 공개 키가 config 계정에 저장된 키와 같은지 확인합니다. 하지만 config 계정을 신뢰할 수 있다고 가정하여 소유권을 확인하지 않습니다.

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

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

    // Transfer funds logic
}

악의적인 행위자는 일치하는 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과 같은 읽기 전용 시스템 계정)의 경우 알려진 값과 계정 주소를 비교하거나 계정 소유자가 예상과 일치하는지 확인할 수 있습니다. 수동 주소 또는 소유권 검사가 필요 없는 get() 메서드로 sysvar에 접근하세요. 이 방식이 해당 계정에 더 안전하게 접근하는 방법입니다. 하지만 모든 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는 디버그 모드로 컴파일할 때 정수 오버플로와 언더플로를 검사합니다. 이러한 조건이 감지되면 런타임에 프로그램이 패닉을 일으킵니다. 하지만 --release 플래그를 사용해 릴리스 모드로 컴파일할 때는 정수 오버플로나 언더플로에 대한 패닉 검사가 포함되지 않습니다. 따라서 오버플로나 언더플로가 조용히 발생해 발견하기 어려운 취약점으로 이어질 수 있습니다. 버클리 패킷 필터(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(())
}

수정된 예에서는 checked_sub를 사용해 balance에서 tokens_to_subtract를 뺍니다. balance가 차감액을 충당하기에 충분하면 checked_sub는 **Some(new_balance)**를 반환합니다. 프로그램은 계정 잔액을 안전하게 업데이트하고 이를 로그에 기록합니다. 하지만 차감으로 인해 언더플로가 발생한다면 checked_sub가 None을 반환하므로 오류를 반환해 처리할 수 있습니다.

Checked Math 매크로

Checked Math는 수학 표현식 자체를 거의 변경하지 않고 검사 속성을 바꾸는 절차적 매크로입니다. checked_* 산술 함수의 문제는 수학 표기법이 사라진다는 점입니다. a + b 대신 **a.checked_add(b).unwrap()**처럼 번거로운 메서드를 사용해야 합니다. 예를 들어 검사 산술 함수로 (x * y) + z를 작성하려면 **x.checked_mul(y).unwrap().checked_add(z).unwrap()**으로 작성해야 합니다.

대신 Checked Math 매크로를 사용하면 다음과 같이 표현할 수 있습니다.

코드
use checked_math::checked_math as cm;

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

이 방식은 작성하기 더 편리하고 표현식의 수학 표기법을 유지하며 **.unwrap()**도 하나만 필요합니다. 매크로가 일반 수학 표현식을 검사 단계 중 하나라도 None을 반환하면 None을 반환하는 표현식으로 변환하기 때문입니다. 성공하면 **Some(_)**이 반환되므로 마지막에 표현식을 언래핑합니다.

캐스팅

마찬가지로 적절한 검사 없이 as 키워드로 정수 타입 간 캐스팅을 수행하면 정수 오버플로나 언더플로 취약점이 생길 수 있습니다. 캐스팅 과정에서 값이 의도치 않게 잘리거나 확장될 수 있기 때문입니다. 더 큰 정수 타입에서 더 작은 타입으로 캐스팅할 때(예: u64에서 u32) Rust는 대상 타입에 들어가지 않는 원래 값의 상위 비트를 잘라냅니다. 원래 값이 대상 타입에 저장할 수 있는 최댓값을 초과하면 문제가 됩니다. 더 작은 정수 타입에서 더 큰 타입으로 캐스팅할 때(예: i16에서 i32) Rust는 값을 확장합니다. 부호 없는 타입에서는 이 과정이 단순합니다. 하지만 부호 있는 정수에서는 부호 확장이 발생해 의도치 않은 음수 값이 생길 수 있습니다.

권장 완화 방법

이 취약점을 완화하려면 Rust의 안전한 캐스팅 메서드를 사용하세요. 여기에는 try_from과 from 같은 메서드가 포함됩니다. try_from은 Result 타입을 반환하므로 값이 대상 타입에 들어가지 않는 경우를 명시적이고 안전하게 처리할 수 있습니다. Rust의 from 메서드는 손실이 없다고 보장되는 변환(예: u8에서 u32)에 안전한 암시적 변환으로 사용할 수 있습니다. 예를 들어 프로그램에서 u64 타입의 토큰 수량을 처리용 u32 타입으로 안전하게 변환해야 한다면 다음과 같이 작성할 수 있습니다.

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

이 예에서 amount가 u32에 저장할 수 있는 최댓값인 4 294 967 295를 초과하면 변환이 실패하고 프로그램이 오류를 반환합니다. 따라서 잠재적인 오버플로나 언더플로를 방지할 수 있습니다.

PDA 공유

취약점

PDA 공유는 여러 권한 도메인이나 역할에서 동일한 PDA를 사용할 때 발생하는 일반적인 취약점입니다. 적절한 액세스 제어 검사 없이 PDA를 서명자로 잘못 사용하면 악의적인 공격자가 자신에게 속하지 않은 데이터나 자금에 접근할 수 있습니다.

예시 시나리오

토큰 스테이킹과 보상 분배를 지원하도록 설계된 프로그램을 생각해 보겠습니다. 이 프로그램은 단일 PDA를 사용해 지정된 풀로 토큰을 전송하고 보상을 출금합니다. PDA는 스테이킹 풀 이름과 같은 정적 시드에서 파생되므로 모든 작업에서 공통으로 사용됩니다.

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

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

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

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

스테이킹과 보상 출금 기능이 모두 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는 각각 서로 다른 시드인 staking_pool과 rewards_pool을 특정 계정의 키와 결합해 파생합니다. 따라서 각 PDA가 의도된 기능에 고유하게 연결되며 승인되지 않은 작업의 위험을 줄일 수 있습니다.

나머지 계정

취약점

ctx.remaining_accounts를 사용하면 처음에 Accounts 구조체에 지정하지 않은 추가 계정을 함수에 전달할 수 있습니다. 따라서 개발자는 동적인 수의 계정이 필요한 상황, 즉 가변적인 수의 사용자를 처리하거나 여러 프로그램과 상호작용하는 상황에 더 유연하게 대응할 수 있습니다. 하지만 이러한 유연성에는 주의할 점이 있습니다. ctx.remaining_accounts를 통해 전달된 계정에는 Accounts 구조체에 정의된 계정과 동일한 검증이 적용되지 않습니다. ctx.remaining_accounts는 전달된 계정을 검증하지 않으므로 악의적인 공격자가 프로그램에서 상호작용하도록 의도하지 않은 계정을 전달해 승인되지 않은 작업이나 접근을 유발할 수 있습니다.

예시 시나리오

ctx.remaining_accounts를 사용해 사용자 PDA를 받고 보상을 동적으로 계산하는 보상 프로그램을 생각해 보겠습니다.

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

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

    // Logic to distribute calculated rewards

    Ok(())
}

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

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

문제는 ctx.remaining_accounts를 통해 전달된 계정을 검증하는 명시적 검사가 없다는 점입니다. 따라서 유효하고 자격이 있는 사용자의 계정만 보상 계산과 분배에 사용되는지 보장하지 못합니다. 악의적인 공격자는 자신이 소유하지 않은 계정이나 직접 생성한 계정을 전달해 실제로 받아야 할 금액보다 더 많은 보상을 받을 수 있습니다.

권장 완화 방법

이 취약점을 완화하려면 개발자가 함수 내에서 각 계정의 유효성을 직접 확인해야 합니다. 계정 소유자가 예상한 사용자와 일치하는지 확인하고 계정 내 관련 데이터를 검증해야 합니다. 이러한 수동 검사를 추가하면 승인되지 않은 접근이나 조작의 위험을 줄이면서 ctx.remaining_acocunts의 유연성을 활용할 수 있습니다.

Rust 관련 오류

Rust는 Solana 프로그램 개발의 공용어입니다. Rust로 개발할 때는 안전하지 않은 코드와 Rust 관련 오류를 중심으로 고유한 과제와 고려 사항이 발생합니다. Rust의 주의점을 이해하면 안전하고 효율적이며 신뢰할 수 있는 프로그램을 개발하는 데 도움이 됩니다.

안전하지 않은 Rust

Rust는 엄격한 소유권 및 대여 시스템을 통해 메모리 안전성을 보장하는 것으로 잘 알려져 있습니다. 하지만 이러한 보장이 방해가 될 때도 있으므로 Rust는 안전성 검사를 우회할 수 있는 unsafe 키워드를 제공합니다. unsafe Rust는 주로 다음 네 가지 컨텍스트에서 사용됩니다.

  • 안전하지 않은 함수: Rust의 안전성 보장을 위반할 수 있는 작업을 수행하는 함수에는 unsafe 키워드를 표시해야 합니다. 예: unsafe fn dangerous_function() {}
  • 안전하지 않은 블록: 안전하지 않은 작업이 허용되는 코드 블록입니다. 예: unsafe { // Unsafe operations }
  • 안전하지 않은 트레이트: 컴파일러가 검증할 수 없는 특정 불변 조건을 전제로 하는 트레이트입니다. 예: unsafe trait BadTrait {}
  • 안전하지 않은 트레이트 구현: unsafe 트레이트의 구현에도 unsafe를 표시해야 합니다. 예: unsafe impl UnsafeTrait for UnsafeType {}

정적 분석이 보수적으로 작동하기 때문에 안전하지 않은 Rust가 필요합니다. 컴파일러가 코드에서 특정 보장이 유지되는지 판단할 때는 유효하지 않은 코드 몇 개를 허용하는 것보다 유효한 코드 몇 개를 거부하는 편이 낫습니다. 코드가 실제로는 문제없이 실행되더라도 Rust의 안전성 보장을 유지한다고 확신할 정보가 충분하지 않으면 Rust 컴파일러는 해당 코드를 거부합니다. 안전하지 않은 코드를 사용하면 개발자가 위험을 감수하고 이러한 검사를 우회할 수 있습니다. 또한 컴퓨터 하드웨어는 본질적으로 안전하지 않습니다. Rust로 저수준 프로그래밍을 수행하려면 개발자에게 안전하지 않은 작업을 수행할 수 있는 방법이 필요합니다.

unsafe 키워드를 사용하면 개발자는 다음 작업을 수행할 수 있습니다.

  • 원시 포인터 역참조: 유효한 데이터가 없을 수도 있는 임의의 메모리 위치를 가리키는 원시 포인터에 직접 접근할 수 있습니다
  • 안전하지 않은 함수 호출: 이러한 함수는 Rust의 안전성 보장을 따르지 않을 수 있으며 정의되지 않은 동작을 유발할 가능성이 있습니다
  • 가변 정적 변수 접근: 전역 가변 상태는 데이터 경합을 일으킬 수 있습니다

안전하지 않은 Rust로 인한 위험을 줄이는 가장 좋은 방법은 unsafe 블록 사용을 최소화하는 것입니다. 어떤 이유로든 unsafe 코드가 반드시 필요하다면 충분히 문서화하고 정기적으로 감사하세요. 가능하다면 프로그램의 나머지 부분에 제공할 수 있는 안전한 추상화로 캡슐화하세요.

패닉과 오류 관리

패닉은 Rust 프로그램이 복구 불가능한 오류를 만나 실행을 종료할 때 발생합니다. 패닉은 포착하도록 설계되지 않은 예기치 않은 오류에 사용됩니다. Solana 프로그램에서 패닉이 발생하면 런타임이 프로그램이 중단되지 않고 오류를 안전하게 처리할 것으로 예상하기 때문에 예기치 않은 동작으로 이어질 수 있습니다.

패닉이 발생하면 Rust는 스택을 언와인딩하면서 정리하기 시작합니다. 이 과정에서 관련 오류의 상세 정보가 포함된 스택 트레이스가 반환됩니다. 공격자는 이를 통해 내부 파일 구조에 관한 정보를 얻을 수 있습니다. Solana 프로그램에 직접 적용되는 문제는 아니지만 프로그램에서 사용하는 종속성이 이러한 공격에 취약할 수 있습니다. 종속성을 최신 상태로 유지하고 알려진 취약점이 없는 버전을 사용하세요.

일반적인 패닉 시나리오는 다음과 같습니다.

  • 0으로 나누기: Rust에서 0으로 나누려고 하면 패닉이 발생합니다. 따라서 나눗셈을 수행하기 전에 제수가 0인지 항상 확인하세요
  • 배열 인덱스 범위 초과: 배열 범위를 벗어난 인덱스로 접근하면 패닉이 발생합니다. 이를 완화하려면 get처럼 Option 타입을 반환하는 메서드를 사용해 배열 요소에 안전하게 접근하세요
  • None 값 언래핑: None 값을 가진 Option에 **.unwrap()**을 호출하면 패닉이 발생합니다. Result를 반환하는 함수에서는 항상 패턴 매칭, unwrap_or나 unwrap_or_else 같은 메서드 또는 ? 연산자를 사용하세요

패닉과 관련된 문제를 완화하려면 패닉을 일으키는 작업을 피하고, 문제가 될 수 있는 모든 입력과 조건을 검증하며, 오류 처리에 Result와 Option 타입을 사용해야 합니다. 또한 프로그램 테스트를 포괄적으로 작성하면 배포 전에 잠재적인 패닉 시나리오를 발견하고 해결하는 데 도움이 됩니다.

시드 충돌

취약점

시드 충돌은 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, 논스 값 등의 고유 식별자를 사용해 매번 고유한 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의 성장과 안정성에 기여하는 일입니다.

여기까지 읽어주셔서 감사합니다! 아래에 이메일 주소를 입력하면 Solana의 새로운 소식을 빠짐없이 받아볼 수 있습니다. 더 자세히 알아볼 준비가 되셨나요? 지금 바로 Helius 블로그의 최신 글을 살펴보고 Solana 여정을 이어가세요.

추가 자료

Helius 구독하기

최신 Solana 개발 소식을 확인하고 새 게시물 알림을 받아보세요

확대 이미지