En nuestros tutoriales anteriores sobre Solana nativo, omitimos las verificaciones de seguridad para mantener los ejemplos breves y enfocados en los temas principales.
En este tutorial, cubriremos las verificaciones de seguridad esenciales para los programas nativos de Solana: validar la propiedad de las cuentas, verificar las IDs de sysvar y programas, requerir firmantes (signers), exigir cuentas modificables (writable), recargar el estado después de CPIs y manejar ataques de polvo (dust attacks) en cuentas de tokens.
Validar la propiedad de la cuenta
Antes de usar los datos de una cuenta, verifica que su propietario (owner) coincida con el program ID esperado. De lo contrario, un atacante puede pasar una cuenta que controla con datos maliciosos.
En Anchor, definir una cuenta con Account<'info, T> verifica automáticamente que la cuenta sea propiedad de tu programa. Para cuentas externas, puedes agregar el atributo #[account(owner = <ID>)] para imponer la propiedad por parte de un program ID específico.
Por ejemplo, supongamos que tenemos una cuenta Config que controla los retiros. Si no verificamos que la configuración sea propiedad de nuestro programa, un atacante puede pasar una cuenta falsa con datos falsos y retirar fondos cuando no debería.
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(())
}
Para solucionar esto, verificamos que la cuenta config sea propiedad de nuestro programa antes de deserializar o usar sus datos.
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(())
}
Validar IDs de Sysvar y Programas
Cuando necesites una sysvar como la sysvar Clock o un system program, siempre verifica que sea el real. Un atacante puede pasar cuentas falsas con datos manipulados.
En los programas de Anchor, esto se impone usando los tipos Sysvar<'info, Clock> o Program<'info, System>, los cuales manejan las verificaciones por ti. Pero en los programas nativos tenemos que verificar manualmente las IDs.
En este ejemplo, se espera que la cuenta clock pasada a withdraw_timelock sea la sysvar Clock, pero sin verificación, un atacante puede pasar una cuenta clock falsa con una marca de tiempo manipulada para retirar anticipadamente.
// 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(())
}
Como solución, asegúrate siempre de que las direcciones de las cuentas de sysvar y programas coincidan:
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(())
}
Ejemplo de la vida real
El exploit del puente Wormhole por $320M en Solana (febrero de 2022) ocurrió porque el programa no se aseguró de que la dirección del system program proporcionada coincidiera con la dirección real del System Program. El atacante pasó una cuenta del sistema falsa que eludió las verificaciones de firma, lo que le permitió acuñar (mint) 120,000 wETH (ETH envuelto) sin autorización.
Es por esto que los programas de Solana siempre deben verificar que las cuentas del sistema y las sysvars coincidan con sus IDs oficiales. Puedes leer el análisis completo de CertiK aquí.
Requerir Firmantes (Signers)
Cuando tu programa restringe una acción a una autoridad específica (por ejemplo, un admin), no basta con verificar que la clave pública de la cuenta coincida con la esperada. Un atacante puede incluir la cuenta admin real en la transacción como no firmante (non-signer) y pasar esa verificación. También debes verificar que la cuenta realmente firmó la transacción (admin.is_signer), lo cual demuestra que el propietario de la clave privada la aprobó. En Anchor, el atributo #[account(signer)] maneja esto por ti.
Aquí hay un ejemplo vulnerable en Rust nativo donde una función actualiza el límite de retiro en una cuenta config:
// 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(())
}
Para solucionar esto, requiere que el admin sea un firmante y coincida con la clave admin almacenada:
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(())
}
Requerir Cuentas Modificables (Writable) para Modificaciones de Datos o Lamports
Si tu programa necesita modificar los lamports o datos de una cuenta, el cliente debe marcar esa cuenta como writable (de escritura) en la transacción, y tu programa debe verificar que sea modificable. Si la cuenta no está marcada como writable, intentar modificarla hará que la transacción falle con un error (por ejemplo, “Readonly account changed”).
Para marcar la cuenta como writable en un cliente TypeScript, establecemos su bandera isWritable en true durante la construcción de la transacción.
// 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([]),
});
Nuestro programa puede verificar si una cuenta es writable usando el campo is_writable de la estructura AccountInfo.
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(())
}
En Anchor, esto se impone con #[account(mut)], lo que verifica is_writable = true antes de que se ejecute tu función.
Recargar el Estado de la Cuenta Después de Cada CPI
Una CPI puede modificar los datos de la cuenta. Tu programa debe leer la cuenta nuevamente después de una llamada CPI antes de tomar decisiones basadas en ella.
Esto es lo que puede suceder sin recargar (usando un código de programa en Rust nativo):
// 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(())
}
En este código, la CPI podría modificar el estado del vault (por ejemplo, establecer is_locked en true), pero seguimos usando el vault_state antiguo que se leyó antes de la CPI. Esto crea una vulnerabilidad de tiempo de comprobación a tiempo de uso (TOCTOU, por sus siglas en inglés), lo que significa que el estado que verificamos ya no es el estado sobre el que estamos actuando.
Para solucionar esto, recarga siempre los datos de la cuenta después de una CPI:
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(())
}
Quemar los Tokens Restantes Antes de Cerrar Cuentas de Tokens
Imagina que estás construyendo un protocolo de liquid staking. Los usuarios depositan SOL y reciben tokens de recibo (receipt tokens) que demuestran su participación (stake). Cuando retiran su participación (unstake), queman sus tokens de recibo para recuperar su SOL. Tu programa almacena los tokens de recibo de cada usuario en una cuenta de tokens PDA (propiedad de tu programa), y después de quemar los tokens de recibo, cierras la cuenta de tokens PDA para devolver la renta (rent) al usuario.
El problema surge cuando el programa asume que la cuenta de tokens PDA solo contiene los tokens de recibo legítimos del usuario. Un atacante puede explotar esto enviando solo 1 token de recibo del mismo mint (un “dust attack” o ataque de polvo) directamente a la cuenta de tokens PDA de la víctima antes de que realicen el unstake.
Cuando el usuario legítimo intenta hacer unstake, el programa quema solo la cantidad de tokens de recibo que el usuario recibió originalmente cuando hizo stake. Pero como el token de recibo de polvo del atacante todavía está en la cuenta, el balance no es cero. La operación de cierre de cuenta falla entonces porque el SPL Token Program requiere que el balance de una cuenta sea exactamente cero antes de cerrarse. Dado que nuestro programa no verifica ni quema ningún token restante antes de cerrar, el token de polvo del atacante permanece allí. Esto fallará cada vez, bloqueando permanentemente el SOL depositado por el usuario y causando una denegación de servicio (DoS).
Aquí hay un ejemplo:
// 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(())
}
En este código, el programa quema solo el amount que el usuario depositó originalmente. Si un atacante envió 1 token de polvo de antemano, permanece en la cuenta. La llamada a close_account falla porque el SPL Token Program requiere que el balance sea exactamente cero, bloqueando permanentemente el SOL depositado por el usuario.
Esta vulnerabilidad se aplica tanto a los programas nativos de Solana como a los programas de Anchor al cerrar cuentas de tokens SPL.
Para solucionar esto, obtén el balance real en la cadena (on-chain balance) y quema cualquier token restante:
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(())
}
Frontrunning en la creación de cuentas de tokens o contratos
La instrucción create_account del System Program falla si la cuenta de destino ya tiene lamports. Dado que las direcciones PDA son deterministas (derivadas de semillas conocidas), un atacante puede calcular la dirección antes de que se cree la cuenta y enviarle 1 lamport. Cuando el programa intenta posteriormente crear la cuenta en esa dirección, create_account falla porque la cuenta ya tiene un balance, lo que causa un DoS.
Por ejemplo, supongamos que tenemos un programa de vault donde cada usuario obtiene un vault PDA derivado de su clave pública:
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(())
}
El atacante simplemente deriva la misma dirección PDA (seeds = ["vault", victim_pubkey]), le envía 1 lamport a través de una transferencia normal de SOL, y la víctima nunca podrá inicializar su vault.
Para solucionar esto, verifica si la cuenta ya tiene lamports. Si es así, omite create_account y en su lugar usa transfer (para completar la renta), allocate (para reservar el espacio de datos de la cuenta) y assign (para establecer al propietario de la cuenta como tu programa) por separado; estas instrucciones no fallan en cuentas que ya tienen un balance:
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(())
}
Al verificar primero vault.lamports() > 0, manejamos ambos casos: la creación normal (sin lamports previos) y el escenario de frontrunning (el atacante envió lamports). Las instrucciones allocate y assign funcionan en cuentas que ya tienen un balance, por lo que el intento de sabotaje (griefing) del atacante no tiene efecto.
En Anchor, el uso de init_if_needed en lugar de init maneja este escenario.
Este artículo es parte de una serie de tutoriales sobre el desarrollo en Solana.