हमारे पिछले native Solana ट्यूटोरियल्स में, हमने उदाहरणों को छोटा रखने और मुख्य विषयों पर केंद्रित करने के लिए सुरक्षा जाँचों (security checks) को छोड़ दिया था।
इस ट्यूटोरियल में, हम native Solana प्रोग्राम्स के लिए आवश्यक सुरक्षा जाँचों को कवर करेंगे: account ownership को मान्य करना, sysvar और program IDs को सत्यापित करना, signers की आवश्यकता सुनिश्चित करना, writable accounts को लागू करना, CPIs के बाद state को रीलोड करना, और token account dust attacks को संभालना।
Account ownership को मान्य (Validate) करें
किसी account के डेटा का उपयोग करने से पहले, यह जाँचें कि उसका owner अपेक्षित program ID से मेल खाता है या नहीं। अन्यथा, एक हमलावर (attacker) दुर्भावनापूर्ण डेटा (malicious data) के साथ अपने नियंत्रण वाला account पास कर सकता है।
Anchor में, किसी account को Account<'info, T> के साथ परिभाषित करने पर यह स्वचालित रूप से जाँच लेता है कि वह account आपके प्रोग्राम के स्वामित्व (owned) में है। बाहरी accounts के लिए, आप किसी विशिष्ट program ID द्वारा स्वामित्व लागू करने (enforce) के लिए #[account(owner = <ID>)] एट्रिब्यूट (attribute) जोड़ सकते हैं।
उदाहरण के लिए, मान लें कि हमारे पास एक Config account है जो निकासी (withdrawals) को नियंत्रित करता है। यदि हम यह सत्यापित नहीं करते हैं कि config का स्वामित्व हमारे प्रोग्राम के पास है, तो एक हमलावर नकली डेटा वाला एक नकली account पास कर सकता है और तब निकासी कर सकता है जब उसे ऐसा नहीं करना चाहिए।
use borsh::{BorshDeserialize, BorshSerialize};
use solana_program::{account_info::AccountInfo, program_error::ProgramError};
#[derive(BorshDeserialize, BorshSerialize)]
struct Config { withdraw_cap: u64 }
pub fn withdraw(config: &AccountInfo, amount: u64) -> Result<(), ProgramError> {
// Missing check: config.owner == program_id
let cfg = Config::try_from_slice(&config.try_borrow_data()?)?;
if amount <= cfg.withdraw_cap {
// proceed to transfer funds...
}
Ok(())
}
इसे ठीक करने के लिए, हम इसके डेटा को deserialize करने या उपयोग करने से पहले यह जाँचते हैं कि config account का स्वामित्व हमारे प्रोग्राम के पास है।
use solana_program::{account_info::AccountInfo, program_error::ProgramError, pubkey::Pubkey};
pub fn withdraw(config: &AccountInfo, amount: u64, program_id: &Pubkey) -> Result<(), ProgramError> {
// Check that the config account is owned by our program
if config.owner != program_id { return Err(ProgramError::IncorrectProgramId); }
// Rest of the function...
Ok(())
}
Sysvar और Program IDs को मान्य (Validate) करें
जब आपको Clock sysvar या system program जैसे किसी sysvar की आवश्यकता हो, तो हमेशा सत्यापित (verify) करें कि यह असली है। एक हमलावर हेरफेर किए गए डेटा के साथ नकली accounts पास कर सकता है।
Anchor प्रोग्राम्स में, इसे Sysvar<'info, Clock> या Program<'info, System> प्रकारों का उपयोग करके लागू किया जाता है, जो आपके लिए इन जाँचों को संभालते हैं। लेकिन हमें native प्रोग्राम्स में IDs को मैन्युअल रूप से जाँचना पड़ता है।
इस उदाहरण में, withdraw_timelock को पास किए गए clock account से Clock sysvar होने की उम्मीद की जाती है, लेकिन बिना सत्यापन के, एक हमलावर जल्दी निकासी करने के लिए हेरफेर किए गए टाइमस्टैम्प के साथ एक नकली clock account पास कर सकता है।
// Vulnerable: fake sysvar allows time manipulation
use solana_program::{account_info::AccountInfo, program_error::ProgramError, clock::Clock};
#[derive(BorshDeserialize, BorshSerialize)]
struct TimeLock {
unlock_time: i64,
amount: u64,
}
pub fn withdraw_timelock(timelock: &AccountInfo, clock: &AccountInfo) -> Result<(), ProgramError> {
// Missing: verify clock.key == sysvar::clock::ID
let timelock = TimeLock::try_from_slice(&timelock.try_borrow_data()?)?;
// Attacker can pass fake clock account with manipulated timestamp
let clock = Clock::from_account_info(clock)?;
if clock.unix_timestamp >= timelock.unlock_time {
// Process early withdrawal with fake timestamp
}
Ok(())
}
इसके समाधान के रूप में, हमेशा सुनिश्चित करें कि sysvar और program account के पते मेल खाते हैं:
use solana_program::{sysvar, program_error::ProgramError, account_info::AccountInfo};
// Rest of the code...
pub fn withdraw_timelock(timelock: &AccountInfo, clock: &AccountInfo) -> Result<(), ProgramError> {
// Fix: verify clock.key == sysvar::clock::ID
if clock.key != &sysvar::clock::ID {
return Err(ProgramError::InvalidAccountData);
}
// Rest of the function...
Ok(())
}
वास्तविक जीवन का उदाहरण (Real life example)
Solana पर $320M का Wormhole ब्रिज कारनामा (exploit) (फ़रवरी 2022) इसलिए हुआ क्योंकि प्रोग्राम ने यह सुनिश्चित नहीं किया था कि प्रदान किया गया system program address वास्तविक System Program address से मेल खाता है। हमलावर ने एक नकली system account पास किया जिसने सिग्नेचर जाँचों को बायपास कर दिया, जिससे उन्हें बिना प्राधिकरण के 120,000 wETH (wrapped ETH) मिंट (mint) करने की अनुमति मिल गई।
यही कारण है कि Solana प्रोग्राम्स को हमेशा यह सत्यापित करना चाहिए कि system accounts और sysvars उनके आधिकारिक IDs से मेल खाते हैं। आप CertiK का पूरा विश्लेषण यहाँ पढ़ सकते हैं।
Signers की आवश्यकता (Require Signers)
जब आपका प्रोग्राम किसी कार्य को किसी विशिष्ट प्राधिकरण (जैसे, एक एडमिन) तक सीमित करता है, तो केवल यह जाँचना पर्याप्त नहीं है कि account की पब्लिक की (public key) अपेक्षित की (key) से मेल खाती है। एक हमलावर वास्तविक एडमिन account को ट्रांज़ैक्शन में एक गैर-हस्ताक्षरकर्ता (non-signer) के रूप में शामिल कर सकता है और उस जाँच को पास कर सकता है। आपको यह भी सत्यापित करना होगा कि account ने वास्तव में ट्रांज़ैक्शन पर हस्ताक्षर (sign) किए हैं (admin.is_signer), जो साबित करता है कि प्राइवेट की के मालिक ने इसे मंज़ूरी दी है। Anchor में, #[account(signer)] एट्रिब्यूट आपके लिए इसे संभालता है।
यहाँ एक कमज़ोर (vulnerable) native Rust उदाहरण दिया गया है जहाँ एक फ़ंक्शन config account में withdraw cap को अपडेट करता है:
// Vulnerable: checks admin key but not that admin actually signed
#[derive(BorshDeserialize, BorshSerialize)]
struct Config { admin: Pubkey, withdraw_cap: u64 }
pub fn update_withdraw_cap(config: &AccountInfo, admin: &AccountInfo, new_cap: u64) -> Result<(), ProgramError> {
let mut cfg = Config::try_from_slice(&config.try_borrow_data()?)?;
// Missing check: require admin.is_signer
if *admin.key != cfg.admin { return Err(ProgramError::InvalidAccountData); }
cfg.withdraw_cap = new_cap;
cfg.serialize(&mut &mut config.try_borrow_mut_data()?[..])?;
Ok(())
}
इसे ठीक करने के लिए, यह आवश्यक करें कि एडमिन एक signer हो और स्टोर की गई एडमिन key से मेल खाए:
pub fn update_withdraw_cap(config: &AccountInfo, admin: &AccountInfo, new_cap: u64) -> Result<(), ProgramError> {
let mut cfg = Config::try_from_slice(&config.try_borrow_data()?)?;
// Ensure admin is a signer
if !admin.is_signer { return Err(ProgramError::MissingRequiredSignature); }
// Ensure admin is the expected admin
if *admin.key != cfg.admin { return Err(ProgramError::InvalidAccountData); }
cfg.withdraw_cap = new_cap;
cfg.serialize(&mut &mut config.try_borrow_mut_data()?[..])?;
Ok(())
}
डेटा या Lamport संशोधनों के लिए Writable Accounts की आवश्यकता
यदि आपके प्रोग्राम को किसी account के lamports या डेटा को संशोधित करने की आवश्यकता है, तो क्लाइंट को ट्रांज़ैक्शन में उस account को writable के रूप में चिह्नित करना होगा, और आपके प्रोग्राम को यह सत्यापित करना चाहिए कि यह writable है। यदि account को writable चिह्नित नहीं किया गया है, तो इसे संशोधित करने के प्रयास से ट्रांज़ैक्शन एक त्रुटि (जैसे, “Readonly account changed”) के साथ विफल हो जाएगा।
TypeScript क्लाइंट में account को writable के रूप में चिह्नित करने के लिए, हम ट्रांज़ैक्शन निर्माण के दौरान इसके isWritable फ़्लैग को true पर सेट करते हैं।
// web3.js: set isWritable = true for accounts you will modify
import { TransactionInstruction, PublicKey } from "@solana/web3.js";
const ix = new TransactionInstruction({
programId,
keys: [
{ pubkey: userAccount, isSigner: false, isWritable: true }, // needs mutation
{ pubkey: payer, isSigner: true, isWritable: false },
],
data: Buffer.from([]),
});
हमारा प्रोग्राम AccountInfo स्ट्रक्ट के is_writable फ़ील्ड का उपयोग करके यह जाँच सकता है कि कोई account writable है या नहीं।
pub fn update_user_balance(user_account: &AccountInfo) -> Result<(), ProgramError> {
// Check if supplied user account is writable before proceeding
if !user_account.is_writable {
return Err(ProgramError::InvalidAccountData);
}
// Rest of the function...
Ok(())
}
Anchor में, आप इसे #[account(mut)] के साथ लागू करते हैं, जो आपके फ़ंक्शन के चलने से पहले is_writable = true की जाँच करता है।
प्रत्येक CPI के बाद Account State को रीलोड करें
एक CPI account डेटा को संशोधित कर सकता है। इसके आधार पर निर्णय लेने से पहले आपके प्रोग्राम को CPI कॉल के बाद account को फिर से पढ़ना चाहिए।
यहाँ बताया गया है कि बिना रीलोड किए क्या हो सकता है (native Rust प्रोग्राम कोड का उपयोग करते हुए):
// Vulnerable: using stale account data after CPI
use solana_program::{
account_info::AccountInfo,
program::{invoke},
instruction::Instruction,
program_error::ProgramError
};
use borsh::BorshDeserialize;
#[derive(BorshDeserialize)]
struct VaultState {
balance: u64,
is_locked: bool,
}
pub fn withdraw_after_cpi_vulnerable(
vault: &AccountInfo,
external_program: &AccountInfo,
cpi_instruction: Instruction,
amount: u64
) -> Result<(), ProgramError> {
// Read vault state before CPI
let vault_state = VaultState::try_from_slice(&vault.try_borrow_data()?)?;
// Perform CPI that might modify the vault
invoke(&cpi_instruction, &[vault.clone(), external_program.clone()])?;
// Vulnerable: using stale vault_state after CPI
// The CPI might have changed vault.is_locked to true
if !vault_state.is_locked && vault_state.balance >= amount {
// Process withdrawal with stale data
}
Ok(())
}
इस कोड में, CPI vault की स्थिति (state) को संशोधित कर सकता है (उदाहरण के लिए, is_locked को true पर सेट कर सकता है), लेकिन हम अभी भी पुराने vault_state का उपयोग कर रहे हैं जिसे CPI से पहले पढ़ा गया था। यह एक time-of-check to time-of-use (TOCTOU) भेद्यता (vulnerability) बनाता है, जिसका अर्थ है कि जिस स्थिति की हमने जाँच की थी, वह अब वह स्थिति नहीं है जिस पर हम कार्य कर रहे हैं।
इसे ठीक करने के लिए, CPI के बाद हमेशा account डेटा को रीलोड करें:
use solana_program::{
account_info::AccountInfo,
program::invoke,
instruction::Instruction,
program_error::ProgramError
};
pub fn withdraw_after_cpi_safe(
vault: &AccountInfo,
external_program: &AccountInfo,
cpi_instruction: Instruction,
amount: u64
) -> Result<(), ProgramError> {
// Read vault state before CPI
let vault_state_before = VaultState::try_from_slice(&vault.try_borrow_data()?)?;
// Perform CPI
invoke(&cpi_instruction, &[vault.clone(), external_program.clone()])?;
// Reload vault state after CPI
let vault_state_after = VaultState::try_from_slice(&vault.try_borrow_data()?)?;
// Use the fresh data for decisions
if !vault_state_after.is_locked && vault_state_after.balance >= amount {
// Process withdrawal with current data
}
Ok(())
}
Token Accounts बंद करने से पहले शेष Tokens को Burn करें
कल्पना करें कि आप एक liquid staking प्रोटोकॉल बना रहे हैं। उपयोगकर्ता SOL जमा (deposit) करते हैं और अपनी हिस्सेदारी (stake) साबित करने वाले receipt tokens प्राप्त करते हैं। जब वे unstake करते हैं, तो वे अपना SOL वापस पाने के लिए अपने receipt tokens को burn कर देते हैं। आपका प्रोग्राम प्रत्येक उपयोगकर्ता के receipt tokens को एक PDA token account (जो आपके प्रोग्राम के स्वामित्व में है) में संग्रहीत करता है, और receipt tokens को burn करने के बाद, आप किराए (rent) को उपयोगकर्ता को वापस करने के लिए PDA token account को बंद कर देते हैं।
समस्या तब उत्पन्न होती है जब प्रोग्राम यह मान लेता है कि PDA token account में केवल उपयोगकर्ता के वैध (legitimate) receipt tokens हैं। एक हमलावर पीड़ित के unstake करने से पहले सीधे उनके PDA token account में उसी मिंट (mint) का केवल 1 receipt token भेजकर (एक “dust attack”) इसका फायदा उठा सकता है।
जब वैध उपयोगकर्ता unstake करने का प्रयास करता है, तो प्रोग्राम केवल उतने ही receipt tokens को burn करता है जितने उपयोगकर्ता ने मूल रूप से stake करते समय प्राप्त किए थे। लेकिन चूँकि हमलावर का 1 dust receipt token अभी भी account में है, इसलिए बैलेंस (balance) शून्य नहीं है। फिर close account ऑपरेशन विफल हो जाता है क्योंकि SPL Token Program को बंद करने से पहले account का बैलेंस बिल्कुल शून्य होना आवश्यक है। चूँकि हमारा प्रोग्राम बंद करने से पहले किसी शेष tokens की जाँच नहीं करता है या उन्हें burn नहीं करता है, हमलावर का dust token वहीं रहता है। यह हर बार विफल हो जाएगा, उपयोगकर्ता के staked SOL को स्थायी रूप से लॉक कर देगा और DoS का कारण बनेगा।
यहाँ एक उदाहरण है:
// Vulnerable: assumes token account balance is always zero
use solana_program::{
account_info::AccountInfo,
entrypoint::ProgramResult,
program::invoke_signed,
pubkey::Pubkey,
};
use spl_token::instruction::{burn, close_account};
pub fn unstake_vulnerable(
program_id: &Pubkey,
accounts: &[AccountInfo],
amount: u64, // assume this to be the amount of receipt tokens the user originally received when they staked
) -> ProgramResult {
let account_info_iter = &mut accounts.iter();
let receipt_token_account = next_account_info(account_info_iter)?;
let receipt_mint = next_account_info(account_info_iter)?;
let vault_authority = next_account_info(account_info_iter)?;
let user = next_account_info(account_info_iter)?;
let token_program = next_account_info(account_info_iter)?;
// Burn the user's original receipt tokens
invoke_signed(
&burn(
token_program.key,
receipt_token_account.key,
receipt_mint.key,
vault_authority.key,
&[],
amount,
)?,
&[receipt_token_account.clone(), receipt_mint.clone(), vault_authority.clone()],
&[&vault_seeds],
)?;
// Missing: check if any dust tokens remain before closing
invoke_signed(
&close_account(
token_program.key,
receipt_token_account.key,
user.key,
vault_authority.key,
&[],
)?,
&[receipt_token_account.clone(), user.clone(), vault_authority.clone()],
&[&vault_seeds],
)?;
Ok(())
}
इस कोड में, प्रोग्राम केवल उसी amount को burn करता है जो उपयोगकर्ता ने मूल रूप से stake किया था। यदि किसी हमलावर ने पहले ही 1 dust token भेज दिया है, तो वह account में रह जाता है। close_account कॉल विफल हो जाती है क्योंकि SPL Token Program के लिए बैलेंस का बिल्कुल शून्य होना आवश्यक है, जो उपयोगकर्ता के staked SOL को स्थायी रूप से लॉक कर देता है।
यह भेद्यता SPL token accounts को बंद करते समय native Solana प्रोग्राम्स और Anchor प्रोग्राम्स दोनों पर लागू होती है।
इसे ठीक करने के लिए, वास्तविक ऑन-चेन (on-chain) बैलेंस प्राप्त करें (fetch) और किसी भी शेष tokens को burn करें:
use solana_program::{
account_info::AccountInfo,
entrypoint::ProgramResult,
program::invoke_signed,
program_pack::Pack,
pubkey::Pubkey,
};
use spl_token::{instruction::{burn, close_account}, state::Account as TokenAccount};
pub fn unstake_safe(
program_id: &Pubkey,
accounts: &[AccountInfo],
) -> ProgramResult {
let account_info_iter = &mut accounts.iter();
let receipt_token_account = next_account_info(account_info_iter)?;
let receipt_mint = next_account_info(account_info_iter)?;
let vault_authority = next_account_info(account_info_iter)?;
let user = next_account_info(account_info_iter)?;
let token_program = next_account_info(account_info_iter)?;
// Fetch actual on-chain balance
let token_account = TokenAccount::unpack(&receipt_token_account.try_borrow_data()?)?;
// Burn all tokens (user's original amount + any dust)
if token_account.amount > 0 {
invoke_signed(
&burn(
token_program.key,
receipt_token_account.key,
receipt_mint.key,
vault_authority.key,
&[],
token_account.amount, // Burns everything including dust
)?,
&[receipt_token_account.clone(), receipt_mint.clone(), vault_authority.clone()],
&[&vault_seeds],
)?;
}
// Safe to close now
invoke_signed(
&close_account(
token_program.key,
receipt_token_account.key,
user.key,
vault_authority.key,
&[],
)?,
&[receipt_token_account.clone(), user.clone(), vault_authority.clone()],
&[&vault_seeds],
)?;
Ok(())
}
Token account या contract निर्माण (creation) की Frontrunning
System Program का create_account निर्देश (instruction) विफल हो जाता है यदि लक्ष्य (target) account में पहले से ही lamports हैं। चूँकि PDA पते नियतात्मक (deterministic) (ज्ञात seeds से प्राप्त) होते हैं, एक हमलावर account बनने से पहले पते की गणना कर सकता है और उसमें 1 lamport भेज सकता है। जब प्रोग्राम बाद में उस पते पर account बनाने का प्रयास करता है, तो create_account विफल हो जाता है क्योंकि account में पहले से ही एक बैलेंस है — जिससे DoS होता है।
उदाहरण के लिए, मान लें कि हमारे पास एक vault प्रोग्राम है जहाँ प्रत्येक उपयोगकर्ता को उनकी पब्लिक की से प्राप्त एक PDA vault मिलता है:
use solana_program::{
account_info::AccountInfo,
entrypoint::ProgramResult,
program::invoke_signed,
pubkey::Pubkey,
rent::Rent,
system_instruction,
sysvar::Sysvar,
};
pub fn initialize_vault(
program_id: &Pubkey,
accounts: &[AccountInfo],
) -> ProgramResult {
let payer = &accounts[0];
let vault = &accounts[1];
let system_program = &accounts[2];
let (vault_pda, bump) = Pubkey::find_program_address(
&[b"vault", payer.key.as_ref()],
program_id,
);
let rent = Rent::get()?;
let space = 48; // vault data size
let lamports = rent.minimum_balance(space);
// Vulnerable: fails if attacker sent lamports to vault_pda beforehand
invoke_signed(
&system_instruction::create_account(
payer.key,
&vault_pda,
lamports,
space as u64,
program_id,
),
&[payer.clone(), vault.clone(), system_program.clone()],
&[&[b"vault", payer.key.as_ref(), &[bump]]],
)?;
Ok(())
}
हमलावर बस वही PDA पता (seeds = ["vault", victim_pubkey]) प्राप्त करता है, एक सामान्य SOL ट्रांसफ़र के माध्यम से उसमें 1 lamport भेजता है, और पीड़ित कभी भी अपना vault इनिशियलाइज़ (initialize) नहीं कर पाता है।
इसे ठीक करने के लिए, जाँचें कि क्या account में पहले से ही lamports हैं। यदि ऐसा है, तो create_account को छोड़ दें और इसके बजाय transfer (किराया टॉप अप करने के लिए), allocate (account के डेटा स्पेस को रिज़र्व करने के लिए), और assign (account के owner को अपने प्रोग्राम पर सेट करने के लिए) का अलग-अलग उपयोग करें — ये निर्देश उन accounts पर विफल नहीं होते हैं जिनमें पहले से बैलेंस होता है:
use solana_program::{
account_info::AccountInfo,
entrypoint::ProgramResult,
program::invoke_signed,
pubkey::Pubkey,
rent::Rent,
system_instruction,
sysvar::Sysvar,
};
pub fn initialize_vault_safe(
program_id: &Pubkey,
accounts: &[AccountInfo],
) -> ProgramResult {
let payer = &accounts[0];
let vault = &accounts[1];
let system_program = &accounts[2];
let (vault_pda, bump) = Pubkey::find_program_address(
&[b"vault", payer.key.as_ref()],
program_id,
);
let signer_seeds = &[b"vault", payer.key.as_ref(), &[bump]];
let rent = Rent::get()?;
let space = 48;
let required_lamports = rent.minimum_balance(space);
if vault.lamports() > 0 {
// Account already has lamports (possibly from an attacker).
// Top up to rent-exempt minimum if needed.
let deficit = required_lamports.saturating_sub(vault.lamports());
if deficit > 0 {
invoke_signed(
&system_instruction::transfer(payer.key, &vault_pda, deficit),
&[payer.clone(), vault.clone()],
&[signer_seeds],
)?;
}
// Allocate space and assign ownership to our program
invoke_signed(
&system_instruction::allocate(&vault_pda, space as u64),
&[vault.clone()],
&[signer_seeds],
)?;
invoke_signed(
&system_instruction::assign(&vault_pda, program_id),
&[vault.clone()],
&[signer_seeds],
)?;
} else {
// No lamports — safe to use create_account
invoke_signed(
&system_instruction::create_account(
payer.key,
&vault_pda,
required_lamports,
space as u64,
program_id,
),
&[payer.clone(), vault.clone(), system_program.clone()],
&[signer_seeds],
)?;
}
// Rest of the code...
Ok(())
}
सबसे पहले vault.lamports() > 0 की जाँच करके, हम दोनों स्थितियों को संभालते हैं: सामान्य निर्माण (कोई पूर्व lamports नहीं) और frontrunning परिदृश्य (हमलावर ने lamports भेजे)। allocate और assign निर्देश उन accounts पर काम करते हैं जिनमें पहले से ही एक बैलेंस है, इसलिए हमलावर के परेशानी पैदा करने वाले (griefing) प्रयास का कोई प्रभाव नहीं पड़ता है।
Anchor में, init के बजाय init_if_needed का उपयोग इस परिदृश्य को संभालता है।
यह लेख Solana development पर एक ट्यूटोरियल सीरीज़ का हिस्सा है।