Securite des programmes Solana#
La securite est le sujet le plus critique du developpement on-chain. Un programme Solana deploye sur le mainnet est accessible a quiconque dispose d’une connexion Internet : n’importe qui peut forger une transaction arbitraire, passer n’importe quels comptes en parametre et appeler n’importe quelle instruction. Dans ce contexte adversarial, chaque hypothese implicite du developpeur devient une surface d’attaque potentielle. Les exploits les plus devastateurs de l’ecosysteme — Wormhole (320 M\(), Mango Markets (114 M\)), Cashio (48 M$) — ont tous exploite des failles qui auraient pu etre evitees par des verifications rigoureuses.
Anchor fournit un filet de securite considerable, mais ce filet n’est pas infaillible. Certaines classes de vulnerabilites — depassements arithmetiques, erreurs de logique metier, attaques de reinitialisation — echappent aux protections du framework et relevent de la responsabilite du developpeur.
Ce chapitre dresse un panorama complet de la securite des programmes Solana : modele de menaces, vulnerabilites classiques (avec code vulnerable et corrige), attaques specifiques a Solana, puis outils d’audit et patterns defensifs.
Modele de menaces#
Avant de cataloguer les vulnerabilites, il faut comprendre le cadre adversarial dans lequel evolue un programme Solana.
Définition 132 (Modele de menaces Solana)
Le modele de menaces (threat model) d’un programme Solana repose sur trois principes :
N’importe qui peut forger une transaction. Tout utilisateur peut construire une transaction qui appelle n’importe quelle instruction de n’importe quel programme.
N’importe quels comptes peuvent etre passes en parametre. L’appelant choisit librement les comptes inclus dans la transaction.
Les donnees d’instruction sont arbitraires. L’appelant peut envoyer n’importe quels octets dans le champ
data.
Le corollaire est la regle d’or : ne jamais faire confiance aux donnees fournies par le client. Toute donnee doit etre verifiee on-chain.
Remarque 104 (Le filet de securite d’Anchor)
Anchor automatise de nombreuses verifications qui, en Solana natif, doivent etre ecrites manuellement :
Verification du proprietaire :
Account<'info, T>verifie que le compte est possede par le programme courant.Verification du signataire :
Signer<'info>verifie que le compte a signe la transaction.Verification du discriminateur : la deserialisation verifie que les 8 premiers octets correspondent au type attendu.
Verification de la mutabilite :
#[account(mut)]verifie que le compte est marque writable.
Cependant, Anchor ne peut pas verifier la logique metier. Les conditions du type « le montant ne depasse pas le solde » ou « le delai n’est pas expire » restent de la responsabilite du developpeur. Un programme Anchor mal ecrit reste vulnerable.
Définition 133 (Principe de verification exhaustive)
Le principe de verification exhaustive stipule que chaque instruction doit valider toutes les conditions necessaires a son execution correcte. Pour chaque compte, le programme doit verifier : qui (signataire ?), quoi (bon type/discriminateur ?), d’ou (bon proprietaire ?), pourquoi (conditions metier respectees ?). L’omission de l’une de ces verifications constitue une vulnerabilite.
Vulnerabilites classiques#
Chaque vulnerabilite est illustree par du code vulnerable et sa correction.
Absence de verification de signataire#
Définition 134 (Missing signer check)
L”absence de verification de signataire (missing signer check) se produit lorsqu’une instruction modifie l’etat d’un compte sans verifier que l’appelant a signe la transaction. Un attaquant peut appeler l’instruction en fournissant la cle publique de la victime sans la signature correspondante.
// VULNERABLE : pas de verification de signataire
#[derive(Accounts)]
pub struct Withdraw<'info> {
#[account(mut)]
pub vault: Account<'info, Vault>,
/// CHECK: non verifie !
pub authority: AccountInfo<'info>,
#[account(mut)]
pub destination: SystemAccount<'info>,
}
La correction consiste a remplacer AccountInfo par Signer.
// CORRIGE : Signer impose la verification de signature
#[derive(Accounts)]
pub struct Withdraw<'info> {
#[account(mut, has_one = authority)]
pub vault: Account<'info, Vault>,
pub authority: Signer<'info>,
#[account(mut)]
pub destination: SystemAccount<'info>,
}
Absence de verification de proprietaire#
Définition 135 (Missing owner check)
L”absence de verification de proprietaire (missing owner check) se produit lorsqu’une instruction accepte un compte sans verifier qu’il est possede par le programme attendu. Un attaquant peut creer un compte avec des donnees formatees identiquement mais possede par un autre programme, contournant les verifications.
// VULNERABLE : UncheckedAccount ne verifie ni owner ni type
#[derive(Accounts)]
pub struct ProcessReward<'info> {
/// CHECK: on fait confiance au client
pub user_account: AccountInfo<'info>,
#[account(mut)]
pub reward_pool: Account<'info, RewardPool>,
}
// CORRIGE : Account<'info, T> verifie owner + discriminateur
#[derive(Accounts)]
pub struct ProcessReward<'info> {
pub user_account: Account<'info, UserAccount>,
#[account(mut)]
pub reward_pool: Account<'info, RewardPool>,
}
Remarque 105 (AccountInfo vs Account)
AccountInfo<'info> est le type brut du runtime : il ne verifie ni proprietaire, ni discriminateur. Account<'info, T> d’Anchor verifie automatiquement que le proprietaire est le programme courant, que les 8 premiers octets correspondent au discriminateur de T, et que les donnees se deserialisent correctement. Regle : ne jamais utiliser AccountInfo sauf necessaire, avec un commentaire /// CHECK:.
Depassement arithmetique#
Définition 136 (Arithmetic overflow/underflow)
Un depassement arithmetique (overflow) se produit lorsqu’une operation sur un entier non signe depasse \(2^{64} - 1\) et « revient a zero ». Un sous-depassement (underflow) est le phenomene inverse. Dans les deux cas, le programme continue avec une valeur incorrecte, permettant a un attaquant de manipuler des soldes.
// VULNERABLE : l'addition peut depasser u64::MAX
pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
let vault = &mut ctx.accounts.vault;
vault.total_deposited = vault.total_deposited + amount;
Ok(())
}
// CORRIGE : checked_add retourne None en cas de depassement
pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
let vault = &mut ctx.accounts.vault;
vault.total_deposited = vault.total_deposited
.checked_add(amount)
.ok_or(ProgramError::ArithmeticOverflow)?;
Ok(())
}
Remarque 106 (Comportement de Rust selon le mode de compilation)
En mode debug, Rust provoque un panic en cas de depassement. En mode release, les depassements sont silencieux (arithmetique modulaire). Les programmes Solana sont compiles en mode release pour le runtime BPF/SBF : les depassements sont donc silencieux par defaut. Il faut imperativement utiliser checked_add, checked_sub, checked_mul et checked_div.
Exemple 46 (Scenario d’exploitation d’un overflow)
Supposons un coffre (vault) avec un solde total_deposited = u64::MAX - 100 (une valeur enorme mais theoriquement possible). Un attaquant appelle deposit(200). Sans checked_add, le calcul produit : \((2^{64} - 101) + 200 = 99\) (modulo \(2^{64}\)). Le solde total passe de \(\sim 1.8 \times 10^{19}\) a 99, ce qui pourrait permettre a l’attaquant de retirer des fonds en faisant croire que le coffre est presque vide. Avec checked_add, l’instruction echoue proprement.
Type cosplay (substitution de compte)#
Définition 137 (Type cosplay / account substitution)
Le type cosplay est une attaque ou un attaquant passe un compte du mauvais type a une instruction. Sans verification du discriminateur, le programme deserialisera les octets comme s’il s’agissait du bon type. Un compte UserProfile pourrait etre passe la ou un AdminConfig est attendu, donnant a l’attaquant des privileges d’administrateur.
// VULNERABLE : UncheckedAccount + deserialisation manuelle
#[derive(Accounts)]
pub struct UpdateConfig<'info> {
/// CHECK: deserialise manuellement
#[account(mut)]
pub config: AccountInfo<'info>,
pub admin: Signer<'info>,
}
pub fn update_config(ctx: Context<UpdateConfig>, new_fee: u16) -> Result<()> {
// Deserialisation manuelle sans verification de discriminateur
let mut data = ctx.accounts.config.try_borrow_mut_data()?;
let admin_key = Pubkey::deserialize(&mut &data[8..40])?;
require!(admin_key == ctx.accounts.admin.key(), CustomError::Unauthorized);
// ... modification de la config
Ok(())
}
// CORRIGE : Account<'info, T> verifie le discriminateur automatiquement
#[derive(Accounts)]
pub struct UpdateConfig<'info> {
#[account(mut, has_one = admin)]
pub config: Account<'info, ProgramConfig>,
pub admin: Signer<'info>,
}
Remarque 107 (Le role du discriminateur)
Le discriminateur Anchor est sha256("account:<NomDuType>")[..8]. Deux types differents ont des discriminateurs differents avec une probabilite \(1 - 2^{-64}\). Account<'info, T> verifie automatiquement cette correspondance, rendant le type cosplay infaisable.
Absence de verification rent-exempt#
Définition 138 (Missing rent-exempt check)
L”absence de verification d’exemption de loyer se produit lorsqu’un compte n’a pas assez de lamports pour etre rent-exempt. Il peut etre supprime par le runtime, entrainant la perte des donnees. Anchor gere cette verification automatiquement avec init et realloc. Le risque existe principalement dans les programmes natifs ou lors de transferts manuels de lamports.
Attaques specifiques a Solana#
L’architecture de Solana introduit des vecteurs d’attaque specifiques lies a son modele de comptes et au cycle de vie des transactions.
Attaque de reinitialisation#
Définition 139 (Attaque de reinitialisation)
L”attaque de reinitialisation se produit lorsqu’un attaquant appelle initialize une seconde fois sur un compte deja initialise. Il peut ecraser l’autorite du compte et prendre le controle des fonds.
// VULNERABLE : pas de verification d'initialisation precedente
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
let config = &mut ctx.accounts.config;
// Un attaquant peut rappeler cette instruction et devenir admin !
config.admin = ctx.accounts.admin.key();
config.fee = 100;
config.is_initialized = true;
Ok(())
}
// CORRIGE (methode 1) : verification manuelle du flag
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
let config = &mut ctx.accounts.config;
require!(!config.is_initialized, CustomError::AlreadyInitialized);
config.admin = ctx.accounts.admin.key();
config.fee = 100;
config.is_initialized = true;
Ok(())
}
Exemple 47 (Protection par la contrainte init d’Anchor)
La methode la plus sure pour prevenir la reinitialisation est d’utiliser la contrainte init d’Anchor, qui echoue automatiquement si le compte existe deja :
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(
init, // echoue si le compte existe deja
payer = admin,
space = 8 + 32 + 2 + 1,
seeds = [b"config"],
bump,
)]
pub config: Account<'info, ProgramConfig>,
#[account(mut)]
pub admin: Signer<'info>,
pub system_program: Program<'info, System>,
}
Avec init, toute tentative de reinitialisation provoque une erreur AccountAlreadyInUse au niveau du System Program.
### Reanimation de compte ferme
````{prf:definition} Attaque de reanimation de compte
:label: ch16-def-account-revival
L'**attaque de reanimation** exploite le fait que la fermeture d'un compte n'est effective qu'a la **fin de la transaction**. Un attaquant peut, dans la meme transaction : (1) appeler une instruction qui ferme le compte, (2) re-crediter le compte avec des lamports. Le runtime ne supprime pas le compte car il possede encore des lamports. L'attaquant peut ensuite le reinitialiser.
Remarque 108 (Protection d’Anchor contre la reanimation)
La contrainte close d’Anchor effectue trois operations : transfert des lamports, mise a zero du discriminateur et assignation du proprietaire au System Program. Meme si un attaquant re-credite le compte, le discriminateur reste a zero et toute deserialisation echouera. C’est une defense en profondeur : la suppression logique empeche la reutilisation.
Substitution de PDA#
Définition 140 (Attaque par substitution de PDA)
L”attaque par substitution de PDA se produit lorsqu’un attaquant passe un PDA derive d’un programme different. Sans verification des seeds, le programme acceptera un compte dont la derivation provient d’un programme malveillant.
// VULNERABLE : pas de verification des seeds
#[derive(Accounts)]
pub struct ClaimReward<'info> {
/// CHECK: on suppose que c'est le bon PDA
pub reward_account: AccountInfo<'info>,
pub user: Signer<'info>,
}
// CORRIGE : la contrainte seeds verifie la derivation
#[derive(Accounts)]
pub struct ClaimReward<'info> {
#[account(
mut,
seeds = [b"reward", user.key().as_ref()],
bump,
)]
pub reward_account: Account<'info, RewardAccount>,
pub user: Signer<'info>,
}
Remarque 109 (Toujours specifier les seeds)
La contrainte seeds recalcule l’adresse PDA a partir des graines et du program_id courant. Un PDA derive d’un autre programme produira une adresse differente. Regle : toujours utiliser la contrainte seeds pour tout compte PDA.
Matrice de vulnerabilites#
La visualisation suivante presente une matrice de risque pour les vulnerabilites etudiees, classees par severite et frequence d’apparition dans les audits.
Audit et patterns defensifs#
Définition 141 (Pattern check-then-act)
Le pattern check-then-act impose de separer la validation de l’execution : (1) Check : valider toutes les conditions prealables, (2) Act : effectuer les modifications d’etat. Aucune modification ne doit etre effectuee si une condition n’est pas remplie.
Définition 142 (La macro require! et la contrainte constraint)
Anchor fournit deux mecanismes d’assertion : require!(condition, ErrorCode) dans le corps d’une instruction, et constraint = expr @ ErrorCode dans #[account(...)] pour la validation declarative. Les deux produisent des erreurs explicites dans les logs de transaction.
Exemple 48 (Codes d’erreur personnalises)
Des codes d’erreur personnalises avec des messages explicites facilitent le debogage et l’audit du programme :
#[error_code]
pub enum VaultError {
#[msg("Le montant du retrait depasse le solde du coffre.")]
InsufficientFunds,
#[msg("Seul l'administrateur peut modifier la configuration.")]
Unauthorized,
#[msg("Le coffre est en pause. Aucune operation n'est autorisee.")]
VaultPaused,
#[msg("Depassement arithmetique lors du calcul du montant.")]
ArithmeticOverflow,
#[msg("Le delai de verrouillage n'est pas encore expire.")]
LockNotExpired,
}
L'exemple suivant montre une instruction bien defendue combinant contraintes declaratives et verifications imperatives.
```rust
pub fn withdraw(ctx: Context<SecureWithdraw>, amount: u64) -> Result<()> {
require!(!ctx.accounts.config.paused, VaultError::VaultPaused);
require!(ctx.accounts.vault.balance >= amount, VaultError::InsufficientFunds);
let clock = Clock::get()?;
require!(clock.unix_timestamp >= ctx.accounts.vault.unlock_timestamp,
VaultError::LockNotExpired);
let vault = &mut ctx.accounts.vault;
vault.balance = vault.balance
.checked_sub(amount)
.ok_or(VaultError::ArithmeticOverflow)?;
// ... transfert de lamports via CPI ...
Ok(())
}
#[derive(Accounts)]
pub struct SecureWithdraw<'info> {
#[account(mut, has_one = authority,
seeds = [b"vault", authority.key().as_ref()], bump)]
pub vault: Account<'info, Vault>,
#[account(seeds = [b"config"], bump)]
pub config: Account<'info, VaultConfig>,
pub authority: Signer<'info>,
#[account(mut)]
pub destination: SystemAccount<'info>,
pub system_program: Program<'info, System>,
}
```
````{prf:remark} Principe du moindre privilege
:label: ch16-rem-least-privilege
Le **principe du moindre privilege** s'applique aux contraintes Anchor : ne marquer `mut` que les comptes reellement modifies, ne marquer `Signer` que si la signature est necessaire, et preferer les PDA aux comptes utilisateur pour le stockage d'etat. Chaque attribut supplementaire elargit la surface d'attaque.
Outils de securite#
L’ecosysteme Solana dispose d’outils et de services specialises pour la verification et l’audit des programmes.
Définition 143 (Outils de verification et d’analyse statique)
Les principaux outils de securite pour les programmes Solana sont :
anchor verify: verifie que le bytecode deploye correspond exactement au code source en recompilant et comparant les hash.Sec3 (X-Ray) : scanner automatise qui detecte des patterns vulnerables connus dans le code source Rust.
Soteria : outil d’analyse statique qui identifie les chemins d’execution ou des verifications sont absentes.
Ces outils ne remplacent pas un audit humain, mais detectent les vulnerabilites les plus courantes.
Définition 144 (Cabinets d’audit)
Les principaux cabinets d’audit specialises Solana sont : OtterSec (Marinade, Jupiter, Tensor), Neodyme (recherche en securite, decouverte de vulnerabilites critiques), Trail of Bits (cabinet generaliste de renommee mondiale) et Halborn (audits multi-chain). Un audit professionnel est indispensable avant tout deploiement mainnet gerant des fonds significatifs.
Remarque 110 (Checklist de securite avant deploiement mainnet)
Avant de deployer un programme sur le mainnet, verifier systematiquement les points suivants :
Tous les comptes ont des types stricts : pas de
AccountInfosans justification documentee.Tous les signataires sont verifies : chaque modification d’etat requiert une signature.
Toutes les operations arithmetiques utilisent
checked_*: aucun+,-,*ou/direct sur des valeurs controlees par l’utilisateur.Tous les PDA ont des contraintes
seeds: la derivation est verifiee on-chain.L’initialisation est protegee :
initd’Anchor ou flagis_initializedverifie manuellement.La fermeture utilise
close: le discriminateur est mis a zero.Les erreurs sont explicites : codes d’erreur personnalises avec messages descriptifs.
Le programme est
anchor verify-able : le code deploye correspond aux sources.Les tests couvrent les cas adversariaux : tentatives de reinitialisation, de retrait non autorise, de depassement.
Un audit externe a ete realise : pour tout programme gerant plus de quelques milliers de dollars.
Arbre d’attaque : reinitialisation#
La visualisation suivante montre le deroulement d’une attaque de reinitialisation sous forme d’arbre d’attaque, depuis la reconnaissance initiale jusqu’a l’exploitation.
Resume#
Ce chapitre a couvert les menaces, les vulnerabilites et les defenses qui constituent le socle de la securite des programmes Solana.
Vulnerabilite |
Description |
Correction |
|---|---|---|
Missing signer |
L’instruction ne verifie pas la signature de l’appelant |
Utiliser |
Missing owner |
Le compte n’est pas verifie comme appartenant au bon programme |
Utiliser |
Arithmetic overflow |
Les operations |
Utiliser |
Type cosplay |
Un compte du mauvais type est passe a l’instruction |
Utiliser |
Missing rent-exempt |
Le compte peut etre supprime par le runtime |
Utiliser |
Reinitialisation |
L’instruction |
Utiliser |
Reanimation de compte |
Un compte ferme est re-credite dans la meme transaction |
Utiliser |
Substitution de PDA |
Un PDA derive d’un autre programme est passe |
Utiliser la contrainte |
Pattern defensif |
Description |
|---|---|
Check-then-act |
Valider toutes les conditions avant de modifier l’etat |
|
Assertions explicites avec codes d’erreur personnalises |
Moindre privilege |
Ne marquer |
|
Verifier la correspondance entre le code source et le bytecode deploye |
Audit externe |
Faire auditer le programme par un cabinet specialise avant le mainnet |