
Le Guide du voyageur galactique de la sécurité des programmes Solana
Sommaire
- Introduction
- L’état d’esprit d’un attaquant exploitant des programmes Solana
- Le modèle de programmation de Solana
- Solana est sous le contrôle de l’attaquant
- Vecteurs d’attaque potentiels
- Stratégies d’atténuation
- Correspondance des données de compte
- La vulnérabilité
- Exemple de scénario
- Atténuation recommandée
- Réallocation des données de compte
- La vulnérabilité
- Exemple de scénario
- Atténuation recommandée
- Rechargement des comptes
- La vulnérabilité
- Exemple de scénario
- Atténuation recommandée
- CPI arbitraire
- La vulnérabilité
- Exemple de scénario
- Atténuation recommandée
- Fonctionnalité de transfert d’autorité
- La vulnérabilité
- Exemple de scénario
- Atténuation recommandée
- Canonicalisation de la graine de bump
- La vulnérabilité
- Exemple de scénario
- Atténuation recommandée
- Fermeture des comptes
- La vulnérabilité
- Exemple de scénario
- Mesure d’atténuation recommandée
- Comptes modifiables dupliqués
- La vulnérabilité
- Exemple de scénario
- Mesure d’atténuation recommandée
- Front-running
- La vulnérabilité
- Exemple de scénario
- Mesure d’atténuation recommandée
- Initialisation non sécurisée
- Exemple non sécurisé et méthode d’atténuation
- Perte de précision
- La vulnérabilité
- Multiplication après division
- Fonctions arithmétiques
- Erreurs d’arrondi
- Absence de vérification du propriétaire
- La vulnérabilité
- Exemple de scénario
- Mesure d’atténuation recommandée
- Comptes en lecture seule
- Absence de vérification du signataire
- La vulnérabilité
- Exemple de scénario
- Mesure d’atténuation recommandée
- Dépassement de capacité et dépassement par le bas
- La vulnérabilité
- Exemple de scénario
- Mesure d’atténuation recommandée
- Conversion de types
- Partage de PDA
- La vulnérabilité
- Exemple de scénario
- Mesure d’atténuation recommandée
- Comptes restants
- La vulnérabilité
- Exemple de scénario
- Mesure d’atténuation recommandée
- Erreurs propres à Rust
- Rust non sécurisé
- Paniques et gestion des erreurs
- Collisions de seeds
- La vulnérabilité
- Exemple de scénario
- Mesure d’atténuation recommandée
- Usurpation de type
- La vulnérabilité
- Exemple de scénario
- Mesure d’atténuation recommandée
- Conclusion
- Ressources supplémentaires
Cet article a été coécrit par bl0ckpain, chercheur en sécurité et développeur de smart contracts ayant précédemment travaillé avec Kudelski Security et Halborn
Introduction
La sécurité des programmes Solana ne consiste pas seulement à empêcher les pirates de voler les fonds d’un projet. Elle vise aussi à garantir qu’un programme se comporte comme prévu, conformément aux spécifications du projet et aux attentes des utilisateurs. La sécurité des programmes Solana peut affecter les performances, l’évolutivité et l’interopérabilité d’une dApp. Les développeurs doivent donc connaître les vecteurs d’attaque potentiels et les vulnérabilités courantes avant de créer des applications destinées au grand public.
Cet article examine les vulnérabilités courantes auxquelles les développeurs seront confrontés lors de la création de programmes Solana. Nous commencerons par présenter l’état d’esprit d’un attaquant cherchant à exploiter des programmes Solana. Nous aborderons notamment le modèle de programmation de Solana, la manière dont sa conception place intrinsèquement le contrôle entre les mains de l’attaquant, les vecteurs d’attaque potentiels et les stratégies d’atténuation courantes. Nous étudierons ensuite diverses vulnérabilités, avec une explication de chacune d’elles ainsi que, le cas échéant, des exemples de code non sécurisé et sécurisé.
Notez que cet article s’adresse à un public de niveau intermédiaire ou avancé, car il suppose une connaissance du modèle de programmation et du développement de programmes sur Solana.
Cet article n’expliquera pas le processus de création d’un programme ni les concepts propres à Solana. Nous nous concentrerons sur l’étude des vulnérabilités courantes et sur les moyens de les atténuer. Si vous débutez sur Solana, nous vous recommandons de lire les articles de blog suivants avant de poursuivre :
L’état d’esprit d’un attaquant exploitant des programmes Solana
Le modèle de programmation de Solana
Le modèle de programmation de Solana façonne le paysage de la sécurité des applications créées sur son réseau. Sur Solana, les comptes servent de conteneurs de données, à l’image des fichiers sur un ordinateur. Les comptes peuvent être répartis en deux grandes catégories : exécutables et non exécutables. Les comptes exécutables, ou programmes, sont capables d’exécuter du code. Les comptes non exécutables servent à stocker des données sans pouvoir exécuter de code, car ils n’en contiennent aucun. Cette séparation du code et des données signifie que les programmes sont sans état : ils interagissent avec les données stockées dans d’autres comptes, transmises par référence lors des transactions.
Solana est sous le contrôle de l’attaquant
Une transaction indique le programme à appeler, une liste de comptes et un tableau d’octets contenant les données d’instruction. Dans ce modèle, il incombe au programme d’analyser et d’interpréter les comptes et les instructions fournis par une transaction donnée. Permettre la transmission de n’importe quel compte à une fonction d’un programme donne aux attaquants un contrôle considérable sur les données que le programme traitera. Il est essentiel de comprendre que le modèle de programmation de Solana place intrinsèquement ce contrôle entre les mains des attaquants afin de développer des programmes sécurisés.
Puisqu’un attaquant peut transmettre n’importe quel compte à une fonction d’un programme, la validation des données constitue un pilier fondamental de la sécurité des programmes Solana. Les développeurs doivent s’assurer que leur programme peut distinguer les entrées légitimes des entrées malveillantes. Cela comprend la vérification du propriétaire des comptes, de leur conformité au type attendu et de leur statut éventuel de signataire.
Vecteurs d’attaque potentiels
Le modèle de programmation et l’environnement d’exécution uniques de Solana donnent lieu à des vecteurs d’attaque spécifiques. Les développeurs doivent impérativement les comprendre pour protéger leurs programmes contre d’éventuelles exploitations. Ces vecteurs d’attaque comprennent :
- Erreurs de logique : des failles dans la logique du programme peuvent être manipulées pour provoquer un comportement imprévu, comme une perte d’actifs ou un accès non autorisé. Cela inclut également une mauvaise mise en œuvre des spécifications du projet : si un programme prétend faire x, il doit faire x avec toutes ses particularités
- Failles de validation des données : une validation insuffisante des données d’entrée peut permettre aux attaquants de transmettre des données malveillantes et de manipuler l’état ou l’exécution du programme
- Problèmes propres à Rust : malgré les mécanismes de sécurité de Rust, les blocs de code non sécurisé, les problèmes de concurrence et les paniques peuvent introduire des vulnérabilités
- Vulnérabilités du contrôle d’accès : une mauvaise mise en œuvre des vérifications de contrôle d’accès, comme la vérification du propriétaire d’un compte, peut permettre à un acteur malveillant d’effectuer des actions non autorisées
- Erreurs arithmétiques et de précision : les dépassements de capacité par valeur supérieure ou inférieure et les erreurs de précision peuvent être exploités à des fins lucratives ou provoquer un dysfonctionnement du programme
- Problèmes d’invocation inter-programmes (CPI) : des failles dans la gestion des CPI peuvent entraîner des changements d’état ou des erreurs inattendus si un programme appelé se comporte de manière malveillante ou imprévue
- Mauvaise utilisation des adresses dérivées de programmes (PDA) : une génération ou une gestion incorrecte des PDA peut créer des vulnérabilités permettant aux attaquants de détourner ou d’usurper des PDA afin d’obtenir un accès non autorisé ou de manipuler des comptes contrôlés par le programme
Notez que la réentrance est intrinsèquement limitée sur Solana en raison de son modèle d’exécution. L’environnement d’exécution de Solana limite la profondeur maximale des CPI à quatre et applique des règles strictes aux comptes, notamment en autorisant uniquement le propriétaire d’un compte à modifier ses données. Ces contraintes empêchent les attaques par réentrance en limitant l’auto-récursion directe et en garantissant qu’un programme ne puisse pas être invoqué involontairement dans un état intermédiaire.
Stratégies d’atténuation
Pour atténuer ces attaques potentielles, les développeurs doivent combiner des tests rigoureux, des audits de code et le respect des bonnes pratiques :
- Mettre en œuvre une validation complète des entrées et des vérifications de contrôle d’accès
- Exploiter pleinement le système de types et les mécanismes de sécurité de Rust, en évitant le code non sécurisé sauf en cas de nécessité
- Suivre les bonnes pratiques de sécurité de Solana et de Rust et se tenir au courant des dernières évolutions
- Effectuer des revues de code internes et utiliser des outils automatisés pour identifier les vulnérabilités courantes et les erreurs de logique pendant le développement du programme
- Faire auditer la base de code par des tiers réputés, notamment des sociétés de sécurité et des chercheurs en sécurité indépendants
- Créer une plateforme de chasse aux bugs pour votre programme afin d’encourager le signalement des vulnérabilités plutôt que de dépendre des hackers gris
Les sections suivantes examineront différentes vulnérabilités par ordre alphabétique. Chaque section décrira une vulnérabilité potentielle, expliquera comment l’atténuer et présentera, dans la mesure du possible, des exemples de scénarios.
Correspondance des données de compte
La vulnérabilité
La correspondance des données de compte est une vulnérabilité qui survient lorsque les développeurs ne vérifient pas que les données stockées dans un compte correspondent à un ensemble de valeurs attendu. Sans vérifications appropriées de validation des données, un programme peut involontairement fonctionner avec des comptes incorrects ou substitués de manière malveillante. Cette vulnérabilité est particulièrement grave dans les scénarios impliquant des vérifications d’autorisations.
Exemple de scénario
Prenons un programme doté de fonctionnalités permettant de gérer ses paramètres administratifs. Il comprend une instruction servant à mettre à jour les configurations administratives actuelles, comme les indicateurs de fonctionnalité ou les paramètres opérationnels. L’instruction doit vérifier que la requête provient d’un administrateur autorisé. Cependant, le programme ne vérifie pas que le compte demandant la modification correspond au compte administrateur stocké dans les données de configuration :
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
}Atténuation recommandée
Pour atténuer cette vulnérabilité, les développeurs peuvent mettre en œuvre des vérifications explicites comparant les clés des comptes et les données stockées aux valeurs attendues. Par exemple, vérifiez que la clé publique du déposant correspond au champ du propriétaire du compte de jetons utilisé pour le dépôt :
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(())
}Les développeurs peuvent également utiliser les attributs has_one et constraint d’Anchor pour appliquer les vérifications de validation des données de manière déclarative. Dans l’exemple ci-dessus, nous pourrions utiliser l’attribut constraint pour vérifier que la clé publique du déposant correspond au propriétaire du compte de jetons de dépôt :
pub struct UpdateAdminSettings<'info> {
#[account(
mut,
constraint = config_data.admin == admin.key()
)]
pub config_data: Account<'info, ConfigData>,
pub admin: Signer<'info>,
}Réallocation des données de compte
La vulnérabilité
Dans Anchor, la fonction realloc fournie par la structure AccountInfo introduit une vulnérabilité subtile liée à la gestion de la mémoire. Cette fonction permet de réallouer la taille des données d’un compte, ce qui peut être utile pour gérer des données dynamiques au sein des programmes. Toutefois, une mauvaise utilisation de realloc peut avoir des conséquences imprévues, notamment gaspiller des unités de calcul ou potentiellement exposer des données résiduelles.
La méthode realloc comporte deux paramètres :
- new_len : un usize qui indique la nouvelle longueur des données du compte
- zero_init : un bool qui détermine si le nouvel espace mémoire doit être initialisé à zéro
realloc est définie comme suit :
pub fn realloc(
&self,
new_len: usize,
zero_init: bool
) -> Result<(), ProgramError>La mémoire allouée aux données du compte est déjà initialisée à zéro au point d’entrée du programme. Cela signifie que le nouvel espace mémoire contient déjà uniquement des zéros lorsque les données sont réallouées à une taille supérieure au sein d’une même transaction. Réinitialiser cette mémoire à zéro est inutile et consomme des unités de calcul supplémentaires. À l’inverse, une réallocation vers une taille inférieure, puis de nouveau vers une taille supérieure au sein de la même transaction, peut exposer des données résiduelles si zero_init est défini sur false.
Exemple de scénario
Prenons un programme de liste de tâches dynamique dans lequel les utilisateurs peuvent ajouter, supprimer ou modifier des entrées au sein d’une même transaction. Ce programme doit réallouer dynamiquement la taille de ses données en fonction des actions des utilisateurs :
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
}Dans ce scénario, la fonction modify_todo_list peut réallouer to_do_list_data plusieurs fois afin de s’adapter à la taille requise par les modifications. Si la taille des données est réduite pour supprimer une tâche, puis de nouveau augmentée pour ajouter de nouvelles entrées au sein de la même transaction, définir zero_init sur false peut exposer des données résiduelles.
Atténuation recommandée
Pour atténuer ce problème, il est essentiel d’utiliser le paramètre zero_init avec discernement :
- Définissez
zero_initsurtruelorsque vous augmentez la taille des données après une diminution antérieure au sein du même appel de transaction. Cela garantit l’initialisation à zéro de tout nouvel espace mémoire et empêche l’exposition de données résiduelles - Définissez
zero_initsurfalselorsque vous augmentez la taille des données sans diminution préalable au sein du même appel de transaction, puisque la mémoire sera déjà initialisée à zéro
Au lieu de réallouer les données pour répondre à des exigences de taille précises, les développeurs devraient utiliser des tables de recherche d’adresses (ALT). Les ALT permettent aux développeurs de compresser les données d’une transaction en stockant jusqu’à 256 adresses dans un seul compte on-chain. Chaque adresse de la table peut ensuite être référencée par un index de 1 octet, ce qui réduit considérablement la quantité de données nécessaire pour référencer des adresses dans une transaction donnée. Les ALT sont bien plus utiles dans les scénarios nécessitant des interactions dynamiques avec les comptes sans redimensionnement fréquent de la mémoire.
Rechargement des comptes
La vulnérabilité
Le rechargement des comptes est une vulnérabilité qui survient lorsque les développeurs ne mettent pas à jour les comptes désérialisés après avoir effectué une CPI. Anchor n’actualise pas automatiquement l’état des comptes désérialisés après une CPI. La logique du programme peut alors fonctionner avec des données obsolètes, ce qui entraîne des erreurs de logique ou des calculs incorrects.
Exemple de scénario
Prenons un protocole dans lequel les utilisateurs peuvent staker des jetons pour accumuler des récompenses au fil du temps. Le programme correspondant comprend une fonctionnalité permettant de mettre à jour les récompenses de staking d’un utilisateur en fonction de certaines conditions ou de déclencheurs externes. Les récompenses d’un utilisateur sont calculées et mises à jour au moyen d’une CPI vers un programme de distribution de récompenses. Cependant, après la CPI, le programme ne met pas à jour le compte de staking d’origine pour refléter le nouveau solde de récompenses :
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,
}Dans cet exemple, la fonction update_rewards tente de mettre à jour les récompenses du compte de staking d’un utilisateur au moyen d’un appel CPI vers un programme de distribution de récompenses. Le programme journalise d’abord ctx.accounts.staking_account.rewards, c’est-à-dire le solde des récompenses, après la CPI, puis poursuit avec une logique qui utilise les données obsolètes de ctx.accounts.staking_account.rewards. Le problème vient du fait que l’état du compte de staking n’est pas automatiquement mis à jour après la CPI, ce qui explique pourquoi les données sont obsolètes.
Atténuation recommandée
Pour atténuer ce problème, appelez explicitement la méthode reload d’Anchor afin de recharger un compte donné depuis le stockage. Le rechargement d’un compte après une CPI reflétera fidèlement son état :
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 arbitraire
La vulnérabilité
Une CPI arbitraire se produit lorsqu’un programme en invoque un autre sans vérifier l’identité du programme cible. Cette vulnérabilité existe parce que l’environnement d’exécution de Solana permet à n’importe quel programme d’en appeler un autre si l’appelant dispose de l’ID du programme appelé et respecte son interface. Si un programme effectue des CPI à partir d’entrées utilisateur sans valider l’ID du programme appelé, il peut exécuter du code dans un programme contrôlé par un attaquant.
Exemple de scénario
Prenons un programme qui distribue des récompenses aux participants en fonction de leurs contributions à un projet. Après avoir distribué les récompenses, le programme enregistre les détails dans un programme de registre distinct à des fins d’audit et de suivi. Ce programme de registre est considéré comme fiable et fournit une interface publique permettant de suivre des entrées spécifiques provenant de programmes autorisés. Le programme comprend une fonction qui distribue et enregistre les récompenses et qui reçoit le programme de registre sous forme de compte. Cependant, la fonction ne vérifie pas le ledger_program fourni avant d’effectuer une CPI vers celui-ci :
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 attaquant pourrait exploiter cette faille en transmettant l’ID d’un programme malveillant comme ledger_program, avec des conséquences imprévues.
Atténuation recommandée
Pour se protéger contre ce problème, les développeurs peuvent ajouter une vérification de l’identité du programme de registre avant d’effectuer la CPI. Cette vérification garantirait que l’appel CPI est adressé au programme prévu, empêchant ainsi les CPI arbitraires :
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 programme peut disposer d’un module CPI accessible au public s’il a été écrit avec Anchor. Cela permet de l’invoquer facilement et de manière sécurisée depuis un autre programme Anchor. Le module CPI d’Anchor vérifie automatiquement que l’adresse du programme transmise correspond à celle stockée dans le module. Une autre solution consiste à coder l’adresse en dur au lieu de demander à l’utilisateur de la transmettre.
Fonctionnalité de transfert d’autorité
La vulnérabilité
Les programmes Solana désignent souvent des clés publiques spécifiques comme autorités pour des fonctions critiques, telles que la mise à jour des paramètres du programme ou le retrait de fonds. Cependant, l’impossibilité de transférer cette autorité à une autre adresse peut présenter des risques importants. Cette limitation devient problématique en cas de changement d’équipe, de vente du protocole ou de compromission de l’autorité.
Exemple de scénario
Prenons un programme dans lequel une autorité administrative globale est chargée de définir certains paramètres du protocole au moyen d’une fonction set_params. Le programme ne comporte aucun mécanisme permettant de changer l’administrateur 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
}Ici, l’autorité est définie de manière statique, sans possibilité de la remplacer par une nouvelle adresse.
Atténuation recommandée
Une approche sécurisée pour atténuer ce problème consiste à créer un processus de transfert d’autorité en deux étapes. Ce processus permettrait à l’autorité actuelle de désigner une nouvelle pending_authority, qui devrait explicitement accepter le rôle. Cette méthode fournirait non seulement une fonctionnalité de transfert d’autorité, mais protégerait également contre les transferts accidentels ou les prises de contrôle malveillantes. Le processus serait le suivant :
- Désignation par l’autorité actuelle : l’autorité actuelle désigne une nouvelle pending_authority en appelant nominate_new_authority, ce qui définit le champ pending_authority dans l’état du programme
- Acceptation par la nouvelle autorité : la pending_authority désignée appelle accept_authority pour assumer son nouveau rôle, transférant ainsi l’autorité actuelle à la pending_authority
Cela ressemblerait à ceci :
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
}Dans cet exemple, la structure de compte ProgramState contient l’authority actuelle et une pending_authority facultative. Le contexte NominateAuthority garantit que l’autorité actuelle signe la transaction, ce qui lui permet de désigner une nouvelle autorité. Le contexte AcceptAuthority vérifie que la pending_authority correspond au signataire de la transaction, ce qui lui permet d’accepter et de devenir la nouvelle autorité. Cette configuration garantit une transition sécurisée et contrôlée de l’autorité au sein du programme.
Canonicalisation de la graine de bump
La vulnérabilité
La canonicalisation de la graine de bump consiste à utiliser la graine de bump valide la plus élevée, c’est-à-dire le bump canonique, lors de la dérivation des PDA. L’utilisation du bump canonique constitue un moyen déterministe et sécurisé de trouver une adresse à partir d’un ensemble de graines. Ne pas utiliser le bump canonique peut créer des vulnérabilités, notamment en permettant à des acteurs malveillants de créer ou de manipuler des PDA qui compromettent la logique du programme ou l’intégrité des données.
Exemple de scénario
Prenons un programme conçu pour créer des profils utilisateur uniques, chacun associé à une PDA explicitement dérivée au moyen de create_program_address. Le programme permet de créer un profil en acceptant un bump fourni par l’utilisateur. Cette approche est toutefois problématique, car elle risque d’utiliser un bump non canonique :
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>,
}Dans ce scénario, le programme dérive une PDA UserProfile au moyen de create_program_address avec des graines comprenant un bump fourni par l’utilisateur. L’utilisation d’un bump fourni par l’utilisateur est problématique, car elle ne garantit pas l’emploi du bump canonique. Un acteur malveillant pourrait ainsi créer plusieurs PDA avec différents bumps pour le même ID utilisateur.
Atténuation recommandée
Pour atténuer ce problème, nous pouvons remanier notre exemple afin de dériver les PDA au moyen de find_program_address et de valider explicitement la graine de 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,
}Ici, find_program_address sert à dériver la PDA avec la graine de bump canonique afin de garantir une création de PDA déterministe et sécurisée. Le bump canonique est stocké dans le compte UserProfile, ce qui permet une validation efficace et sécurisée lors des opérations suivantes. Nous préférons find_program_address à create_program_address, car ce dernier crée une PDA valide sans rechercher de graine de bump. Puisqu’il ne recherche pas de graine de bump, il peut renvoyer une erreur de manière imprévisible pour n’importe quel ensemble de graines et ne convient généralement pas à la création de PDA. find_program_address utilisera toujours le bump canonique lors de la création d’une PDA. En effet, il effectue plusieurs appels à create_program_address, en commençant par un bump de 255 et en le décrémentant à chaque itération. Une fois qu’une adresse valide est trouvée, la fonction renvoie la PDA dérivée et le bump canonique utilisé pour la dériver.
Anchor impose l’utilisation du bump canonique pour les dérivations de PDA au moyen de ses contraintes seeds et bump, simplifiant ainsi l’ensemble du processus afin de garantir une création et une validation sécurisées et déterministes des PDA.
Fermeture des comptes
La vulnérabilité
La fermeture incorrecte des comptes dans un programme peut entraîner plusieurs vulnérabilités, notamment la possibilité de réinitialiser ou de détourner des comptes « fermés ». Le problème survient lorsqu’un compte n’est pas correctement marqué comme fermé ou que sa réutilisation dans des transactions ultérieures n’est pas empêchée. Cette négligence peut permettre à des acteurs malveillants d’exploiter un compte donné, entraînant des actions ou des accès non autorisés au sein du programme.
Exemple de scénario
Prenons un programme qui permet aux utilisateurs de créer et de fermer des comptes de stockage de données. Le programme ferme un compte en transférant ses 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,
}Cette approche pose problème, car le programme ne remet pas à zéro les données du compte et ne le marque pas comme fermé. Le simple transfert de ses lamports restants ne ferme pas le compte.
Mesure d’atténuation recommandée
Pour atténuer ce problème, le programme doit non seulement transférer tous les lamports, mais également remettre à zéro les données du compte et le marquer à l’aide d’un discriminateur (c’est-à-dire "CLOSED_ACCOUNT_DISCRIMINATOR"). Le programme doit aussi mettre en œuvre des vérifications afin d’empêcher la réutilisation des comptes fermés dans de futures transactions :
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,
}Cependant, remettre les données à zéro et ajouter le discriminateur de fermeture ne suffit pas. Un utilisateur peut empêcher le nettoyage automatique d’un compte en lui restituant ses lamports avant la fin d’une instruction. Le compte se retrouve alors dans un état intermédiaire étrange, où il ne peut être ni utilisé ni nettoyé. Nous avons donc ajouté une fonction force_defund pour gérer ce cas limite ; désormais, n’importe qui peut retirer les fonds des comptes fermés.
Anchor simplifie ce processus grâce à la contrainte #[account(close = destination)], qui automatise la fermeture sécurisée des comptes en transférant les lamports, en remettant les données à zéro et en définissant le discriminateur de compte fermé, le tout en une seule opération.
Comptes modifiables dupliqués
La vulnérabilité
Les comptes modifiables dupliqués désignent un scénario dans lequel le même compte est transmis plusieurs fois comme paramètre modifiable à une instruction. Cela se produit lorsqu’une instruction nécessite deux comptes modifiables du même type. Un acteur malveillant peut transmettre deux fois le même compte, entraînant des modifications imprévues de celui-ci, comme l’écrasement de données. La gravité de cette vulnérabilité varie selon le scénario concerné.
Exemple de scénario
Prenons un programme conçu pour récompenser les utilisateurs en fonction de leur participation à une activité on-chain donnée. Le programme contient une instruction permettant de mettre à jour le solde de deux comptes : un compte de récompense et un compte de bonus. Un utilisateur doit recevoir une récompense standard sur un compte et, s’il répond à des critères prédéfinis précis, un éventuel bonus sur un autre compte :
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 acteur malveillant transmet le même compte pour reward_account et bonus_account, le solde du compte sera incorrectement mis à jour deux fois.
Mesure d’atténuation recommandée
Pour atténuer ce problème, ajoutez une vérification dans la logique de l’instruction afin de vous assurer que les clés publiques des deux comptes ne sont pas identiques :
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(())
}Les développeurs peuvent utiliser les contraintes de compte d’Anchor pour ajouter une vérification plus explicite sur le compte. Pour cela, utilisez l’attribut #[account] et le mot-clé 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,
}Front-running
La vulnérabilité
Avec la popularité croissante des agrégateurs de transactions, le front-running est un risque que les protocoles développés sur Solana doivent prendre au sérieux. Depuis la suppression du mempool de Jito, le front-running désigne ici la capacité d’un acteur malveillant à manipuler les valeurs attendues par rapport aux valeurs réelles au moyen de transactions soigneusement élaborées.
Exemple de scénario
Imaginez un protocole qui gère l’achat et les enchères d’un produit, et stocke les informations tarifaires du vendeur dans un compte nommé 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,
}Pour acheter un Product mis en vente, un acheteur doit transmettre le compte ProductListing associé au produit souhaité. Mais que se passe-t-il si le vendeur peut modifier le sale_price de son annonce ?
pub fn change_sale_price(ctx: Context<ChangeSalePrice>, new_price: u64) -> Result<()> {...}Cela créerait une possibilité de front-running pour le vendeur, en particulier si la transaction d’achat de l’acheteur ne comporte pas de vérifications expected_price garantissant qu’il ne paiera pas plus que prévu pour le produit souhaité. Si l’acheteur soumet une transaction pour acheter le Product concerné, le vendeur pourrait appeler change_sale_price et, à l’aide de Jito, s’assurer que cette transaction est incluse avant celle de l’acheteur. À l’insu de l’acheteur, un vendeur malveillant pourrait remplacer le prix du compte ProductListing par un montant exorbitant et le contraindre à payer bien plus que prévu pour le Product!
Mesure d’atténuation recommandée
Une solution simple consiste à inclure des vérifications expected_price du côté de l’acheteur, afin de l’empêcher de payer plus que prévu pour le Product qu’il souhaite acheter :
pub fn purchase_product(ctx: Context<PurchaseProduct>, expected_price: u64) -> Result<()> {
assert!(ctx.accounts.product_listing.sale_price <= expected_price);
...
}Initialisation non sécurisée
Contrairement aux contrats déployés sur l’EVM, les programmes Solana ne sont pas déployés avec un constructeur chargé de définir les variables d’état. Ils sont initialisés manuellement, généralement par une fonction appelée initialize ou portant un nom similaire. Les fonctions d’initialisation définissent généralement des données telles que l’autorité du programme ou créent les comptes qui constituent la base du programme déployé, par exemple un compte d’état central.
Comme la fonction d’initialisation est appelée manuellement, et non automatiquement lors du déploiement du programme, cette instruction doit être appelée par une adresse connue contrôlée par l’équipe de développement du programme. Dans le cas contraire, un attaquant peut devancer l’initialisation et éventuellement configurer le programme à l’aide de comptes qu’il contrôle.
Une pratique courante consiste à utiliser l’upgrade_authority du programme comme adresse autorisée à appeler la fonction initialize, si le programme dispose d’une autorité de mise à niveau.
Exemple non sécurisé et méthode d’atténuation
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,
...
}L’exemple ci-dessus est une version simplifiée d’une fonction initialize qui définit l’autorité d’un compte CentralState sur l’appelant de l’instruction. Cependant, n’importe quel compte peut appeler initialize ! Comme indiqué précédemment, une méthode courante pour sécuriser une fonction d’initialisation consiste à utiliser l’upgrade_authority du programme, connue lors du déploiement.
Voici un exemple tiré de la documentation d’Anchor, qui utilise constraint pour garantir que seule l’autorité de mise à niveau du programme peut appeler 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>,
}Perte de précision
La vulnérabilité
Même si elle semble minime, une perte de précision peut représenter une menace importante pour un programme. Elle peut entraîner des calculs incorrects, des possibilités d’arbitrage et un comportement inattendu du programme.
La perte de précision dans les opérations arithmétiques est une source d’erreurs courante. Pour les programmes Solana, l’arithmétique à virgule fixe est recommandée dès que possible. En effet, les programmes ne prennent en charge qu’un sous-ensemble limité des opérations en virgule flottante de Rust. Si un programme tente d’utiliser une opération en virgule flottante non prise en charge, l’environnement d’exécution renvoie une erreur de symbole non résolu. En outre, les opérations en virgule flottante nécessitent davantage d’instructions que leurs équivalents entiers. L’utilisation de l’arithmétique à virgule fixe et la nécessité de traiter avec précision un grand nombre de tokens et des montants fractionnaires peuvent aggraver la perte de précision.
Multiplication après division
Bien que la propriété associative s’applique à la plupart des opérations mathématiques, son utilisation dans l’arithmétique informatique peut entraîner une perte de précision inattendue. Un exemple classique de perte de précision se produit lorsqu’une multiplication est effectuée après une division, ce qui peut produire un résultat différent d’une multiplication effectuée avant la division. Prenons par exemple les expressions suivantes : (a / c) * b et (a * b) / c. Mathématiquement, ces expressions sont associatives : elles devraient produire le même résultat. Cependant, dans le contexte de Solana et de l’arithmétique à virgule fixe, l’ordre des opérations joue un rôle déterminant. Effectuer d’abord la division (a / c) peut entraîner une perte de précision si le quotient est arrondi à l’entier inférieur avant d’être multiplié par b. Le résultat obtenu peut alors être inférieur à celui attendu. À l’inverse, effectuer la multiplication (a * b) avant la division par c peut préserver davantage la précision d’origine. Cette différence peut provoquer des calculs incorrects et créer un comportement inattendu du programme et/ou des possibilités d’arbitrage.
Fonctions arithmétiques saturating_*
Bien que les fonctions arithmétiques saturating_* empêchent les dépassements de capacité positifs et négatifs en plafonnant les valeurs à leur maximum ou minimum possible, elles peuvent provoquer des bugs subtils et une perte de précision si cette limite est atteinte de manière inattendue. Cela se produit lorsque la logique du programme suppose que la saturation suffit à garantir un résultat précis et ne gère pas la perte potentielle de précision ou d’exactitude.
Imaginez par exemple un programme conçu pour calculer et distribuer des récompenses aux utilisateurs en fonction du nombre de tokens qu’ils échangent pendant une période donnée :
pub fn calculate_reward(transaction_amount: u64, reward_multiplier: u64) -> u64 {
transaction_amount.saturating_mul(reward_multiplier)
}Prenons un scénario dans lequel transaction_amount vaut 100 000 tokens et reward_multiplier vaut 100 tokens par transaction. La multiplication des deux dépassera la valeur maximale qu’un u64 peut contenir. Leur produit sera donc plafonné, ce qui entraînera une perte de précision importante et une récompense insuffisante pour l’utilisateur.
Erreurs d’arrondi
Les opérations d’arrondi sont une source courante de perte de précision en programmation. Le choix de la méthode d’arrondi peut avoir une incidence significative sur l’exactitude des calculs et le comportement des programmes Solana. La fonction try_round_u64() arrondit les valeurs décimales à l’entier le plus proche. L’arrondi à l’entier supérieur pose problème, car il peut gonfler artificiellement les valeurs et créer des écarts entre les calculs réels et attendus.
Prenons un programme Solana qui convertit des garanties en liquidités selon les conditions du marché. Le programme utilise try_round_u64() pour arrondir le résultat d’une opération de division :
pub fn collateral_to_liquidity(&self, collateral_amount: u64) -> Result<u64, ProgramError> {
Decimal::from(collateral_amount)
.try_div(self.0)?
.try_round_u64()
}Dans ce scénario, un arrondi à l’entier supérieur peut entraîner l’émission d’un nombre de tokens de liquidité supérieur à ce que justifie le montant des garanties. Des acteurs malveillants peuvent exploiter cet écart pour mener des attaques d’arbitrage et extraire de la valeur du protocole grâce à des résultats d’arrondi avantageux. Pour atténuer ce risque, utilisez try_floor_u64 afin d’arrondir à l’entier inférieur. Cette approche réduit le risque de gonfler artificiellement les valeurs et garantit que l’arrondi n’avantage pas l’utilisateur au détriment du système. Vous pouvez aussi mettre en œuvre une logique qui gère les scénarios dans lesquels l’arrondi pourrait avoir une incidence explicite sur le résultat. Il peut notamment s’agir de définir des seuils précis pour les décisions d’arrondi ou d’appliquer une logique différente selon l’ordre de grandeur des valeurs concernées.
Absence de vérification du propriétaire
La vulnérabilité
Les vérifications de propriété sont essentielles pour confirmer que le programme attendu possède un compte impliqué dans une transaction ou une opération. Les comptes comprennent un champ owner, qui indique le programme autorisé à écrire dans les données du compte. Ce champ garantit que seuls les programmes autorisés peuvent modifier l’état d’un compte. Il permet également de vérifier que les comptes transmis à une instruction appartiennent au programme attendu. L’absence de vérification de propriété peut entraîner de graves vulnérabilités, notamment des transferts de fonds non autorisés et l’exécution d’opérations privilégiées.
Exemple de scénario
Prenons une fonction de programme conçue pour permettre uniquement aux administrateurs d’effectuer des retraits depuis un coffre. La fonction reçoit un compte de configuration, c’est-à-dire config, et utilise son champ admin pour vérifier si la clé publique du compte administrateur fourni correspond à celle stockée dans le compte config. Cependant, elle ne vérifie pas la propriété du compte config, car elle suppose qu’il est fiable :
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 acteur malveillant pourrait exploiter cette faille en fournissant un compte config qu’il contrôle et dont le champ admin correspond, trompant ainsi le programme pour qu’il exécute le retrait.
Mesure d’atténuation recommandée
Pour atténuer ce risque, effectuez une vérification de propriété qui contrôle le champ owner du compte :
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 simplifie cette vérification grâce au type Account. Account<'info, T> est une enveloppe autour d’AccountInfo qui vérifie la propriété du programme et désérialise les données sous-jacentes dans T, c’est-à-dire le type de compte spécifié. Les développeurs peuvent ainsi utiliser Account<'info, T> pour valider facilement la propriété du compte. Ils peuvent également utiliser l’attribut #[account] afin d’ajouter le trait Owner à un compte donné. Ce trait définit l’adresse censée posséder le compte. Les développeurs peuvent en outre utiliser la contrainte owner pour définir le programme qui doit posséder un compte donné s’il diffère du programme en cours d’exécution. Cela s’avère utile, par exemple, lors de l’écriture d’une instruction qui attend un compte correspondant à un PDA dérivé d’un autre programme. La contrainte owner est définie sous la forme #[account(owner = <expr>)], où <expr> est une expression arbitraire.
Comptes en lecture seule
Il est tout aussi important de vérifier la validité des comptes déclarés en lecture seule dans le contexte d’exécution d’un programme. Cette vérification est essentielle, car un acteur malveillant pourrait transmettre des comptes contenant des données arbitraires ou spécialement conçues à la place de comptes légitimes. Cela pourrait entraîner un comportement inattendu ou dangereux du programme. Les développeurs doivent toujours effectuer des vérifications pour s’assurer que les comptes que le programme doit lire sont authentiques et n’ont pas été altérés. Ils peuvent notamment comparer l’adresse du compte à des valeurs connues ou confirmer que son propriétaire correspond à celui attendu, en particulier pour les sysvars, c’est-à-dire les comptes système en lecture seule tels que Clock ou EpochSchedule. Accédez aux sysvars à l’aide de la méthode get(), qui ne nécessite aucune vérification manuelle de l’adresse ou de la propriété. Cette approche est plus sûre pour accéder à ces comptes. Cependant, toutes les sysvars ne prennent pas en charge la méthode get(). Dans ce cas, utilisez leur adresse publique pour y accéder.
Absence de vérification du signataire
La vulnérabilité
Les transactions sont signées avec la clé privée d’un wallet afin de garantir l’authentification, l’intégrité, la non-répudiation et l’autorisation d’une transaction précise par un wallet précis. En exigeant que les transactions soient signées avec la clé privée de l’expéditeur, l’environnement d’exécution de Solana peut vérifier que le bon compte initie une transaction et que celle-ci n’a pas été altérée. Ce mécanisme sous-tend l’absence de tiers de confiance propre aux réseaux décentralisés. Sans cette vérification, n’importe quel compte fournissant le compte approprié comme argument peut exécuter une transaction. Cela peut entraîner un accès non autorisé à des informations, des fonds ou des fonctionnalités privilégiés. Cette vulnérabilité survient lorsqu’un programme ne vérifie pas qu’une opération a été signée par la clé privée du compte approprié avant d’exécuter certaines fonctionnalités privilégiées.
Exemple de scénario
Prenons la fonction suivante :
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(())
}Cette fonction vise à mettre à jour l’administrateur du programme. Elle inclut une vérification pour s’assurer que l’administrateur actuel initie l’opération, ce qui constitue un bon contrôle d’accès. Cependant, elle ne vérifie pas que la clé privée de l’administrateur actuel a signé la transaction. Ainsi, toute personne appelant cette fonction peut transmettre le compte admin approprié de sorte que admin.pubkey() = config.admin, que le compte appelant la fonction soit réellement l’administrateur actuel ou non. Un acteur malveillant peut donc exécuter l’instruction en transmettant son propre compte comme nouvel administrateur, sans avoir besoin de l’autorisation de l’administrateur actuel.
Mesure d’atténuation recommandée
Les programmes doivent inclure des vérifications pour confirmer qu’un compte a été signé par le wallet approprié. Pour cela, vérifiez le champ AccountInfo::is_signer des comptes impliqués dans la transaction. Le programme peut garantir que seuls les comptes autorisés effectuent certaines actions en vérifiant que le compte exécutant l’opération privilégiée possède un indicateur is_signer défini sur true.
L’exemple de code mis à jour se présenterait comme suit :
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 simplifie l’ensemble de ce processus grâce au type de compte Signer<’info>.
Dépassement de capacité et dépassement par le bas
La vulnérabilité
Un entier est un nombre sans composante fractionnaire. Rust stocke les entiers sous forme de variables de taille fixe. Ces variables sont définies par leur caractère signé (c’est-à-dire signé ou non signé) et l’espace qu’elles occupent en mémoire. Par exemple, le type u8 désigne un entier non signé occupant 8 bits. Il peut contenir des valeurs comprises entre 0 et 255. Le stockage d’une valeur hors de cette plage entraîne un dépassement de capacité ou un dépassement par le bas. Un dépassement de capacité d’entier se produit lorsqu’une variable dépasse sa capacité maximale et revient à sa valeur minimale. Un dépassement par le bas se produit lorsqu’une variable passe sous sa capacité minimale et revient à sa valeur maximale.
Rust inclut des vérifications des dépassements de capacité et des dépassements par le bas des entiers lors de la compilation en mode débogage. Ces vérifications provoquent une panique du programme à l’exécution si une telle condition est détectée. Cependant, Rust n’inclut pas de vérifications provoquant une panique pour les dépassements de capacité et les dépassements par le bas lors de la compilation en mode release avec l’indicateur --release. Ce comportement peut introduire des vulnérabilités subtiles, car le dépassement se produit silencieusement. La chaîne d’outils Berkley Packet Filter (BPF) fait partie intégrante de l’environnement de développement de Solana, puisqu’elle compile les programmes Solana. La commande cargo build-bpf compile les projets Rust en bytecode BPF pour leur déploiement. Le problème est qu’elle compile les programmes en mode release par défaut. Les programmes Solana sont donc vulnérables aux dépassements de capacité et aux dépassements par le bas des entiers.
Exemple de scénario
Un attaquant peut exploiter cette vulnérabilité en tirant parti du comportement silencieux des dépassements en mode release, en particulier dans les fonctions qui gèrent les soldes de tokens. Prenons l’exemple suivant :
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(())
}Pour simplifier, cette fonction suppose que le solde est stocké dans le premier octet. Elle prend le solde du compte et en soustrait tokens_to_subtract. Si le solde de l’utilisateur est inférieur à tokens_to_subtract, cela entraîne un dépassement par le bas. Par exemple, le solde d’un utilisateur possédant 10 tokens passerait à 165 tokens à la suite d’un dépassement par le bas
Mesure d’atténuation recommandée
overflow-checks
Le moyen le plus simple d’atténuer cette vulnérabilité consiste à définir la clé overflow-checks sur true dans le fichier Cargo.toml du projet. Rust ajoutera alors au compilateur des vérifications des dépassements de capacité et des dépassements par le bas. Cependant, l’ajout de ces vérifications augmente le coût de calcul d’une transaction. Lorsqu’il est nécessaire d’optimiser le calcul, il peut être préférable de définir overflow-checks sur false.
Arithmétique checked_*
Utilisez les fonctions arithmétiques checked_* de Rust sur chaque type d’entier afin de vérifier de manière ciblée les dépassements de capacité et les dépassements par le bas dans l’ensemble de votre programme. Ces fonctions renvoient None si un dépassement se produit. Le programme peut ainsi gérer l’erreur sans s’interrompre brutalement. Par exemple, vous pouvez remanier le code précédent comme suit :
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(())
}Dans l’exemple révisé, checked_sub permet de soustraire tokens_to_subtract de balance. Ainsi, si balance suffit à couvrir la soustraction, checked_sub renvoie Some(new_balance). Le programme continue à mettre à jour le solde du compte en toute sécurité et le consigne dans les journaux. Toutefois, si la soustraction entraîne un dépassement par le bas, checked_sub renvoie None, ce que nous pouvons gérer en renvoyant une erreur.
Macro Checked Math
Checked Math est une macro procédurale permettant de modifier les propriétés de vérification des expressions mathématiques sans, pour l’essentiel, altérer ces expressions. Le problème des fonctions arithmétiques checked_* est la perte de la notation mathématique. Il faut alors utiliser des méthodes peu pratiques comme a.checked_add(b).unwrap() à la place de a + b. Par exemple, pour écrire (x * y) + z à l’aide des fonctions arithmétiques vérifiées, il faudrait écrire x.checked_mul(y).unwrap().checked_add(z).unwrap().
Avec la macro Checked Math, cette même expression s’écrit plutôt comme suit :
use checked_math::checked_math as cm;
cm!((x * y) + z).unwrap()Cette syntaxe est plus pratique, préserve la notation mathématique de l’expression et ne nécessite qu’un seul .unwrap(). En effet, la macro convertit les expressions mathématiques normales en une expression qui renvoie None si l’une des étapes vérifiées renvoie None. En cas de réussite, Some(_) est renvoyé, ce qui explique pourquoi nous extrayons la valeur de l’expression à la fin.
Conversion de types
De même, la conversion entre types d’entiers à l’aide du mot-clé as, sans vérifications appropriées, peut introduire une vulnérabilité de dépassement de capacité ou de dépassement par le bas. Une conversion peut en effet tronquer ou étendre les valeurs de manière involontaire. Lors d’une conversion d’un type d’entier plus grand vers un type plus petit (par exemple, de u64 vers u32), Rust tronque les bits de poids fort de la valeur d’origine qui ne tiennent pas dans le type cible. Cela pose problème lorsque la valeur d’origine dépasse la valeur maximale que le type cible peut stocker. Lors d’une conversion d’un type d’entier plus petit vers un type plus grand (par exemple, de i16 vers i32), Rust étend la valeur. Cette opération est simple pour les types non signés. Toutefois, avec les entiers signés, elle peut entraîner une extension de signe et introduire involontairement des valeurs négatives.
Mesure d’atténuation recommandée
Utilisez les méthodes de conversion sécurisées de Rust pour atténuer cette vulnérabilité. Il s’agit notamment de méthodes telles que try_from et from. try_from renvoie un type Result, ce qui permet de gérer explicitement et proprement les cas où la valeur ne tient pas dans le type cible. La méthode from de Rust peut servir à effectuer une conversion implicite sûre lorsque celle-ci est garantie sans perte (par exemple, de u8 vers u32). Supposons, par exemple, qu’un programme doive convertir en toute sécurité une quantité de tokens de type u64 vers un type u32 pour la traiter. Il peut alors procéder comme suit :
pub fn convert_token_amount(amount: u64) -> Result<u32, ProgramError> {
u32::try_from(amount).map_err(|_| ProgramError::InvalidArgument)
}Dans cet exemple, si amount dépasse la valeur maximale que peut contenir un u32 (c’est-à-dire 4 294 967 295), la conversion échoue et le programme renvoie une erreur. Cela empêche un éventuel dépassement de capacité ou dépassement par le bas.
Partage de PDA
La vulnérabilité
Le partage de PDA est une vulnérabilité courante qui survient lorsqu’un même PDA est utilisé dans plusieurs domaines d’autorité ou pour plusieurs rôles. Un acteur malveillant pourrait alors accéder à des données ou à des fonds qui ne lui appartiennent pas en détournant des PDA comme signataires sans que les contrôles d’accès appropriés soient en place.
Exemple de scénario
Prenons un programme conçu pour faciliter le staking de tokens et la distribution de récompenses. Le programme utilise un seul PDA pour transférer des tokens vers un pool donné et retirer des récompenses. Le PDA est dérivé à l’aide d’une seed statique (par exemple, le nom du pool de staking), ce qui le rend commun à toutes les opérations :
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
}Cela pose problème, car les fonctionnalités de staking et de retrait des récompenses reposent sur le même PDA dérivé de staking_pool_pda. Les utilisateurs pourraient ainsi manipuler le contrat afin de retirer des récompenses sans autorisation ou de fausser le staking.
Mesure d’atténuation recommandée
Pour atténuer cette vulnérabilité, utilisez des PDA distincts pour les différentes fonctionnalités. Assurez-vous que chaque PDA répond à un contexte précis et qu’il est dérivé à l’aide de seeds uniques et propres à l’opération :
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
}Dans l’exemple ci-dessus, les PDA servant au staking des tokens et au retrait des récompenses sont dérivés à l’aide de seeds distinctes (staking_pool et rewards_pool, respectivement), combinées à la clé du compte concerné. Cela garantit que les PDA sont liés de manière unique aux fonctionnalités auxquelles ils sont destinés, ce qui réduit le risque d’actions non autorisées.
Comptes restants
La vulnérabilité
ctx.remaining_accounts permet de transmettre à une fonction des comptes supplémentaires qui n’étaient pas initialement spécifiés dans la structure Accounts. Cela offre davantage de flexibilité aux développeurs et leur permet de gérer des scénarios nécessitant un nombre dynamique de comptes (par exemple, traiter un nombre variable d’utilisateurs ou interagir avec différents programmes). Cette flexibilité accrue s’accompagne toutefois d’une réserve : les comptes transmis via ctx.remaining_accounts ne sont pas soumis aux mêmes validations que les comptes définis dans la structure Accounts. Comme ctx.remaining_accounts ne valide pas les comptes transmis, un acteur malveillant pourrait exploiter ce comportement en fournissant des comptes avec lesquels le programme n’était pas censé interagir, ce qui entraînerait des actions ou des accès non autorisés.
Exemple de scénario
Prenons un programme de récompenses qui utilise ctx.remaining_accounts pour recevoir les PDA des utilisateurs et calculer dynamiquement les récompenses :
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
}Le problème est qu’aucune vérification explicite ne valide les comptes transmis via ctx.remaining_accounts. Le programme ne garantit donc pas que seuls les comptes d’utilisateurs valides et éligibles sont pris en compte dans le calcul et la distribution des récompenses. Un acteur malveillant pourrait ainsi transmettre des comptes qui ne lui appartiennent pas, ou qu’il a lui-même créés, afin de recevoir plus de récompenses que ce qui lui est réellement dû.
Mesure d’atténuation recommandée
Pour atténuer cette vulnérabilité, les développeurs doivent vérifier manuellement la validité de chaque compte dans la fonction. Cela implique notamment de vérifier le propriétaire du compte afin de s’assurer qu’il correspond à l’utilisateur attendu, ainsi que de valider toutes les données pertinentes du compte. En intégrant ces vérifications manuelles, les développeurs peuvent profiter de la flexibilité de ctx.remaining_acocunts tout en réduisant le risque d’accès non autorisé ou de manipulation.
Erreurs propres à Rust
Rust est la lingua franca du développement de programmes sur Solana. Développer en Rust présente un ensemble particulier de défis et de considérations, notamment concernant le code non sécurisé et les erreurs propres à Rust. Comprendre les particularités de Rust aide à développer des programmes sécurisés, efficaces et fiables.
Rust non sécurisé
Rust est réputé pour ses garanties de sécurité de la mémoire, assurées par un système strict de propriété et d’emprunt. Toutefois, ces garanties peuvent parfois constituer un obstacle. Rust propose donc le mot-clé unsafe pour contourner les contrôles de sécurité. Le Rust unsafe est utilisé dans quatre contextes principaux :
- Fonctions non sécurisées : les fonctions qui effectuent des opérations susceptibles d’enfreindre les garanties de sécurité de Rust doivent être marquées avec le mot-clé unsafe. Par exemple, unsafe fn dangerous_function() {}
- Blocs non sécurisés : blocs de code dans lesquels les opérations non sécurisées sont autorisées. Par exemple, unsafe { // Unsafe operations }
- Traits non sécurisés : traits qui impliquent certains invariants que le compilateur ne peut pas vérifier. Par exemple, unsafe trait BadTrait {}
- Implémentation de traits non sécurisés : les implémentations de traits unsafe doivent également être marquées comme unsafe. Par exemple, unsafe impl UnsafeTrait for UnsafeType {}
Le Rust non sécurisé existe parce que l’analyse statique est prudente. Lorsque le compilateur tente de déterminer si le code respecte un ensemble donné de garanties, il vaut mieux rejeter quelques instances de code valide que d’accepter quelques instances de code non valide. Même si le code peut parfaitement s’exécuter, le compilateur Rust le rejette lorsqu’il ne dispose pas de suffisamment d’informations pour s’assurer qu’il respecte les garanties de sécurité de Rust. Le code non sécurisé permet aux développeurs de contourner ces vérifications à leurs propres risques. En outre, le matériel informatique est intrinsèquement non sécurisé. Les développeurs doivent pouvoir effectuer des opérations non sécurisées pour programmer à bas niveau avec Rust.
Avec le mot-clé unsafe, les développeurs peuvent :
- Déréférencer des pointeurs bruts : permet d’accéder directement à la mémoire via des pointeurs bruts pouvant pointer vers n’importe quel emplacement mémoire, lequel peut ne pas contenir de données valides
- Appeler des fonctions non sécurisées : ces fonctions peuvent ne pas respecter les garanties de sécurité de Rust et entraîner un comportement potentiellement indéfini
- Accéder à des variables statiques mutables : un état global mutable peut provoquer des situations de compétition
La meilleure façon d’atténuer les risques liés au Rust non sécurisé consiste à limiter l’utilisation des blocs unsafe. Si du code unsafe est absolument nécessaire, quelle qu’en soit la raison, assurez-vous qu’il est bien documenté, régulièrement audité et, si possible, encapsulé dans une abstraction sûre pouvant être mise à la disposition du reste du programme.
Paniques et gestion des erreurs
Une panique survient lorsqu’un programme Rust rencontre une erreur irrécupérable et interrompt son exécution. Les paniques servent à signaler des erreurs inattendues qui ne sont pas destinées à être interceptées. Dans le contexte des programmes Solana, une panique peut entraîner un comportement inattendu, car l’environnement d’exécution s’attend à ce que les programmes gèrent les erreurs proprement sans planter.
Lorsqu’une panique se produit, Rust commence à dépiler la pile tout en la nettoyant. Il renvoie alors une trace de pile contenant des informations détaillées sur l’erreur concernée. Cela pourrait fournir à un attaquant des informations sur la structure de fichiers sous-jacente. Bien que cela ne s’applique pas directement aux programmes Solana, les dépendances utilisées par un programme pourraient être vulnérables à ce type d’attaque. Veillez à maintenir les dépendances à jour et à utiliser des versions qui ne contiennent aucune vulnérabilité connue.
Les scénarios courants de panique incluent :
- Division par zéro : Rust provoque une panique lors d’une tentative de division par zéro. Vérifiez donc toujours que le diviseur n’est pas nul avant d’effectuer une division
- Indice de tableau hors limites : l’accès à un tableau avec un indice qui dépasse ses limites provoque une panique. Pour atténuer ce risque, utilisez des méthodes qui renvoient un type Option (comme get) afin d’accéder aux éléments du tableau en toute sécurité
- Extraction de valeurs None : l’appel de .unwrap() sur une Option contenant une valeur None provoque une panique. Utilisez toujours le filtrage par motif ou des méthodes comme unwrap_or, unwrap_or_else, ou l’opérateur ? dans les fonctions qui renvoient un Result
Pour atténuer les problèmes liés aux paniques, il est essentiel d’éviter les opérations qui les provoquent, de valider toutes les entrées et les conditions susceptibles d’entraîner des opérations problématiques, et d’utiliser les types Result et Option pour gérer les erreurs. En outre, la rédaction de tests complets pour le programme permet de détecter et de corriger les scénarios de panique potentiels avant le déploiement.
Collisions de seeds
La vulnérabilité
Les collisions de seeds se produisent lorsque différentes entrées (c’est-à-dire des seeds et des identifiants de programme) utilisées pour générer un PDA aboutissent à la même adresse de PDA. Cela pose problème lorsque des PDA sont utilisés à différentes fins dans un programme, car des comportements inattendus peuvent en résulter, notamment des attaques par déni de service ou une compromission totale.
Exemple de scénario
Prenons un programme destiné à une plateforme de vote décentralisée portant sur différentes propositions et initiatives. Chaque session de vote pour une proposition ou une initiative donnée est créée avec un identifiant unique, et les utilisateurs soumettent leurs votes. Le programme utilise des PDA à la fois pour les sessions de vote et pour les votes individuels :
// 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>,
}Dans ce scénario, un attaquant tenterait de façonner soigneusement une session de vote qui, lorsqu’elle est combinée à la seed statique "session", produirait un PDA correspondant par coïncidence au PDA généré pour une autre session de vote. La création délibérée d’un PDA qui entre en collision avec celui d’une autre session de vote pourrait perturber le fonctionnement de la plateforme, par exemple en empêchant des votes légitimes sur des propositions ou l’ajout de nouvelles initiatives à la plateforme, puisque l’environnement d’exécution de Solana ne peut pas distinguer les PDA en collision.
Mesure d’atténuation recommandée
Pour atténuer le risque de collisions de seeds, les développeurs peuvent :
- Utiliser des préfixes uniques pour les seeds des différents PDA d’un même programme. Cette approche contribue à garantir que les PDA restent distincts
- Utiliser des identifiants uniques (par exemple, des horodatages, des identifiants d’utilisateur ou des valeurs de nonce) pour garantir qu’un PDA unique est généré à chaque fois
- Vérifier par programmation qu’un PDA généré n’entre pas en collision avec des PDA existants
Usurpation de type
La vulnérabilité
L’usurpation de type est une vulnérabilité dans laquelle un type de compte est présenté comme un autre en raison de l’absence de vérifications de type lors de la désérialisation. Cela peut entraîner l’exécution d’actions non autorisées ou la corruption de données, car le programme agit en supposant à tort le rôle ou les autorisations du compte. Vérifiez toujours explicitement le type attendu du compte lors de la désérialisation.
Exemple de scénario
Prenons un programme qui gère l’accès aux opérations d’administration en fonction du rôle d’un utilisateur. Chaque compte utilisateur comprend un discriminateur de rôle permettant de distinguer les utilisateurs ordinaires des administrateurs. Le programme contient une fonction de mise à jour des paramètres d’administration réservée aux administrateurs. Toutefois, il ne vérifie pas le discriminateur du compte et désérialise les données du compte utilisateur sans confirmer que le compte appartient à un administrateur :
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,
}Le problème est que update_admin_settings désérialise le compte utilisateur transmis sans vérifier son discriminateur de rôle, notamment parce qu’il manque un champ discriminateur dans la structure User !
Mesure d’atténuation recommandée
Pour atténuer ce problème, les développeurs peuvent ajouter un champ discriminateur à la structure User et le vérifier pendant le processus de désérialisation :
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 simplifie l’atténuation des vulnérabilités d’usurpation de type en gérant automatiquement les discriminateurs des types de comptes. Cette opération repose sur le wrapper Account<'info, T>, grâce auquel Anchor garantit la sûreté des types en vérifiant automatiquement le discriminateur lors de la désérialisation. Les développeurs peuvent ainsi se concentrer davantage sur la logique métier de leur programme plutôt que d’implémenter manuellement différentes vérifications de type.
Conclusion
On ne saurait trop insister sur l’importance de la sécurité des programmes. Cet article a passé en revue tout l’éventail des vulnérabilités courantes, des erreurs propres à Rust aux complexités de la méthode realloc d’Anchor. La maîtrise de chacune de ces vulnérabilités, et plus généralement de la sécurité des programmes, est un processus continu qui exige un apprentissage, une adaptation et une collaboration constants. En tant que développeurs, notre engagement en faveur de la sécurité ne consiste pas seulement à protéger les actifs : il s’agit aussi d’instaurer la confiance, de garantir l’intégrité de nos applications et de contribuer à la croissance et à la stabilité de Solana.
Si vous avez lu jusqu’ici, merci, anon ! Saisissez votre adresse e-mail ci-dessous pour ne manquer aucune actualité sur les nouveautés de Solana. Prêt à aller plus loin ? Découvrez les derniers articles sur le blog Helius et poursuivez votre aventure avec Solana dès aujourd’hui.
Ressources supplémentaires
Articles associés
Abonnez-vous à Helius
Suivez les dernières actualités du développement sur Solana et recevez une notification à chaque publication


