
Guía del autoestopista sobre la seguridad de programas de Solana
Tabla de contenido
- Introducción
- La mentalidad del atacante al explotar programas de Solana
- El modelo de programación de Solana
- Solana está bajo el control del atacante
- Posibles vectores de ataque
- Estrategias de mitigación
- Correspondencia de datos de cuentas
- La vulnerabilidad
- Escenario de ejemplo
- Mitigación recomendada
- Reasignación de datos de cuentas
- La vulnerabilidad
- Escenario de ejemplo
- Mitigación recomendada
- Recarga de cuentas
- La vulnerabilidad
- Escenario de ejemplo
- Mitigación recomendada
- CPI arbitraria
- La vulnerabilidad
- Escenario de ejemplo
- Mitigación recomendada
- Funcionalidad de transferencia de autoridad
- La vulnerabilidad
- Escenario de ejemplo
- Mitigación recomendada
- Canonicalización de la semilla bump
- La vulnerabilidad
- Escenario de ejemplo
- Mitigación recomendada
- Cierre de cuentas
- La vulnerabilidad
- Escenario de ejemplo
- Mitigación recomendada
- Cuentas mutables duplicadas
- La vulnerabilidad
- Escenario de ejemplo
- Mitigación recomendada
- Frontrunning
- La vulnerabilidad
- Escenario de ejemplo
- Mitigación recomendada
- Inicialización insegura
- Ejemplo inseguro y cómo mitigarlo
- Pérdida de precisión
- La vulnerabilidad
- Multiplicación después de la división
- Funciones aritméticas
- Errores de redondeo
- Falta de comprobación de propiedad
- La vulnerabilidad
- Escenario de ejemplo
- Mitigación recomendada
- Cuentas de solo lectura
- Falta de comprobación del firmante
- La vulnerabilidad
- Escenario de ejemplo
- Mitigación recomendada
- Desbordamiento y subdesbordamiento
- La vulnerabilidad
- Escenario de ejemplo
- Mitigación recomendada
- Conversión de tipos
- Uso compartido de PDA
- La vulnerabilidad
- Escenario de ejemplo
- Mitigación recomendada
- Cuentas restantes
- La vulnerabilidad
- Escenario de ejemplo
- Mitigación recomendada
- Errores específicos de Rust
- Rust inseguro
- Panics y gestión de errores
- Colisiones de semillas
- La vulnerabilidad
- Escenario de ejemplo
- Mitigación recomendada
- Suplantación de tipos
- La vulnerabilidad
- Escenario de ejemplo
- Mitigación recomendada
- Conclusión
- Recursos adicionales
Este artículo fue escrito en coautoría con bl0ckpain, investigador de seguridad y desarrollador de contratos inteligentes que anteriormente trabajó con Kudelski Security y Halborn
Introducción
La seguridad de los programas de Solana no consiste solo en evitar que los hackers roben los fondos de un proyecto. También garantiza que un programa se comporte según lo previsto y cumpla con las especificaciones del proyecto y las expectativas de los usuarios. La seguridad de los programas de Solana puede afectar el rendimiento, la escalabilidad y la interoperabilidad de una dApp. Por eso, los desarrolladores deben conocer los posibles vectores de ataque y las vulnerabilidades comunes antes de crear aplicaciones destinadas a usuarios finales.
Este artículo explora las vulnerabilidades comunes que los desarrolladores encontrarán al crear programas de Solana. Comenzamos con una introducción a la mentalidad de un atacante que busca explotar programas de Solana. Abordamos temas como el modelo de programación de Solana, la forma en que el diseño de Solana queda intrínsecamente bajo el control del atacante, los posibles vectores de ataque y las estrategias comunes de mitigación. Después, analizamos distintas vulnerabilidades y explicamos cada una con ejemplos de código inseguro y seguro cuando corresponde.
Ten en cuenta que este artículo está dirigido a una audiencia de nivel intermedio o avanzado, ya que presupone conocimientos sobre el modelo de programación de Solana y el desarrollo de programas.
Este artículo no explicará el proceso de crear un programa ni los conceptos específicos de Solana. Nos centraremos en examinar vulnerabilidades comunes y aprender a mitigarlas. Si Solana es algo nuevo para ti, te recomendamos leer estas publicaciones anteriores del blog antes de continuar con este artículo:
La mentalidad del atacante al explotar programas de Solana
El modelo de programación de Solana
El modelo de programación de Solana determina el panorama de seguridad de las aplicaciones creadas en su red. En Solana, las cuentas funcionan como contenedores de datos, de forma similar a los archivos de una computadora. Podemos dividirlas en dos tipos generales: ejecutables y no ejecutables. Las cuentas ejecutables, o programas, pueden ejecutar código. Las cuentas no ejecutables se utilizan para almacenar datos sin capacidad de ejecutar código, ya que no contienen ninguno. Esta separación entre código y datos significa que los programas no tienen estado. Interactúan con datos almacenados en otras cuentas, que se pasan por referencia durante las transacciones.
Solana está bajo el control del atacante
Una transacción especifica el programa que se llamará, una lista de cuentas y un arreglo de bytes con datos de instrucciones. Este modelo depende de que el programa analice e interprete las cuentas y las instrucciones proporcionadas por una transacción determinada. Permitir que cualquier cuenta se pase a la función de un programa da a los atacantes un control considerable sobre los datos con los que operará. Comprender que el modelo de programación de Solana está intrínsecamente bajo el control del atacante es esencial para desarrollar programas seguros.
Dado que un atacante puede pasar cualquier cuenta a la función de un programa, la validación de datos se convierte en un pilar fundamental de la seguridad de los programas de Solana. Los desarrolladores deben garantizar que su programa distinga entre entradas legítimas y maliciosas. Esto incluye verificar la propiedad de las cuentas, confirmar que sean del tipo esperado y comprobar si una cuenta es firmante.
Posibles vectores de ataque
El modelo de programación y el entorno de ejecución únicos de Solana generan vectores de ataque específicos. Comprender estos vectores es esencial para que los desarrolladores protejan sus programas frente a posibles exploits. Estos vectores de ataque incluyen:
- Errores de lógica: las fallas en la lógica del programa podrían manipularse para provocar comportamientos no deseados, como la pérdida de activos o el acceso no autorizado. Esto también incluye no implementar correctamente las especificaciones del proyecto: si un programa afirma hacer x, debe hacer x con todas sus particularidades
- Fallas en la validación de datos: una validación inadecuada de los datos de entrada puede permitir que los atacantes introduzcan datos maliciosos y manipulen el estado o la ejecución del programa
- Problemas específicos de Rust: a pesar de las funciones de seguridad de Rust, los bloques de código inseguro, los problemas de concurrencia y los panics pueden introducir vulnerabilidades
- Vulnerabilidades de control de acceso: no implementar correctamente las comprobaciones de control de acceso, como verificar al propietario de una cuenta, puede permitir que un actor malicioso realice acciones no autorizadas
- Errores aritméticos y de precisión: los desbordamientos, subdesbordamientos y errores de precisión pueden explotarse para obtener beneficios económicos o provocar fallas en un programa
- Problemas de invocación entre programas (CPI): las fallas al gestionar CPI pueden provocar cambios de estado o errores inesperados si un programa invocado se comporta de forma maliciosa o imprevista
- Uso incorrecto de direcciones derivadas de programas (PDA): generar o gestionar PDA de forma incorrecta puede provocar vulnerabilidades que permitan a los atacantes secuestrar o suplantar PDA para obtener acceso no autorizado o manipular cuentas controladas por el programa
Ten en cuenta que la reentrada está intrínsecamente limitada en Solana debido a su modelo de ejecución. El entorno de ejecución de Solana restringe las CPI a una profundidad máxima de cuatro y aplica reglas estrictas a las cuentas, como permitir que solo el propietario de una cuenta modifique sus datos. Estas restricciones evitan los ataques de reentrada al limitar la recursión directa y garantizar que un programa no pueda invocarse involuntariamente en un estado intermedio.
Estrategias de mitigación
Para mitigar estos posibles ataques, los desarrolladores deben combinar pruebas rigurosas, auditorías de código y el cumplimiento de las prácticas recomendadas:
- Implementa una validación exhaustiva de las entradas y comprobaciones de control de acceso
- Aprovecha al máximo el sistema de tipos y las funciones de seguridad de Rust, y evita el código inseguro salvo que sea necesario
- Sigue las prácticas recomendadas de seguridad de Solana y Rust, y mantente al día con las novedades
- Realiza revisiones internas del código y utiliza herramientas automatizadas para identificar vulnerabilidades comunes y errores de lógica durante el desarrollo del programa
- Solicita que terceros de confianza auditen tu código base, incluidas empresas de seguridad e investigadores de seguridad independientes
- Crea una plataforma de recompensas por errores para tu programa que incentive el reporte de vulnerabilidades, en lugar de depender de hackers de sombrero gris
Las siguientes secciones explorarán distintas vulnerabilidades en orden alfabético. Cada sección describirá una posible vulnerabilidad, explicará cómo mitigarla y proporcionará escenarios de ejemplo siempre que sea posible.
Correspondencia de datos de cuentas
La vulnerabilidad
La correspondencia de datos de cuentas es una vulnerabilidad que surge cuando los desarrolladores no comprueban que los datos almacenados en una cuenta coincidan con un conjunto de valores esperado. Sin las comprobaciones adecuadas de validación de datos, un programa puede operar accidentalmente con cuentas incorrectas o sustituidas de forma maliciosa. Esta vulnerabilidad es especialmente grave en situaciones que implican comprobaciones de permisos.
Escenario de ejemplo
Considera un programa con funciones para gestionar su configuración administrativa. El programa incluye una instrucción para actualizar las configuraciones administrativas actuales, como indicadores de funcionalidades o parámetros operativos. La instrucción debe validar que la solicitud provenga de un administrador autorizado. Sin embargo, el programa no verifica que la cuenta que solicita el cambio coincida con la cuenta del administrador almacenada en los datos de configuración:
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
}Mitigación recomendada
Para mitigar esta vulnerabilidad, los desarrolladores pueden implementar comprobaciones explícitas que comparen las claves de las cuentas y los datos almacenados con los valores esperados. Por ejemplo, verifica que la clave pública del depositante coincida con el campo de propietario de la cuenta de tokens utilizada para el depósito:
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(())
}Los desarrolladores también pueden usar los atributos has_one y constraint de Anchor para aplicar comprobaciones de validación de datos de forma declarativa. En el ejemplo anterior, podríamos usar el atributo constraint para comprobar que la clave pública del depositante y el propietario de la cuenta de tokens del depósito sean equivalentes:
pub struct UpdateAdminSettings<'info> {
#[account(
mut,
constraint = config_data.admin == admin.key()
)]
pub config_data: Account<'info, ConfigData>,
pub admin: Signer<'info>,
}Reasignación de datos de cuentas
La vulnerabilidad
En Anchor, la función realloc proporcionada por la estructura AccountInfo introduce una vulnerabilidad sutil relacionada con la gestión de memoria. Esta función permite reasignar el tamaño de los datos de una cuenta, lo que puede ser útil para gestionar datos dinámicos dentro de los programas. Sin embargo, el uso incorrecto de realloc puede producir consecuencias no deseadas, como desperdiciar unidades de cómputo o exponer datos obsoletos.
El método realloc tiene dos parámetros:
- new_len: un usize que especifica la nueva longitud de los datos de la cuenta
- zero_init: un bool que determina si el nuevo espacio de memoria debe inicializarse con ceros
realloc se define de la siguiente manera:
pub fn realloc(
&self,
new_len: usize,
zero_init: bool
) -> Result<(), ProgramError>La memoria asignada a los datos de una cuenta ya se inicializa con ceros en el punto de entrada del programa. Esto significa que el nuevo espacio de memoria ya contiene ceros cuando los datos se reasignan a un tamaño mayor dentro de una misma transacción. Volver a llenarlo con ceros es innecesario y consume unidades de cómputo adicionales. Por el contrario, reasignar a un tamaño menor y luego volver a uno mayor dentro de la misma transacción podría exponer datos obsoletos si zero_init es false.
Escenario de ejemplo
Considera un programa dinámico de lista de tareas en el que los usuarios pueden agregar, eliminar o modificar entradas dentro de una misma transacción. Este programa debe reasignar dinámicamente el tamaño de sus datos según las acciones del usuario:
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
}En este escenario, la función modify_todo_list podría reasignar to_do_list_data varias veces para adaptarse al tamaño que requieren las modificaciones. Si el tamaño de los datos se reduce para eliminar una tarea y luego vuelve a aumentar para agregar nuevas entradas dentro de la misma transacción, establecer zero_init en false podría exponer datos obsoletos.
Mitigación recomendada
Para mitigar este problema, es fundamental usar con prudencia el parámetro zero_init:
- Establece
zero_initentrueal aumentar el tamaño de los datos después de haberlo reducido en la misma llamada de transacción. Esto garantiza que todo espacio de memoria nuevo se inicialice con ceros y evita exponer datos obsoletos - Establece
zero_initenfalseal aumentar el tamaño de los datos sin haberlo reducido previamente en la misma llamada de transacción, ya que la memoria ya estará inicializada con ceros
En lugar de reasignar datos para cumplir requisitos de tamaño específicos, los desarrolladores deberían usar tablas de consulta de direcciones (ALT). Las ALT permiten comprimir los datos de una transacción al almacenar hasta 256 direcciones en una única cuenta on-chain. Después, cada dirección de la tabla puede referenciarse mediante un índice de 1 byte, lo que reduce considerablemente los datos necesarios para las referencias a direcciones en una transacción determinada. Las ALT son mucho más útiles en situaciones que requieren interacciones dinámicas entre cuentas sin redimensionar la memoria con frecuencia.
Recarga de cuentas
La vulnerabilidad
La recarga de cuentas es una vulnerabilidad que surge cuando los desarrolladores no actualizan las cuentas deserializadas después de realizar una CPI. Anchor no actualiza automáticamente el estado de las cuentas deserializadas después de una CPI. Esto puede causar que la lógica del programa opere con datos obsoletos y produzca errores lógicos o cálculos incorrectos.
Escenario de ejemplo
Considera un protocolo en el que los usuarios pueden hacer staking de tokens para obtener recompensas con el tiempo. El programa que lo facilita incluye funciones para actualizar las recompensas de staking de un usuario según determinadas condiciones o activadores externos. Las recompensas del usuario se calculan y actualizan mediante una CPI a un programa de distribución de recompensas. Sin embargo, el programa no actualiza la cuenta de staking original después de la CPI para reflejar el nuevo saldo de recompensas:
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,
}En este ejemplo, la función update_rewards intenta actualizar las recompensas de la cuenta de staking de un usuario mediante una llamada CPI a un programa de distribución de recompensas. Inicialmente, el programa registra ctx.accounts.staking_account.rewards, es decir, el saldo de recompensas, después de la CPI. Luego continúa con una lógica que utiliza los datos obsoletos de ctx.accounts.staking_account.rewards. El problema es que el estado de la cuenta de staking no se actualiza automáticamente después de la CPI, por lo que los datos quedan obsoletos.
Mitigación recomendada
Para mitigar este problema, llama explícitamente al método reload de Anchor para volver a cargar una cuenta determinada desde el almacenamiento. Recargar una cuenta después de una CPI reflejará con precisión su estado:
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 arbitraria
La vulnerabilidad
Las CPI arbitrarias ocurren cuando un programa invoca otro sin verificar la identidad del programa de destino. Esta vulnerabilidad existe porque el entorno de ejecución de Solana permite que cualquier programa llame a otro si el programa invocador tiene el ID del programa invocado y respeta su interfaz. Si un programa realiza CPI según entradas del usuario sin validar el ID del programa invocado, podría ejecutar código en un programa controlado por un atacante.
Escenario de ejemplo
Considera un programa que distribuye premios a los participantes según sus contribuciones a un proyecto. Después de distribuir las recompensas, el programa registra los detalles en un programa de libro mayor separado para fines de auditoría y seguimiento. Se asume que el programa de libro mayor es confiable y proporciona una interfaz pública para registrar entradas específicas de programas autorizados. El programa incluye una función para distribuir y registrar recompensas que recibe el programa de libro mayor como una cuenta. Sin embargo, la función no verifica el ledger_program proporcionado antes de realizar una CPI hacia él:
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>,
}Un atacante podría explotar esta vulnerabilidad pasando el ID de un programa malicioso como ledger_program, lo que produciría consecuencias no deseadas.
Mitigación recomendada
Para protegerse contra este problema, los desarrolladores pueden agregar una comprobación que verifique la identidad del programa de libro mayor antes de realizar la CPI. Esta comprobación garantizaría que la llamada CPI se haga al programa previsto y evitaría CPI arbitrarias:
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>,
}Un programa puede tener un módulo CPI disponible públicamente si se escribió con Anchor. Esto permite invocarlo desde otro programa de Anchor de forma sencilla y segura. El módulo CPI de Anchor comprueba automáticamente que la dirección proporcionada del programa coincida con la dirección almacenada en el módulo. Como alternativa, se puede codificar la dirección de forma fija en lugar de pedir al usuario que la proporcione.
Funcionalidad de transferencia de autoridad
La vulnerabilidad
Los programas de Solana suelen designar claves públicas específicas como autoridades para funciones críticas, como actualizar parámetros del programa o retirar fondos. Sin embargo, la imposibilidad de transferir esta autoridad a otra dirección puede generar riesgos importantes. Esta limitación resulta problemática ante cambios en el equipo, ventas del protocolo o si la autoridad se ve comprometida.
Escenario de ejemplo
Considera un programa en el que una autoridad administradora global se encarga de establecer parámetros específicos del protocolo mediante una función set_params. El programa no incluye un mecanismo para cambiar al administrador global:
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
}Aquí, la autoridad se define de forma estática, sin posibilidad de actualizarla con una nueva dirección.
Mitigación recomendada
Una forma segura de mitigar este problema es crear un proceso de dos pasos para transferir la autoridad. Este proceso permitiría que la autoridad actual nomine una nueva pending_authority, que deberá aceptar explícitamente la función. Esto no solo permitiría transferir la autoridad, sino que también protegería contra transferencias accidentales o apropiaciones maliciosas. El flujo sería el siguiente:
- Nominación por la autoridad actual: la autoridad actual nominaría una nueva pending_authority mediante una llamada a nominate_new_authority, que establece el campo pending_authority en el estado del programa
- Aceptación por la nueva autoridad: la pending_authority nominada llama a accept_authority para asumir su nueva función y transferir la autoridad actual a pending_authority
Se vería de esta forma:
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
}En este ejemplo, la estructura de cuenta ProgramState contiene la authority actual y una pending_authority opcional. El contexto NominateAuthority garantiza que la autoridad actual firme la transacción, lo que le permite nominar una nueva autoridad. El contexto AcceptAuthority comprueba que pending_authority coincida con el firmante de la transacción, lo que le permite aceptar y convertirse en la nueva autoridad. Esta configuración garantiza una transición de autoridad segura y controlada dentro del programa.
Canonicalización de la semilla bump
La vulnerabilidad
La canonicalización de la semilla bump consiste en usar la semilla bump válida más alta, es decir, el bump canónico, al derivar PDA. Usar el bump canónico es una forma determinista y segura de encontrar una dirección a partir de un conjunto de semillas. No usarlo puede provocar vulnerabilidades, como permitir que actores maliciosos creen o manipulen PDA que comprometan la lógica del programa o la integridad de los datos.
Escenario de ejemplo
Considera un programa diseñado para crear perfiles de usuario únicos, cada uno con una PDA asociada derivada explícitamente mediante create_program_address. El programa permite crear un perfil utilizando un bump proporcionado por el usuario. Sin embargo, esto resulta problemático porque introduce el riesgo de usar un bump no canónico:
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>,
}En este escenario, el programa deriva una PDA UserProfile mediante create_program_address con semillas que incluyen un bump proporcionado por el usuario. Usar un bump proporcionado por el usuario es problemático porque no garantiza el uso del bump canónico. Esto permitiría que un actor malicioso creara varias PDA con distintos bumps para el mismo ID de usuario.
Mitigación recomendada
Para mitigar este problema, podemos refactorizar nuestro ejemplo para derivar PDA mediante find_program_address y validar explícitamente la semilla bump:
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,
}Aquí se usa find_program_address para derivar la PDA con la semilla bump canónica y garantizar una creación de PDA determinista y segura. El bump canónico se almacena en la cuenta UserProfile, lo que permite una validación eficiente y segura en operaciones posteriores. Preferimos find_program_address en lugar de create_program_address porque este último crea una PDA válida sin buscar una semilla bump. Como no busca una semilla bump, puede devolver un error de forma impredecible para cualquier conjunto de semillas y, en general, no es adecuado para crear PDA. find_program_address usará siempre el bump canónico al crear una PDA. Esto se debe a que itera sobre varias llamadas a create_program_address, comenzando con un bump de 255 y reduciéndolo en cada iteración. Cuando encuentra una dirección válida, la función devuelve la PDA derivada y el bump canónico utilizado para derivarla.
Anchor exige el bump canónico para derivar PDA mediante sus restricciones seeds y bump, lo que simplifica todo el proceso y garantiza una creación y validación de PDA segura y determinista.
Cierre de cuentas
La vulnerabilidad
Cerrar incorrectamente las cuentas de un programa puede generar varias vulnerabilidades, incluida la posibilidad de que las cuentas "cerradas" se reinicialicen o se usen indebidamente. El problema surge cuando una cuenta no se marca correctamente como cerrada o no se impide su reutilización en transacciones posteriores. Este descuido puede permitir que actores maliciosos exploten una cuenta determinada y realicen acciones u obtengan acceso no autorizados dentro del programa.
Escenario de ejemplo
Considera un programa que permite a los usuarios crear y cerrar cuentas de almacenamiento de datos. El programa cierra una cuenta transfiriendo sus 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,
}Esto es problemático porque el programa no borra los datos de la cuenta ni la marca como cerrada. Transferir únicamente los lamports restantes no cierra la cuenta.
Mitigación recomendada
Para mitigar este problema, el programa no solo debe transferir todos los lamports, sino también borrar los datos de la cuenta y marcarla con un discriminador (es decir, "CLOSED_ACCOUNT_DISCRIMINATOR"). El programa también debe implementar comprobaciones para impedir que las cuentas cerradas se reutilicen en transacciones futuras:
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,
}Sin embargo, borrar los datos y agregar el discriminador de cierre no es suficiente. Un usuario puede impedir que una cuenta sea eliminada por el recolector de basura si repone sus lamports antes de que termine una instrucción. Esto dejará la cuenta en un extraño estado intermedio en el que no se puede usar ni eliminar. Por eso, agregamos una función force_defund para abordar este caso límite; ahora cualquiera puede retirar los fondos de las cuentas cerradas.
Anchor simplifica este proceso con la restricción #[account(close = destination)], que automatiza el cierre seguro de cuentas al transferir los lamports, borrar los datos y establecer el discriminador de cuenta cerrada, todo en una sola operación.
Cuentas mutables duplicadas
La vulnerabilidad
Las cuentas mutables duplicadas se refieren a un escenario en el que la misma cuenta se pasa más de una vez como parámetro mutable a una instrucción. Esto ocurre cuando una instrucción requiere dos cuentas mutables del mismo tipo. Un actor malicioso podría pasar la misma cuenta dos veces y provocar que se modifique de formas no previstas (por ejemplo, sobrescribiendo datos). La gravedad de esta vulnerabilidad varía según el escenario específico.
Escenario de ejemplo
Considera un programa diseñado para recompensar a los usuarios según su participación en cierta actividad on-chain. El programa tiene una instrucción para actualizar el saldo de dos cuentas: una cuenta de recompensa y una cuenta de bonificación. Un usuario debe recibir una recompensa estándar en una cuenta y una posible bonificación en otra según criterios específicos predeterminados:
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,
}Si un actor malicioso pasa la misma cuenta como reward_account y bonus_account, el saldo de la cuenta se actualizará incorrectamente dos veces.
Mitigación recomendada
Para mitigar este problema, agrega una comprobación a la lógica de la instrucción para verificar que las claves públicas de las dos cuentas no sean idénticas:
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(())
}Los desarrolladores pueden usar las restricciones de cuentas de Anchor para agregar una comprobación más explícita sobre la cuenta. Esto puede hacerse con el atributo #[account] y la palabra clave 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,
}Frontrunning
La vulnerabilidad
Con la creciente popularidad de los agrupadores de transacciones, el frontrunning es un riesgo que los protocolos creados en Solana deben tomar en serio. Tras la eliminación del mempool de Jito, aquí usamos frontrunning para referirnos a la capacidad de un actor malicioso de manipular los valores esperados frente a los reales mediante transacciones cuidadosamente construidas.
Escenario de ejemplo
Imagina un protocolo que gestiona la compra y las ofertas de un producto y almacena la información de precios del vendedor en una cuenta llamada 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,
}Para comprar un Product publicado, el comprador debe pasar la cuenta ProductListing relacionada con el producto que desea. Pero ¿qué ocurre si el vendedor puede cambiar el sale_price de su publicación?
pub fn change_sale_price(ctx: Context<ChangeSalePrice>, new_price: u64) -> Result<()> {...}Esto generaría una oportunidad de frontrunning para el vendedor, especialmente si la transacción de compra no incluye comprobaciones expected_price que garanticen que el comprador no pague por el producto más de lo esperado. Si el comprador envía una transacción para adquirir el Product determinado, el vendedor podría llamar a change_sale_price y, mediante Jito, garantizar que esta transacción se incluya antes que la del comprador. Sin que el comprador lo sepa, un vendedor malicioso podría cambiar el precio de la cuenta ProductListing a una cantidad exorbitante y obligarlo a pagar mucho más de lo esperado por Product!
Mitigación recomendada
Una solución sencilla sería incluir comprobaciones de expected_price en el lado del comprador para evitar que pague más de lo esperado por el Product que desea comprar:
pub fn purchase_product(ctx: Context<PurchaseProduct>, expected_price: u64) -> Result<()> {
assert!(ctx.accounts.product_listing.sale_price <= expected_price);
...
}Inicialización insegura
A diferencia de los contratos desplegados en la EVM, los programas de Solana no se despliegan con un constructor que establezca las variables de estado. En su lugar, se inicializan manualmente (por lo general, mediante una función llamada initialize o algo similar). Las funciones de inicialización suelen establecer datos como la autoridad del programa o crear cuentas que forman la base del programa desplegado (es decir, una cuenta de estado central o algo similar).
Como la función de inicialización se llama manualmente y no de forma automática al desplegar el programa, esta instrucción debe invocarse desde una dirección conocida bajo el control del equipo de desarrollo del programa. De lo contrario, un atacante podría adelantarse a la inicialización mediante frontrunning y configurar el programa con cuentas bajo su control.
Una práctica común es usar la upgrade_authority del programa como dirección autorizada para llamar a la función initialize, si el programa tiene una autoridad de actualización.
Ejemplo inseguro y cómo mitigarlo
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,
...
}El ejemplo anterior es una función initialize simplificada que establece como autoridad de una cuenta CentralState a quien llama la instrucción. Sin embargo, ¡podría ser cualquier cuenta que llame a initialize! Como se mencionó antes, una forma común de proteger una función de inicialización es usar la upgrade_authority del programa, conocida al momento del despliegue.
A continuación se muestra un ejemplo de la documentación de Anchor, que usa constraint para garantizar que solo la autoridad de actualización del programa pueda llamar a 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>,
}Pérdida de precisión
La vulnerabilidad
La pérdida de precisión, aunque parezca mínima, puede representar una amenaza importante para un programa. Puede provocar cálculos incorrectos, oportunidades de arbitraje y comportamientos inesperados del programa.
La pérdida de precisión en las operaciones aritméticas es una fuente común de errores. En los programas de Solana se recomienda usar aritmética de punto fijo siempre que sea posible. Esto se debe a que los programas solo admiten un subconjunto limitado de las operaciones de punto flotante de Rust. Si un programa intenta usar una operación de punto flotante no admitida, el entorno de ejecución devolverá un error de símbolo sin resolver. Además, las operaciones de punto flotante requieren más instrucciones que sus equivalentes con enteros. El uso de aritmética de punto fijo y la necesidad de manejar con precisión grandes cantidades de tokens y valores fraccionarios pueden agravar la pérdida de precisión.
Multiplicación después de la división
Aunque la propiedad asociativa se cumple para la mayoría de las operaciones matemáticas, aplicarla en la aritmética computacional puede causar una pérdida de precisión inesperada. Un ejemplo clásico ocurre al multiplicar después de dividir, lo que puede producir resultados diferentes a los de multiplicar antes de dividir. Por ejemplo, considera las siguientes expresiones: (a / c) * b y (a * b) / c. Matemáticamente, estas expresiones son asociativas y deberían producir el mismo resultado. Sin embargo, en el contexto de Solana y la aritmética de punto fijo, el orden de las operaciones es muy importante. Dividir primero (a / c) puede provocar una pérdida de precisión si el cociente se redondea hacia abajo antes de multiplicarlo por b. Esto podría generar un resultado menor de lo esperado. En cambio, multiplicar (a * b) antes de dividir entre c podría conservar más precisión original. Esta diferencia puede generar cálculos incorrectos, comportamientos inesperados del programa u oportunidades de arbitraje.
Funciones aritméticas saturating_*
Aunque las funciones aritméticas saturating_* evitan el desbordamiento superior e inferior al limitar los valores a sus máximos o mínimos posibles, pueden causar errores sutiles y pérdida de precisión si se alcanza ese límite de forma inesperada. Esto ocurre cuando la lógica del programa supone que la saturación por sí sola garantizará un resultado preciso e ignora la posible pérdida de precisión o exactitud.
Por ejemplo, imagina un programa diseñado para calcular y distribuir recompensas a los usuarios según la cantidad de tokens que intercambian durante un período específico:
pub fn calculate_reward(transaction_amount: u64, reward_multiplier: u64) -> u64 {
transaction_amount.saturating_mul(reward_multiplier)
}Considera un escenario donde transaction_amount equivale a 100,000 tokens e reward_multiplier equivale a 100 tokens por transacción. Multiplicar ambos valores superará el valor máximo que puede contener un u64. Esto significa que su producto quedará limitado, lo que causará una pérdida considerable de precisión y otorgará al usuario una recompensa inferior a la debida.
Errores de redondeo
Las operaciones de redondeo son una causa común de pérdida de precisión en programación. La elección del método de redondeo puede afectar de manera importante la exactitud de los cálculos y el comportamiento de los programas de Solana. La función try_round_u64() redondea los valores decimales al número entero más cercano. Redondear hacia arriba es problemático porque puede inflar artificialmente los valores y generar discrepancias entre los cálculos reales y los esperados.
Considera un programa de Solana que convierte garantías en liquidez según las condiciones del mercado. El programa usa try_round_u64() para redondear el resultado de una operación de división:
pub fn collateral_to_liquidity(&self, collateral_amount: u64) -> Result<u64, ProgramError> {
Decimal::from(collateral_amount)
.try_div(self.0)?
.try_round_u64()
}En este escenario, redondear hacia arriba puede provocar la emisión de más tokens de liquidez de los que justifica el valor de la garantía. Los actores maliciosos pueden aprovechar esta discrepancia para realizar ataques de arbitraje y extraer valor del protocolo mediante resultados de redondeo manipulados a su favor. Para mitigar el problema, usa try_floor_u64 y redondea hacia abajo al entero más cercano. Este enfoque minimiza el riesgo de inflar artificialmente los valores y garantiza que el redondeo no beneficie al usuario a costa del sistema. Como alternativa, implementa una lógica que maneje explícitamente los escenarios en los que el redondeo pueda afectar el resultado. Esto podría incluir establecer umbrales específicos para las decisiones de redondeo o aplicar una lógica diferente según la magnitud de los valores involucrados.
Falta de comprobación de propiedad
La vulnerabilidad
Las comprobaciones de propiedad son fundamentales para validar que el programa esperado sea propietario de una cuenta involucrada en una transacción u operación. Las cuentas incluyen un campo owner, que indica el programa con autoridad para escribir en los datos de la cuenta. Este campo garantiza que solo los programas autorizados puedan modificar el estado de una cuenta. Además, sirve para garantizar que las cuentas pasadas a una instrucción pertenezcan al programa esperado. Omitir las comprobaciones de propiedad puede generar vulnerabilidades graves, como transferencias de fondos no autorizadas y ejecución de operaciones privilegiadas.
Escenario de ejemplo
Considera una función de un programa definida para permitir retiros de una bóveda exclusivamente a los administradores. La función recibe una cuenta de configuración (es decir, config) y usa su campo admin para comprobar si la clave pública de la cuenta de administrador proporcionada coincide con la almacenada en la cuenta config. Sin embargo, no verifica la propiedad de la cuenta config porque supone que es confiable:
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
}Un actor malicioso podría explotar esta vulnerabilidad proporcionando una cuenta config bajo su control con un campo admin coincidente, lo que engañaría al programa para que ejecute el retiro.
Mitigación recomendada
Para mitigar este problema, realiza una comprobación de propiedad que verifique el campo owner de la cuenta:
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 simplifica esta comprobación con el tipo Account. Account<'info, T> es un contenedor de AccountInfo que verifica la propiedad del programa y deserializa los datos subyacentes en T (es decir, el tipo de cuenta especificado). Esto permite a los desarrolladores usar Account<'info, T> para validar fácilmente la propiedad de una cuenta. También pueden usar el atributo #[account] para agregar el trait Owner a una cuenta determinada. Este trait define la dirección que se espera que sea propietaria de la cuenta. Además, los desarrolladores pueden usar la restricción owner para definir el programa que debe poseer una cuenta determinada si es diferente del que se está ejecutando. Esto resulta útil, por ejemplo, al escribir una instrucción que espera que una cuenta sea una PDA derivada de otro programa. La restricción owner se define como #[account(owner = <expr>)], donde <expr> es una expresión arbitraria.
Cuentas de solo lectura
Es igual de importante verificar la validez de las cuentas especificadas como de solo lectura dentro del contexto de ejecución de un programa. Esto es fundamental porque un actor malicioso podría pasar cuentas con datos arbitrarios o manipulados en lugar de cuentas legítimas. Esto podría provocar un comportamiento inesperado o dañino del programa. Los desarrolladores deben realizar comprobaciones para garantizar que las cuentas de las que el programa necesita leer sean auténticas y no hayan sido manipuladas. Esto puede implicar verificar la dirección de la cuenta comparándola con valores conocidos o confirmar que su propietario sea el esperado, especialmente en el caso de las sysvars (es decir, cuentas del sistema de solo lectura, como Clock o EpochSchedule). Accede a las sysvars mediante el método get(), que no requiere comprobaciones manuales de dirección ni propiedad. Este enfoque es más seguro para acceder a estas cuentas; sin embargo, no todas las sysvars admiten el método get(). En ese caso, accede a ellas mediante su dirección pública.
Falta de comprobación del firmante
La vulnerabilidad
Las transacciones se firman con la clave privada de una billetera para garantizar la autenticación, la integridad, el no repudio y la autorización de una transacción específica por parte de una billetera determinada. Al exigir que las transacciones se firmen con la clave privada del remitente, el entorno de ejecución de Solana puede verificar que la cuenta correcta inicie una transacción y que esta no haya sido manipulada. Este mecanismo sustenta la naturaleza sin confianza de las redes descentralizadas. Sin esta verificación, cualquier cuenta que proporcione la cuenta correcta como argumento puede ejecutar una transacción. Esto podría provocar acceso no autorizado a información, fondos o funcionalidades privilegiadas. Esta vulnerabilidad surge cuando no se valida si una operación fue firmada con la clave privada de la cuenta correspondiente antes de ejecutar ciertas funcionalidades privilegiadas.
Escenario de ejemplo
Considera la siguiente función:
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(())
}Esta función pretende actualizar el administrador del programa. Incluye una comprobación para garantizar que el administrador actual inicie la operación, lo cual constituye un buen control de acceso. Sin embargo, la función no verifica que la clave privada del administrador actual haya firmado la transacción. Por lo tanto, cualquiera que llame a esta función puede pasar la cuenta admin correcta de modo que admin.pubkey() = config.admin, independientemente de que la cuenta que llama a la función sea realmente el administrador actual. Esto permite que un actor malicioso ejecute la instrucción pasando su cuenta como nuevo administrador, lo que elude directamente la necesidad de autorización del administrador actual.
Mitigación recomendada
Los programas deben incluir comprobaciones para verificar que una cuenta haya sido firmada por la billetera correspondiente. Esto puede hacerse comprobando el campo AccountInfo::is_signer de las cuentas involucradas en la transacción. El programa puede garantizar que solo las cuentas autorizadas realicen determinadas acciones al comprobar si la cuenta que ejecuta la operación privilegiada tiene el indicador is_signer establecido en true.
El ejemplo de código actualizado se vería así:
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 simplifica todo este proceso con el tipo de cuenta Signer<’info>.
Desbordamiento y subdesbordamiento
La vulnerabilidad
Un entero es un número sin componente fraccionario. Rust almacena los enteros como variables de tamaño fijo. Estas variables se definen por su signo (es decir, con o sin signo) y por la cantidad de espacio que ocupan en la memoria. Por ejemplo, el tipo u8 representa un entero sin signo que ocupa 8 bits. Puede contener valores de 0 a 255. Almacenar un valor fuera de ese rango provocaría un desbordamiento o subdesbordamiento de enteros. Un desbordamiento de enteros ocurre cuando una variable supera su capacidad máxima y vuelve a su valor mínimo. Un subdesbordamiento de enteros ocurre cuando una variable cae por debajo de su capacidad mínima y vuelve a su valor máximo.
Rust incluye comprobaciones de desbordamiento y subdesbordamiento de enteros al compilar en modo de depuración. Estas comprobaciones harán que el programa entre en estado de panic durante la ejecución si se detecta una condición de este tipo. Sin embargo, Rust no incluye comprobaciones que provoquen un estado de panic por desbordamiento o subdesbordamiento de enteros cuando se compila en modo de lanzamiento con la marca --release. Este comportamiento puede introducir vulnerabilidades sutiles, ya que el desbordamiento o subdesbordamiento ocurre de forma silenciosa. La cadena de herramientas Berkley Packet Filter (BPF) es esencial para el entorno de desarrollo de Solana, ya que compila sus programas. El comando cargo build-bpf compila proyectos de Rust en bytecode BPF para su despliegue. El problema es que compila los programas en modo de lanzamiento de forma predeterminada. Por lo tanto, los programas de Solana son vulnerables a desbordamientos y subdesbordamientos de enteros.
Escenario de ejemplo
Un atacante puede aprovechar esta vulnerabilidad mediante el comportamiento silencioso de desbordamiento o subdesbordamiento en el modo de lanzamiento, especialmente en funciones que administran saldos de tokens. Considera el siguiente ejemplo:
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(())
}Para simplificar, esta función supone que el saldo está almacenado en el primer byte. Toma el saldo de la cuenta y le resta tokens_to_subtract. Si el saldo del usuario es menor que tokens_to_subtract, se producirá un subdesbordamiento. Por ejemplo, el saldo de un usuario con 10 tokens sufriría un subdesbordamiento y pasaría a un total de 165 tokens
Mitigación recomendada
overflow-checks
La forma más sencilla de mitigar esta vulnerabilidad es establecer la clave overflow-checks en true dentro del archivo Cargo.toml del proyecto. De este modo, Rust agregará al compilador comprobaciones de desbordamiento y subdesbordamiento. Sin embargo, agregar estas comprobaciones aumenta el costo de cómputo de una transacción. Cuando sea necesario optimizar el cómputo, puede ser más conveniente establecer overflow-checks en false.
Aritmética checked_*
Usa las funciones aritméticas checked_* de Rust en cada tipo de entero para comprobar estratégicamente los desbordamientos y subdesbordamientos en todo el programa. Estas funciones devolverán None si se produce un desbordamiento o subdesbordamiento. Esto permite que el programa gestione el error correctamente. Por ejemplo, podrías refactorizar el código anterior de la siguiente manera:
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(())
}En el ejemplo revisado, se usa checked_sub para restar tokens_to_subtract de balance. Por lo tanto, si balance es suficiente para cubrir la resta, checked_sub devolverá Some(new_balance). El programa continúa actualizando de forma segura el saldo de la cuenta y lo registra. Sin embargo, si la resta produjera un subdesbordamiento, checked_sub devolvería None, lo que podemos gestionar devolviendo un error.
Macro Checked Math
Checked Math es una macro de procedimiento que permite cambiar las propiedades de comprobación de expresiones matemáticas sin modificar, en general, dichas expresiones. El problema de las funciones aritméticas checked_* es que se pierde la notación matemática. En su lugar, deben usarse métodos engorrosos como a.checked_add(b).unwrap() en vez de a + b. Por ejemplo, si queremos escribir (x * y) + z con las funciones aritméticas comprobadas, escribiríamos x.checked_mul(y).unwrap().checked_add(z).unwrap().
En cambio, la siguiente expresión se vería así con la macro Checked Math:
use checked_math::checked_math as cm;
cm!((x * y) + z).unwrap()Esta forma es más fácil de escribir, conserva la notación matemática de la expresión y solo requiere un .unwrap(). Esto se debe a que la macro convierte las expresiones matemáticas normales en una expresión que devuelve None si cualquiera de los pasos comprobados devuelve None. Si se ejecuta correctamente, se devuelve Some(_), por lo que desenvolvemos la expresión al final.
Conversión de tipos
Del mismo modo, convertir entre tipos de enteros con la palabra clave as sin las comprobaciones adecuadas puede introducir una vulnerabilidad de desbordamiento o subdesbordamiento de enteros. Esto se debe a que la conversión puede truncar o ampliar valores de formas imprevistas. Al convertir un tipo de entero más grande en uno más pequeño (por ejemplo, de u64 a u32), Rust truncará los bits superiores del valor original que no quepan en el tipo de destino. Esto resulta problemático cuando el valor original supera el valor máximo que puede almacenar el tipo de destino. Al convertir un tipo de entero más pequeño en uno más grande (por ejemplo, de i16 a i32), Rust ampliará el valor. Esto es sencillo para los tipos sin signo. Sin embargo, con enteros con signo puede producir una extensión de signo e introducir valores negativos imprevistos.
Mitigación recomendada
Usa los métodos seguros de conversión de tipos de Rust para mitigar esta vulnerabilidad. Esto incluye métodos como try_from y from. Usar try_from devuelve un tipo Result, lo que permite gestionar explícitamente y de forma correcta los casos en los que el valor no cabe en el tipo de destino. El método from de Rust puede usarse para realizar una conversión implícita segura cuando se garantiza que no habrá pérdidas (por ejemplo, de u8 a u32). Por ejemplo, supongamos que un programa necesita convertir de forma segura una cantidad de tokens de tipo u64 a un tipo u32 para procesarla. En ese caso, puede hacer lo siguiente:
pub fn convert_token_amount(amount: u64) -> Result<u32, ProgramError> {
u32::try_from(amount).map_err(|_| ProgramError::InvalidArgument)
}En este ejemplo, si amount supera el valor máximo que puede contener un u32 (es decir, 4 294 967 295), la conversión falla y el programa devuelve un error. Esto evita que se produzca un posible desbordamiento o subdesbordamiento.
Uso compartido de PDA
La vulnerabilidad
El uso compartido de PDA es una vulnerabilidad común que surge cuando se usa el mismo PDA en varios dominios de autoridad o roles. Esto podría permitir que un actor malicioso acceda a datos o fondos que no le pertenecen al usar indebidamente los PDA como firmantes sin las comprobaciones de control de acceso adecuadas.
Escenario de ejemplo
Considera un programa diseñado para facilitar el staking de tokens y la distribución de recompensas. El programa usa un solo PDA para transferir tokens a un pool determinado y retirar recompensas. El PDA se deriva mediante una semilla estática (por ejemplo, el nombre del pool de staking), por lo que es común a todas las operaciones:
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
}Esto es problemático porque las funciones de staking y retiro de recompensas dependen del mismo PDA derivado de staking_pool_pda. Esto podría permitir que los usuarios manipulen el contrato para retirar recompensas sin autorización o alterar el staking.
Mitigación recomendada
Para mitigar esta vulnerabilidad, usa PDA distintos para cada funcionalidad. Asegúrate de que cada PDA corresponda a un contexto específico y se derive mediante semillas únicas y específicas de la operación:
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
}En el ejemplo anterior, los PDA para hacer staking de tokens y retirar recompensas se derivan mediante semillas distintas (staking_pool y rewards_pool, respectivamente), combinadas con la clave de la cuenta específica. Esto garantiza que los PDA estén vinculados de forma exclusiva a sus funcionalidades previstas y reduce el riesgo de acciones no autorizadas.
Cuentas restantes
La vulnerabilidad
ctx.remaining_accounts permite pasar a una función cuentas adicionales que no se especificaron inicialmente en la estructura Accounts. Esto ofrece más flexibilidad al desarrollador y le permite gestionar situaciones que requieren un número dinámico de cuentas (es decir, procesar un número variable de usuarios o interactuar con distintos programas). Sin embargo, esta mayor flexibilidad tiene una salvedad: las cuentas pasadas mediante ctx.remaining_accounts no se someten a la misma validación que se aplica a las cuentas definidas en la estructura Accounts. Como ctx.remaining_accounts no valida las cuentas recibidas, un actor malicioso podría aprovecharlo para pasar cuentas con las que el programa no debía interactuar, lo que provocaría acciones o accesos no autorizados.
Escenario de ejemplo
Considera un programa de recompensas que usa ctx.remaining_accounts para recibir PDA de usuarios y calcular recompensas de forma dinámica:
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
}El problema es que no hay comprobaciones explícitas para validar las cuentas pasadas mediante ctx.remaining_accounts. Por lo tanto, no se garantiza que solo se procesen las cuentas de usuarios válidos y aptos durante el cálculo y la distribución de recompensas. Un actor malicioso podría pasar cuentas que no le pertenecen o que él mismo creó para recibir más recompensas de las que realmente le corresponden.
Mitigación recomendada
Para mitigar esta vulnerabilidad, los desarrolladores deben verificar manualmente la validez de cada cuenta dentro de la función. Esto incluye comprobar el propietario de la cuenta para garantizar que coincida con el usuario esperado y validar todos los datos pertinentes de la cuenta. Al incorporar estas comprobaciones manuales, los desarrolladores pueden aprovechar la flexibilidad de ctx.remaining_acocunts y, al mismo tiempo, reducir el riesgo de acceso o manipulación no autorizados.
Errores específicos de Rust
Rust es la lengua franca del desarrollo de programas en Solana. Desarrollar en Rust presenta un conjunto único de desafíos y consideraciones, en especial en torno al código inseguro y los errores específicos de Rust. Comprender las particularidades de Rust ayuda a desarrollar programas seguros, eficientes y confiables.
Rust inseguro
Rust es reconocido por sus garantías de seguridad de memoria, logradas mediante un sistema estricto de propiedad y préstamos. Sin embargo, estas garantías a veces pueden ser un obstáculo, por lo que Rust ofrece la palabra clave unsafe para omitir las comprobaciones de seguridad. El Rust unsafe se usa en cuatro contextos principales:
- Funciones inseguras: las funciones que realizan operaciones que pueden infringir las garantías de seguridad de Rust deben marcarse con la palabra clave unsafe. Por ejemplo, unsafe fn dangerous_function() {}
- Bloques inseguros: bloques de código donde se permiten operaciones inseguras. Por ejemplo, unsafe { // Operaciones inseguras }
- Traits inseguros: traits que implican ciertas invariantes que el compilador no puede verificar. Por ejemplo, unsafe trait BadTrait {}
- Implementación de traits inseguros: las implementaciones de traits unsafe también deben marcarse como unsafe. Por ejemplo, unsafe impl UnsafeTrait for UnsafeType {}
El Rust inseguro existe porque el análisis estático es conservador. Cuando el compilador intenta determinar si el código cumple un conjunto de garantías, es mejor rechazar algunos casos de código válido que aceptar algunos casos de código no válido. Aunque el código pueda ejecutarse perfectamente, el compilador de Rust lo rechazará si no tiene suficiente información para confirmar que cumple las garantías de seguridad de Rust. El código inseguro permite que los desarrolladores omitan estas comprobaciones bajo su propia responsabilidad. Además, el hardware informático es inseguro por naturaleza. Los desarrolladores deben poder realizar operaciones inseguras para programar a bajo nivel con Rust.
Con la palabra clave unsafe, los desarrolladores pueden:
- Desreferenciar punteros sin procesar: permite acceder directamente mediante punteros sin procesar a cualquier ubicación de la memoria, que podría no contener datos válidos
- Llamar a funciones inseguras: estas funciones podrían incumplir las garantías de seguridad de Rust y provocar un comportamiento potencialmente indefinido
- Acceder a variables estáticas mutables: el estado global mutable puede provocar condiciones de carrera de datos
La mejor forma de mitigar los riesgos del Rust inseguro es minimizar el uso de bloques unsafe. Si el código unsafe es absolutamente necesario por cualquier motivo, asegúrate de que esté bien documentado, se audite con regularidad y, si es posible, esté encapsulado en una abstracción segura que pueda proporcionarse al resto del programa.
Panics y gestión de errores
Un panic ocurre cuando un programa de Rust encuentra un error irrecuperable y finaliza la ejecución. Los panics se usan para errores inesperados que no deben capturarse. En el contexto de los programas de Solana, un panic puede provocar un comportamiento inesperado, ya que el entorno de ejecución espera que los programas gestionen los errores correctamente sin bloquearse.
Cuando se produce un panic, Rust comienza a desenrollar la pila y a limpiarla a medida que avanza. Esto devuelve un seguimiento de pila que incluye información detallada sobre el error. Esa información podría revelar a un atacante la estructura de archivos subyacente. Aunque esto no se aplica directamente a los programas de Solana, las dependencias que usa un programa podrían ser vulnerables a un ataque de este tipo. Asegúrate de mantener las dependencias actualizadas y usar versiones que no contengan vulnerabilidades conocidas.
Entre los escenarios comunes de panic se incluyen:
- División entre cero: Rust entrará en estado de panic al intentar dividir entre cero. Por lo tanto, comprueba siempre que el divisor no sea cero antes de realizar una división
- Índice de array fuera de los límites: acceder a un array con un índice que supera sus límites provocará un panic. Para mitigar este riesgo, usa métodos que devuelvan un tipo Option (como get) y accede de forma segura a los elementos del array
- Desenvolver valores None: llamar a .unwrap() en un Option que contiene un valor None provocará un panic. Usa siempre coincidencia de patrones o métodos como unwrap_or, unwrap_or_else o el operador ? en funciones que devuelvan un Result
Para mitigar los problemas asociados con los panics, es esencial evitar las operaciones que los provocan, validar todas las entradas y condiciones que podrían generar operaciones problemáticas, y usar los tipos Result y Option para gestionar errores. Además, escribir pruebas exhaustivas del programa ayudará a descubrir y solucionar posibles situaciones de panic antes del despliegue.
Colisiones de semillas
La vulnerabilidad
Las colisiones de semillas ocurren cuando diferentes entradas (es decir, semillas e identificadores de programa) usadas para generar un PDA producen la misma dirección de PDA. Esto resulta problemático cuando los PDA se usan en un programa para distintos fines, ya que puede provocar comportamientos inesperados, incluidos ataques de denegación de servicio o una vulneración total.
Escenario de ejemplo
Considera un programa para una plataforma descentralizada de votación sobre distintas propuestas e iniciativas. Cada sesión de votación de una propuesta o iniciativa determinada se crea con un identificador único, y los usuarios envían sus votos. El programa usa PDA tanto para las sesiones de votación como para los votos individuales:
// 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>,
}En este escenario, un atacante intentaría diseñar cuidadosamente una sesión de votación que, al combinarse con la semilla estática "session", produjera un PDA que coincidiera por casualidad con el PDA generado para otra sesión de votación. Crear deliberadamente un PDA que entre en conflicto con el PDA de otra sesión de votación podría interrumpir las operaciones de la plataforma. Por ejemplo, podría impedir votos legítimos sobre propuestas o evitar que se agreguen nuevas iniciativas a la plataforma, ya que el entorno de ejecución de Solana no puede distinguir entre los PDA que colisionan.
Mitigación recomendada
Para mitigar el riesgo de colisiones de semillas, los desarrolladores pueden:
- Usar prefijos únicos para las semillas de distintos PDA dentro del mismo programa. Este enfoque ayudará a garantizar que los PDA permanezcan diferenciados
- Usar identificadores únicos (por ejemplo, marcas de tiempo, identificadores de usuario o valores nonce) para garantizar que siempre se genere un PDA único
- Validar mediante programación que un PDA generado no colisione con otros PDA existentes
Suplantación de tipos
La vulnerabilidad
La suplantación de tipos es una vulnerabilidad en la que un tipo de cuenta se presenta erróneamente como otro debido a la falta de comprobaciones de tipo durante la deserialización. Esto puede provocar la ejecución de acciones no autorizadas o la corrupción de datos, ya que el programa operaría bajo una suposición incorrecta sobre el rol o los permisos de la cuenta. Comprueba siempre de forma explícita el tipo previsto de la cuenta durante la deserialización.
Escenario de ejemplo
Considera un programa que administra el acceso a operaciones de administración según el rol del usuario. Cada cuenta de usuario incluye un discriminador de rol para distinguir entre usuarios normales y administradores. El programa contiene una función para actualizar la configuración de administración destinada únicamente a administradores. Sin embargo, el programa no comprueba el discriminador de la cuenta y deserializa los datos de la cuenta del usuario sin confirmar si esta pertenece a un administrador:
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,
}El problema es que update_admin_settings deserializa la cuenta de usuario recibida sin comprobar el discriminador de rol, en parte porque a la estructura User le falta un campo discriminador.
Mitigación recomendada
Para mitigar este problema, los desarrolladores pueden agregar un campo discriminador a la estructura User y verificarlo durante el proceso de deserialización:
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 simplifica la mitigación de las vulnerabilidades de suplantación de tipos al administrar automáticamente los discriminadores de los tipos de cuenta. Esto se hace mediante el contenedor Account<'info, T>, con el que Anchor garantiza la seguridad de tipos al comprobar automáticamente el discriminador durante la deserialización. Esto permite que los desarrolladores se concentren más en la lógica de negocio de su programa en lugar de implementar manualmente distintas comprobaciones de tipo.
Conclusión
Es imposible exagerar la importancia de la seguridad de los programas. Este artículo ha recorrido todo el espectro de vulnerabilidades comunes, desde errores específicos de Rust hasta las complejidades del método realloc de Anchor. Dominar cada una de estas vulnerabilidades, y la seguridad de los programas en general, es un proceso continuo que exige aprendizaje, adaptación y colaboración constantes. Como desarrolladores, nuestro compromiso con la seguridad no solo consiste en proteger activos, sino también en generar confianza, garantizar la integridad de nuestras aplicaciones y contribuir al crecimiento y la estabilidad de Solana.
Si llegaste hasta aquí, ¡gracias, anónimo! Ingresa tu dirección de correo electrónico a continuación para no perderte ninguna novedad de Solana. ¿Quieres profundizar más? Explora los artículos más recientes en el blog de Helius y continúa hoy tu recorrido por Solana.
Recursos adicionales
Artículos relacionados
Suscríbete a Helius
Mantente al día con las novedades del desarrollo en Solana y recibe actualizaciones cuando publiquemos


