
Per Anhalter durch die Sicherheit von Solana-Programmen
Inhaltsverzeichnis
- Einführung
- Die Denkweise von Angreifern beim Ausnutzen von Solana-Programmen
- Das Programmiermodell von Solana
- Solana wird von Angreifern kontrolliert
- Potenzielle Angriffsvektoren
- Gegenmaßnahmen
- Abgleich von Account-Daten
- Die Schwachstelle
- Beispielszenario
- Empfohlene Gegenmaßnahme
- Neuzuweisung von Account-Daten
- Die Schwachstelle
- Beispielszenario
- Empfohlene Gegenmaßnahme
- Neuladen von Accounts
- Die Schwachstelle
- Beispielszenario
- Empfohlene Gegenmaßnahme
- Beliebige CPI
- Die Schwachstelle
- Beispielszenario
- Empfohlene Gegenmaßnahme
- Übertragung von Berechtigungen
- Die Schwachstelle
- Beispielszenario
- Empfohlene Gegenmaßnahme
- Kanonisierung des Bump Seeds
- Die Schwachstelle
- Beispielszenario
- Empfohlene Gegenmaßnahme
- Konten schließen
- Die Schwachstelle
- Beispielszenario
- Empfohlene Gegenmaßnahme
- Doppelte veränderbare Konten
- Die Schwachstelle
- Beispielszenario
- Empfohlene Gegenmaßnahme
- Front-Running
- Die Schwachstelle
- Beispielszenario
- Empfohlene Gegenmaßnahme
- Unsichere Initialisierung
- Unsicheres Beispiel und Gegenmaßnahme
- Präzisionsverlust
- Die Schwachstelle
- Multiplikation nach Division
- Arithmetische
- Rundungsfehler
- Fehlende Eigentümerprüfung
- Die Schwachstelle
- Beispielszenario
- Empfohlene Gegenmaßnahme
- Schreibgeschützte Konten
- Fehlende Signaturprüfung
- Die Schwachstelle
- Beispielszenario
- Empfohlene Gegenmaßnahme
- Überlauf und Unterlauf
- Die Schwachstelle
- Beispielszenario
- Empfohlene Gegenmaßnahme
- Typumwandlung
- Gemeinsame Nutzung von PDAs
- Die Schwachstelle
- Beispielszenario
- Empfohlene Gegenmaßnahme
- Verbleibende Accounts
- Die Schwachstelle
- Beispielszenario
- Empfohlene Gegenmaßnahme
- Rust-spezifische Fehler
- Unsicheres Rust
- Panics und Fehlerbehandlung
- Seed-Kollisionen
- Die Schwachstelle
- Beispielszenario
- Empfohlene Gegenmaßnahme
- Typverwechslung
- Die Schwachstelle
- Beispielszenario
- Empfohlene Gegenmaßnahme
- Fazit
- Zusätzliche Ressourcen
Dieser Artikel entstand gemeinsam mit bl0ckpain, einem Sicherheitsforscher und Smart-Contract-Entwickler, der zuvor bei Kudelski Security und Halborn gearbeitet hat.
Einführung
Bei der Sicherheit von Solana-Programmen geht es nicht nur darum, Hacker davon abzuhalten, die Mittel eines Projekts zu stehlen. Es muss auch sichergestellt sein, dass sich ein Programm wie vorgesehen verhält und die Projektspezifikationen sowie die Erwartungen der Nutzer erfüllt. Die Sicherheit von Solana-Programmen kann die Performance, Skalierbarkeit und Interoperabilität einer dApp beeinflussen. Entwickler müssen daher potenzielle Angriffsvektoren und häufige Schwachstellen kennen, bevor sie Anwendungen für Endnutzer entwickeln.
Dieser Artikel behandelt häufige Schwachstellen, auf die Entwickler beim Erstellen von Solana-Programmen stoßen. Zunächst führen wir in die Denkweise von Angreifern ein, die Solana-Programme ausnutzen wollen. Dabei geht es um das Programmiermodell von Solana, die inhärente Kontrolle durch Angreifer, mögliche Angriffsvektoren und gängige Gegenmaßnahmen. Anschließend betrachten wir verschiedene Schwachstellen. Wir erklären jede davon und zeigen, wo möglich, unsichere und sichere Codebeispiele.
Dieser Artikel richtet sich an Leser mit mittleren oder fortgeschrittenen Kenntnissen, da er Wissen über das Programmiermodell und die Programmentwicklung auf Solana voraussetzt.
Dieser Artikel erklärt weder die Entwicklung eines Programms noch Solana-spezifische Konzepte. Wir konzentrieren uns darauf, häufige Schwachstellen zu untersuchen und zu lernen, wie sie sich entschärfen lassen. Wenn Solana neu für dich ist, empfehlen wir dir, zuerst diese früheren Blogbeiträge zu lesen:
Die Denkweise von Angreifern beim Ausnutzen von Solana-Programmen
Das Programmiermodell von Solana
Das Programmiermodell von Solana prägt die Sicherheitslandschaft der Anwendungen, die in seinem Netzwerk entwickelt werden. Auf Solana dienen Accounts als Container für Daten, ähnlich wie Dateien auf einem Computer. Accounts lassen sich in zwei allgemeine Typen unterteilen: ausführbare und nicht ausführbare. Ausführbare Accounts, auch Programme genannt, können Code ausführen. Nicht ausführbare Accounts speichern Daten, können aber keinen Code ausführen, da sie keinen Code enthalten. Durch diese Trennung von Code und Daten sind Programme zustandslos. Sie interagieren mit Daten, die in anderen Accounts gespeichert und während Transaktionen per Referenz übergeben werden.
Solana wird von Angreifern kontrolliert
Eine Transaktion gibt das aufzurufende Programm, eine Liste von Accounts und ein Byte-Array mit Instruktionsdaten an. Bei diesem Modell muss das Programm die Accounts und Instruktionen analysieren und interpretieren, die eine Transaktion bereitstellt. Da jeder beliebige Account an die Funktion eines Programms übergeben werden kann, erhalten Angreifer erhebliche Kontrolle über die Daten, mit denen das Programm arbeitet. Das Verständnis dieses inhärent von Angreifern kontrollierten Programmiermodells ist entscheidend für die Entwicklung sicherer Solana-Programme.
Da Angreifer jeden beliebigen Account an eine Programmfunktion übergeben können, ist die Datenvalidierung eine grundlegende Säule der Sicherheit von Solana-Programmen. Entwickler müssen sicherstellen, dass ihr Programm legitime von bösartigen Eingaben unterscheiden kann. Dazu gehört zu prüfen, wem ein Account gehört, ob er dem erwarteten Typ entspricht und ob er ein Signer ist.
Potenzielle Angriffsvektoren
Das einzigartige Programmiermodell und die Ausführungsumgebung von Solana schaffen spezifische Angriffsvektoren. Entwickler müssen diese Vektoren verstehen, um ihre Programme vor möglichen Exploits zu schützen. Dazu gehören:
- Logikfehler: Fehler in der Programmlogik können manipuliert werden und unbeabsichtigtes Verhalten verursachen, etwa den Verlust von Assets oder unbefugten Zugriff. Dazu gehört auch, Projektspezifikationen nicht korrekt umzusetzen. Wenn ein Programm behauptet, x zu tun, muss es x mit all seinen Besonderheiten tun
- Fehler bei der Datenvalidierung: Eine unzureichende Validierung von Eingabedaten kann Angreifern erlauben, bösartige Daten zu übergeben und den Programmzustand oder die Ausführung zu manipulieren
- Rust-spezifische Probleme: Trotz der Sicherheitsfunktionen von Rust können unsichere Codeblöcke, Nebenläufigkeitsprobleme und Panics Schwachstellen verursachen
- Schwachstellen in der Zugriffskontrolle: Werden Prüfungen der Zugriffskontrolle nicht korrekt umgesetzt, etwa die Verifizierung des Eigentümers eines Accounts, kann ein bösartiger Akteur unbefugte Aktionen ausführen
- Rechen- und Präzisionsfehler: Overflows, Underflows und Präzisionsfehler können für finanzielle Gewinne ausgenutzt werden oder Fehlfunktionen eines Programms verursachen
- Probleme mit Cross-Program Invocation (CPI): Fehler beim Umgang mit CPIs können unerwartete Zustandsänderungen oder Fehler verursachen, wenn sich ein aufgerufenes Programm bösartig oder unerwartet verhält
- Fehlverwendung von Program Derived Addresses (PDAs): Eine fehlerhafte Erzeugung oder Verarbeitung von PDAs kann Schwachstellen schaffen, durch die Angreifer PDAs übernehmen oder fälschen, um unbefugten Zugriff zu erhalten oder vom Programm kontrollierte Accounts zu manipulieren
Beachte, dass Reentrancy auf Solana durch das Ausführungsmodell grundsätzlich eingeschränkt ist. Die Solana-Runtime begrenzt CPIs auf eine maximale Tiefe von vier und erzwingt strenge Account-Regeln. Beispielsweise darf nur der Eigentümer eines Accounts dessen Daten ändern. Diese Einschränkungen verhindern Reentrancy-Angriffe, indem sie direkte Selbstrekursion begrenzen und sicherstellen, dass ein Programm nicht unfreiwillig in einem Zwischenzustand aufgerufen werden kann.
Gegenmaßnahmen
Um diese potenziellen Angriffe zu entschärfen, sollten Entwickler gründliche Tests, Code-Audits und bewährte Verfahren kombinieren:
- Implementiere eine umfassende Eingabevalidierung und Prüfungen der Zugriffskontrolle
- Nutze das Typsystem und die Sicherheitsfunktionen von Rust vollständig und vermeide unsicheren Code, sofern er nicht notwendig ist
- Halte dich an bewährte Sicherheitsverfahren für Solana und Rust und informiere dich über neue Entwicklungen
- Führe interne Code-Reviews durch und nutze automatisierte Tools, um während der Programmentwicklung häufige Schwachstellen und Logikfehler zu erkennen
- Lass deine Codebasis von renommierten Dritten prüfen, darunter Sicherheitsfirmen und unabhängige Sicherheitsforscher
- Erstelle eine Bug-Bounty-Plattform für dein Programm, um Anreize für das Melden von Schwachstellen zu schaffen, statt dich auf Grey Hats zu verlassen
Die folgenden Abschnitte behandeln verschiedene Schwachstellen in alphabetischer Reihenfolge. Jeder Abschnitt beschreibt eine potenzielle Schwachstelle, erklärt geeignete Gegenmaßnahmen und zeigt nach Möglichkeit Beispielszenarien.
Abgleich von Account-Daten
Die Schwachstelle
Eine Schwachstelle beim Abgleich von Account-Daten entsteht, wenn Entwickler nicht prüfen, ob die in einem Account gespeicherten Daten den erwarteten Werten entsprechen. Ohne korrekte Prüfungen der Datenvalidierung arbeitet ein Programm möglicherweise unbeabsichtigt mit falschen oder bösartig ausgetauschten Accounts. Diese Schwachstelle ist besonders kritisch bei Prüfungen von Berechtigungen.
Beispielszenario
Betrachte ein Programm zur Verwaltung seiner administrativen Einstellungen. Das Programm enthält eine Instruktion, um die aktuelle administrative Konfiguration zu aktualisieren, etwa Feature-Flags oder Betriebsparameter. Die Instruktion muss prüfen, ob die Anfrage von einem autorisierten Administrator stammt. Das Programm prüft jedoch nicht, ob der Account, der die Änderung anfordert, mit dem in den Konfigurationsdaten gespeicherten Administrator-Account übereinstimmt:
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
}Empfohlene Gegenmaßnahme
Um diese Schwachstelle zu entschärfen, können Entwickler explizite Prüfungen implementieren, die Account-Schlüssel und gespeicherte Daten mit den erwarteten Werten vergleichen. Prüfe beispielsweise, ob der öffentliche Schlüssel des Einzahlers mit dem Eigentümerfeld des für die Einzahlung verwendeten Token-Accounts übereinstimmt:
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(())
}Entwickler können auch die Anchor-Attribute has_one und constraint verwenden, um Prüfungen der Datenvalidierung deklarativ durchzusetzen. Im obigen Beispiel könnten wir mit dem Attribut constraint prüfen, ob der öffentliche Schlüssel des Einzahlers dem Eigentümer des Token-Accounts für die Einzahlung entspricht:
pub struct UpdateAdminSettings<'info> {
#[account(
mut,
constraint = config_data.admin == admin.key()
)]
pub config_data: Account<'info, ConfigData>,
pub admin: Signer<'info>,
}Neuzuweisung von Account-Daten
Die Schwachstelle
In Anchor führt die von der Struktur AccountInfo bereitgestellte Funktion realloc eine komplexe Schwachstelle im Zusammenhang mit der Speicherverwaltung ein. Mit dieser Funktion lässt sich die Datengröße eines Accounts neu zuweisen, was für die dynamische Datenverarbeitung in Programmen nützlich sein kann. Eine unsachgemäße Verwendung von realloc kann jedoch unbeabsichtigte Folgen haben, darunter verschwendete Compute Units oder die mögliche Offenlegung veralteter Daten.
Die Methode realloc hat zwei Parameter:
- new_len: ein usize, das die neue Länge der Account-Daten festlegt
- zero_init: ein bool, das bestimmt, ob der neue Speicherbereich mit Nullen initialisiert werden soll
realloc ist wie folgt definiert:
pub fn realloc(
&self,
new_len: usize,
zero_init: bool
) -> Result<(), ProgramError>Der für Account-Daten zugewiesene Speicher wird bereits am Einstiegspunkt des Programms mit Nullen initialisiert. Wenn Daten innerhalb einer einzelnen Transaktion auf eine größere Größe neu zugewiesen werden, ist der neue Speicherbereich daher bereits mit Nullen gefüllt. Eine erneute Initialisierung ist unnötig und verbraucht zusätzliche Compute Units. Wird der Speicher dagegen innerhalb derselben Transaktion zuerst verkleinert und anschließend wieder vergrößert, könnten veraltete Daten offengelegt werden, wenn zero_init auf false gesetzt ist.
Beispielszenario
Betrachte ein dynamisches Aufgabenlistenprogramm, in dem Nutzer innerhalb einer einzelnen Transaktion Einträge hinzufügen, entfernen oder ändern können. Dieses Programm muss seine Datengröße abhängig von den Nutzeraktionen dynamisch neu zuweisen:
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
}In diesem Szenario könnte die Funktion modify_todo_list to_do_list_data mehrfach neu zuweisen, um die für die Änderungen erforderliche Größe bereitzustellen. Wird die Datengröße zum Entfernen eines Aufgabeneintrags reduziert und anschließend innerhalb derselben Transaktion wieder erhöht, um neue Einträge hinzuzufügen, könnte das Setzen von zero_init auf false veraltete Daten offenlegen.
Empfohlene Gegenmaßnahme
Um dieses Problem zu entschärfen, musst du den Parameter zero_init umsichtig verwenden:
- Setze
zero_initauftrue, wenn du die Datengröße nach einer vorherigen Verkleinerung innerhalb desselben Transaktionsaufrufs erhöhst. Dadurch wird jeder neue Speicherbereich mit Nullen initialisiert und verhindert, dass veraltete Daten offengelegt werden - Setze
zero_initauffalse, wenn du die Datengröße ohne vorherige Verkleinerung im selben Transaktionsaufruf erhöhst, da der Speicher bereits mit Nullen initialisiert ist
Statt Daten neu zuzuweisen, um bestimmte Größenanforderungen zu erfüllen, sollten Entwickler Address Lookup Tables (ALTs) verwenden. Mit ALTs können Entwickler die Daten einer Transaktion komprimieren, indem sie bis zu 256 Adressen in einem einzelnen On-Chain-Account speichern. Jede Adresse in der Tabelle lässt sich anschließend über einen 1-Byte-Index referenzieren. Dadurch sinkt die für Adressreferenzen in einer Transaktion benötigte Datenmenge erheblich. ALTs eignen sich deutlich besser für Szenarien, die dynamische Account-Interaktionen ohne häufige Größenänderungen des Speichers erfordern.
Neuladen von Accounts
Die Schwachstelle
Eine Schwachstelle beim Neuladen von Accounts entsteht, wenn Entwickler deserialisierte Accounts nach einer CPI nicht aktualisieren. Anchor aktualisiert den Zustand deserialisierter Accounts nach einer CPI nicht automatisch. Dadurch kann die Programmlogik mit veralteten Daten arbeiten, was zu Logikfehlern oder falschen Berechnungen führt.
Beispielszenario
Betrachte ein Protokoll, in dem Nutzer Token staken können, um im Laufe der Zeit Belohnungen zu verdienen. Das zugehörige Programm enthält Funktionen, um die Staking-Belohnungen eines Nutzers anhand bestimmter Bedingungen oder externer Auslöser zu aktualisieren. Die Belohnungen eines Nutzers werden über eine CPI zu einem Programm für die Verteilung von Belohnungen berechnet und aktualisiert. Das Programm aktualisiert den ursprünglichen Staking-Account nach der CPI jedoch nicht, um den neuen Belohnungssaldo abzubilden:
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,
}In diesem Beispiel versucht die Funktion update_rewards, die Belohnungen für den Staking-Account eines Nutzers durch einen CPI-Aufruf an ein Programm zur Verteilung von Belohnungen zu aktualisieren. Zunächst protokolliert das Programm nach der CPI ctx.accounts.staking_account.rewards, also den Belohnungssaldo. Anschließend fährt es mit Logik fort, die die veralteten Daten aus ctx.accounts.staking_account.rewards verwendet. Das Problem besteht darin, dass der Zustand des Staking-Accounts nach der CPI nicht automatisch aktualisiert wird. Deshalb sind die Daten veraltet.
Empfohlene Gegenmaßnahme
Um dieses Problem zu entschärfen, rufe explizit die Anchor-Methode reload auf, um einen bestimmten Account erneut aus dem Speicher zu laden. Wenn du einen Account nach einer CPI neu lädst, wird sein Zustand korrekt abgebildet:
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(())
}Beliebige CPI
Die Schwachstelle
Beliebige CPIs entstehen, wenn ein Programm ein anderes Programm aufruft, ohne die Identität des Zielprogramms zu prüfen. Diese Schwachstelle besteht, weil die Solana-Runtime jedem Programm erlaubt, ein anderes Programm aufzurufen, sofern der Aufrufer die Programm-ID des aufgerufenen Programms kennt und dessen Schnittstelle einhält. Führt ein Programm CPIs anhand von Nutzereingaben aus, ohne die Programm-ID des aufgerufenen Programms zu validieren, könnte es Code in einem vom Angreifer kontrollierten Programm ausführen.
Beispielszenario
Betrachte ein Programm, das Teilnehmer anhand ihrer Beiträge zu einem Projekt belohnt. Nach der Verteilung der Belohnungen zeichnet das Programm die Details zu Prüf- und Nachverfolgungszwecken in einem separaten Ledger-Programm auf. Das Ledger-Programm gilt als vertrauenswürdig und stellt eine öffentliche Schnittstelle bereit, um bestimmte Einträge autorisierter Programme nachzuverfolgen. Das Programm enthält eine Funktion zum Verteilen und Erfassen von Belohnungen, die das Ledger-Programm als Account entgegennimmt. Die Funktion prüft ledger_program jedoch nicht, bevor sie eine CPI dorthin ausführt:
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>,
}Ein Angreifer könnte dies ausnutzen, indem er die ID eines bösartigen Programms als ledger_program übergibt. Das könnte unbeabsichtigte Folgen haben.
Empfohlene Gegenmaßnahme
Zum Schutz vor diesem Problem können Entwickler eine Prüfung ergänzen, die vor der CPI die Identität des Ledger-Programms verifiziert. Dadurch wird sichergestellt, dass der CPI-Aufruf an das vorgesehene Programm geht, und beliebige CPIs werden verhindert:
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>,
}Ein Programm kann über ein öffentlich verfügbares CPI-Modul verfügen, wenn es mit Anchor geschrieben wurde. Dadurch lässt es sich einfach und sicher aus einem anderen Anchor-Programm aufrufen. Das Anchor-CPI-Modul prüft automatisch, ob die übergebene Programmadresse mit der im Modul gespeicherten Programmadresse übereinstimmt. Alternativ lässt sich die Adresse fest im Code hinterlegen, statt sie vom Nutzer übergeben zu lassen.
Übertragung von Berechtigungen
Die Schwachstelle
Solana-Programme legen häufig bestimmte öffentliche Schlüssel als Autoritäten für kritische Funktionen fest, etwa für die Aktualisierung von Programmparametern oder das Abheben von Mitteln. Kann diese Autorität jedoch nicht an eine andere Adresse übertragen werden, entstehen erhebliche Risiken. Diese Einschränkung wird problematisch, wenn sich das Team ändert, das Protokoll verkauft wird oder die Autorität kompromittiert ist.
Beispielszenario
Betrachte ein Programm, in dem eine globale Administratorautorität über eine Funktion set_params bestimmte Protokollparameter festlegt. Das Programm enthält keinen Mechanismus, um den globalen Administrator zu ändern:
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
}Hier ist die Autorität statisch definiert und kann nicht auf eine neue Adresse aktualisiert werden.
Empfohlene Gegenmaßnahme
Ein sicherer Ansatz zur Entschärfung dieses Problems ist ein zweistufiger Prozess zur Übertragung der Autorität. Dabei kann die aktuelle Autorität eine neue pending_authority nominieren, die ihre Rolle ausdrücklich annehmen muss. Dies ermöglicht nicht nur die Übertragung der Autorität, sondern schützt auch vor versehentlichen Übertragungen oder bösartigen Übernahmen. Der Ablauf sieht so aus:
- Nominierung durch die aktuelle Autorität: Die aktuelle Autorität nominiert durch den Aufruf von nominate_new_authority eine neue pending_authority. Dadurch wird das Feld pending_authority im Programmzustand gesetzt
- Annahme durch die neue Autorität: Die nominierte pending_authority ruft accept_authority auf, um ihre neue Rolle anzunehmen. Dadurch wird die Autorität von der aktuellen Autorität auf pending_authority übertragen
Das könnte etwa so aussehen:
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
}In diesem Beispiel enthält die Account-Struktur ProgramState die aktuelle authority und eine optionale pending_authority. Der Kontext NominateAuthority stellt sicher, dass die aktuelle Autorität die Transaktion signiert, sodass sie eine neue Autorität nominieren kann. Der Kontext AcceptAuthority prüft, ob pending_authority mit dem Signer der Transaktion übereinstimmt. Dadurch kann dieser die Rolle annehmen und zur neuen Autorität werden. Dieser Aufbau gewährleistet eine sichere und kontrollierte Übertragung der Autorität innerhalb des Programms.
Kanonisierung des Bump Seeds
Die Schwachstelle
Die Kanonisierung des Bump Seeds bezeichnet die Verwendung des höchsten gültigen Bump Seeds, also des kanonischen Bumps, beim Ableiten von PDAs. Der kanonische Bump bietet eine deterministische und sichere Methode, um anhand einer Gruppe von Seeds eine Adresse zu finden. Wird der kanonische Bump nicht verwendet, können Schwachstellen entstehen. Bösartige Akteure könnten etwa PDAs erstellen oder manipulieren und dadurch die Programmlogik oder Datenintegrität beeinträchtigen.
Beispielszenario
Betrachte ein Programm zur Erstellung eindeutiger Nutzerprofile, denen jeweils eine explizit mit create_program_address abgeleitete PDA zugeordnet ist. Das Programm erstellt Profile mit einem vom Nutzer bereitgestellten Bump. Das ist problematisch, weil dadurch das Risiko besteht, einen nicht kanonischen Bump zu verwenden:
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>,
}In diesem Szenario leitet das Programm mit create_program_address und Seeds, die einen vom Nutzer bereitgestellten Bump enthalten, eine UserProfile-PDA ab. Ein vom Nutzer bereitgestellter Bump ist problematisch, weil dabei die Verwendung des kanonischen Bumps nicht sichergestellt wird. Ein bösartiger Akteur könnte dadurch für dieselbe Nutzer-ID mehrere PDAs mit unterschiedlichen Bumps erstellen.
Empfohlene Gegenmaßnahme
Um dieses Problem zu entschärfen, können wir unser Beispiel so überarbeiten, dass PDAs mit find_program_address abgeleitet und der Bump Seed explizit validiert werden:
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,
}Hier wird find_program_address verwendet, um die PDA mit dem kanonischen Bump Seed abzuleiten und dadurch eine deterministische und sichere PDA-Erstellung zu gewährleisten. Der kanonische Bump wird im UserProfile-Account gespeichert. So lässt er sich bei nachfolgenden Vorgängen effizient und sicher validieren. Wir bevorzugen find_program_address gegenüber create_program_address, weil Letzteres eine gültige PDA erstellt, ohne nach einem Bump Seed zu suchen. Da nicht nach einem Bump Seed gesucht wird, kann die Funktion für eine beliebige Gruppe von Seeds unvorhersehbar einen Fehler zurückgeben und eignet sich im Allgemeinen nicht zum Erstellen von PDAs. find_program_address verwendet beim Erstellen einer PDA immer den kanonischen Bump. Dafür durchläuft die Funktion verschiedene Aufrufe von create_program_address. Sie beginnt mit einem Bump von 255 und verringert ihn bei jeder Iteration. Sobald sie eine gültige Adresse findet, gibt sie die abgeleitete PDA und den dafür verwendeten kanonischen Bump zurück.
Anchor erzwingt den kanonischen Bump für PDA-Ableitungen über seine Constraints seeds und bump. Das vereinfacht den gesamten Prozess und gewährleistet eine sichere und deterministische Erstellung und Validierung von PDAs.
Konten schließen
Die Schwachstelle
Wenn ein Programm Konten nicht ordnungsgemäß schließt, können mehrere Schwachstellen entstehen. Unter anderem lassen sich „geschlossene“ Konten möglicherweise neu initialisieren oder missbrauchen. Das Problem entsteht, wenn ein Konto nicht korrekt als geschlossen markiert oder seine Wiederverwendung in späteren Transaktionen nicht verhindert wird. Böswillige Akteure können dieses Versäumnis ausnutzen und dadurch unbefugte Aktionen oder Zugriffe innerhalb des Programms ausführen.
Beispielszenario
Betrachte ein Programm, mit dem Benutzer Datenspeicherkonten erstellen und schließen können. Das Programm schließt ein Konto, indem es dessen Lamports überträgt:
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,
}Das ist problematisch, da das Programm weder die Daten des Kontos mit Nullen überschreibt noch das Konto als geschlossen markiert. Allein die Übertragung der verbleibenden Lamports schließt das Konto nicht.
Empfohlene Gegenmaßnahme
Um dieses Problem zu beheben, sollte das Programm nicht nur alle Lamports übertragen, sondern auch die Daten des Kontos mit Nullen überschreiben und es mit einem Diskriminator markieren (d. h. „CLOSED_ACCOUNT_DISCRIMINATOR“). Außerdem sollte das Programm Prüfungen implementieren, die verhindern, dass geschlossene Konten in zukünftigen Transaktionen wiederverwendet werden:
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,
}Es reicht jedoch nicht aus, die Daten mit Nullen zu überschreiben und den Diskriminator für geschlossene Konten hinzuzufügen. Ein Benutzer kann verhindern, dass die Garbage Collection ein Konto entfernt, indem er dessen Lamports vor dem Ende einer Anweisung wieder auffüllt. Dadurch gerät das Konto in einen ungewöhnlichen Schwebezustand, in dem es weder verwendet noch von der Garbage Collection entfernt werden kann. Deshalb haben wir eine force_defund-Funktion für diesen Sonderfall hinzugefügt. Nun kann jeder geschlossene Konten leeren.
Anchor vereinfacht diesen Prozess mit der Einschränkung #[account(close = destination)]. Sie schließt Konten sicher in einem einzigen Vorgang, indem sie Lamports überträgt, Daten mit Nullen überschreibt und den Diskriminator für geschlossene Konten setzt.
Doppelte veränderbare Konten
Die Schwachstelle
Doppelte veränderbare Konten bezeichnen ein Szenario, in dem dasselbe Konto mehrmals als veränderbarer Parameter an eine Anweisung übergeben wird. Das kann passieren, wenn eine Anweisung zwei veränderbare Konten desselben Typs erfordert. Ein böswilliger Akteur könnte dasselbe Konto zweimal übergeben, sodass es auf unbeabsichtigte Weise verändert wird, etwa durch das Überschreiben von Daten. Wie schwerwiegend diese Schwachstelle ist, hängt vom konkreten Szenario ab.
Beispielszenario
Betrachte ein Programm, das Benutzer anhand ihrer Teilnahme an einer bestimmten On-Chain-Aktivität belohnt. Das Programm enthält eine Anweisung, die den Saldo zweier Konten aktualisiert: eines Prämienkontos und eines Bonuskontos. Ein Benutzer soll auf einem Konto eine Standardprämie und auf einem anderen Konto einen möglichen Bonus erhalten, wenn bestimmte vordefinierte Kriterien erfüllt sind:
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,
}Wenn ein böswilliger Akteur dasselbe Konto für reward_account und bonus_account übergibt, wird der Kontostand fälschlicherweise zweimal aktualisiert.
Empfohlene Gegenmaßnahme
Füge der Anweisungslogik eine Prüfung hinzu, die sicherstellt, dass die öffentlichen Schlüssel der beiden Konten nicht identisch sind:
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(())
}Entwickler können mit den Kontoeinschränkungen von Anchor eine explizitere Prüfung für das Konto hinzufügen. Verwende dazu das Attribut #[account] und das Schlüsselwort 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
Die Schwachstelle
Da Transaktions-Bundler immer beliebter werden, sollten auf Solana basierende Protokolle Front-Running ernst nehmen. Seit der Entfernung des Mempools von Jito bezeichnen wir hier als Front-Running die Fähigkeit eines böswilligen Akteurs, erwartete und tatsächliche Werte durch gezielt konstruierte Transaktionen zu manipulieren.
Beispielszenario
Stell dir ein Protokoll vor, das Käufe und Gebote für ein Produkt verarbeitet und die Preisinformationen des Verkäufers in einem Konto namens SellInfo speichert:
#[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,
}Um ein angebotenes Product zu kaufen, muss ein Käufer das zugehörige ProductListing-Konto übergeben. Was aber passiert, wenn der Verkäufer den sale_price seines Angebots ändern kann?
pub fn change_sale_price(ctx: Context<ChangeSalePrice>, new_price: u64) -> Result<()> {...}Dadurch entstünde für den Verkäufer eine Möglichkeit zum Front-Running. Das gilt insbesondere, wenn die Kauftransaktion des Käufers keine expected_price-Prüfungen enthält, die sicherstellen, dass er für das gewünschte Produkt nicht mehr als erwartet bezahlt. Wenn der Käufer eine Transaktion zum Kauf des angegebenen Product einreicht, könnte der Verkäufer change_sale_price aufrufen und mithilfe von Jito sicherstellen, dass diese Transaktion vor der Transaktion des Käufers aufgenommen wird. Ein böswilliger Verkäufer könnte den Preis im ProductListing-Konto unbemerkt auf einen exorbitanten Betrag erhöhen. Der Käufer müsste dann für Product! deutlich mehr als erwartet bezahlen.
Empfohlene Gegenmaßnahme
Eine einfache Lösung besteht darin, auf der Käuferseite des Geschäfts expected_price-Prüfungen einzubauen. So kann der Käufer für das gewünschte Product nicht mehr als erwartet bezahlen:
pub fn purchase_product(ctx: Context<PurchaseProduct>, expected_price: u64) -> Result<()> {
assert!(ctx.accounts.product_listing.sale_price <= expected_price);
...
}Unsichere Initialisierung
Anders als in der EVM bereitgestellte Contracts werden Solana-Programme nicht mit einem Konstruktor bereitgestellt, der Zustandsvariablen setzt. Stattdessen werden sie manuell initialisiert, normalerweise durch eine Funktion namens initialize oder ähnlich. Initialisierungsfunktionen setzen üblicherweise Daten wie die Autorität des Programms oder erstellen Konten, die die Grundlage des bereitgestellten Programms bilden, etwa ein zentrales Zustandskonto.
Da die Initialisierungsfunktion manuell und nicht automatisch bei der Bereitstellung des Programms aufgerufen wird, muss eine bekannte Adresse unter Kontrolle des Entwicklungsteams diese Anweisung aufrufen. Andernfalls kann ein Angreifer die Initialisierung durch Front-Running vorwegnehmen und das Programm möglicherweise mit Konten unter seiner Kontrolle einrichten.
Üblicherweise wird die upgrade_authority des Programms als autorisierte Adresse für den Aufruf der initialize-Funktion verwendet, sofern das Programm über eine Upgrade-Autorität verfügt.
Unsicheres Beispiel und Gegenmaßnahme
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,
...
}Das obige Beispiel zeigt eine vereinfachte initialize-Funktion, die den Aufrufer der Anweisung als Autorität eines CentralState-Kontos festlegt. initialize könnte jedoch von jedem beliebigen Konto aufgerufen werden. Wie bereits erwähnt, lässt sich eine Initialisierungsfunktion häufig absichern, indem die bei der Bereitstellung bekannte upgrade_authority des Programms verwendet wird.
Das folgende Beispiel stammt aus der Anchor-Dokumentation. Es stellt mit constraint sicher, dass nur die Upgrade-Autorität des Programms initialize aufrufen kann:
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>,
}Präzisionsverlust
Die Schwachstelle
Ein Präzisionsverlust kann noch so gering erscheinen und dennoch eine erhebliche Gefahr für ein Programm darstellen. Er kann zu falschen Berechnungen, Arbitragemöglichkeiten und unerwartetem Programmverhalten führen.
Präzisionsverluste bei arithmetischen Operationen sind eine häufige Fehlerquelle. Für Solana-Programme wird nach Möglichkeit Festkommaarithmetik empfohlen. Der Grund dafür ist, dass Programme nur eine begrenzte Teilmenge der Gleitkommaoperationen von Rust unterstützen. Versucht ein Programm, eine nicht unterstützte Gleitkommaoperation zu verwenden, gibt die Runtime einen Fehler wegen eines nicht aufgelösten Symbols zurück. Außerdem benötigen Gleitkommaoperationen mehr Anweisungen als ihre ganzzahligen Entsprechungen. Der Einsatz von Festkommaarithmetik sowie die Notwendigkeit, große Token-Mengen und Bruchteile präzise zu verarbeiten, können Präzisionsverluste verstärken.
Multiplikation nach Division
Obwohl das Assoziativgesetz für die meisten mathematischen Operationen gilt, kann seine Anwendung in der Computerarithmetik zu unerwarteten Präzisionsverlusten führen. Ein klassisches Beispiel tritt auf, wenn eine Multiplikation nach einer Division ausgeführt wird. Dabei kann ein anderes Ergebnis entstehen als bei einer Multiplikation vor der Division. Betrachte beispielsweise diese Ausdrücke: (a / c) * b und (a * b) / c. Mathematisch sind diese Ausdrücke assoziativ und sollten dasselbe Ergebnis liefern. Im Kontext von Solana und der Festkommaarithmetik spielt die Reihenfolge der Operationen jedoch eine entscheidende Rolle. Wird zuerst (a / c) dividiert, kann Präzision verloren gehen, wenn der Quotient vor der Multiplikation mit b abgerundet wird. Dadurch kann das Ergebnis kleiner als erwartet ausfallen. Wird dagegen (a * b) vor der Division durch c multipliziert, bleibt möglicherweise mehr von der ursprünglichen Präzision erhalten. Dieser Unterschied kann zu falschen Berechnungen, unerwartetem Programmverhalten und/oder Arbitragemöglichkeiten führen.
Arithmetische saturating_*-Funktionen
Arithmetische saturating_*-Funktionen verhindern zwar Über- und Unterläufe, indem sie Werte auf den maximal oder minimal möglichen Wert begrenzen. Wird diese Grenze jedoch unerwartet erreicht, können subtile Fehler und Präzisionsverluste entstehen. Das passiert, wenn die Programmlogik davon ausgeht, dass die Sättigung allein ein genaues Ergebnis garantiert, und einen möglichen Verlust an Präzision oder Genauigkeit nicht behandelt.
Stell dir beispielsweise ein Programm vor, das Prämien anhand der Token-Menge berechnet und verteilt, die Benutzer innerhalb eines bestimmten Zeitraums handeln:
pub fn calculate_reward(transaction_amount: u64, reward_multiplier: u64) -> u64 {
transaction_amount.saturating_mul(reward_multiplier)
}Angenommen, transaction_amount beträgt 100.000 Token und reward_multiplier beträgt 100 Token pro Transaktion. Das Produkt der beiden Werte überschreitet den Maximalwert, den ein u64 speichern kann. Das Produkt wird daher begrenzt. Dadurch entsteht ein erheblicher Präzisionsverlust, und der Benutzer erhält eine zu geringe Prämie.
Rundungsfehler
Rundungsoperationen sind eine häufige Ursache für Präzisionsverluste in der Programmierung. Die gewählte Rundungsmethode kann die Genauigkeit der Berechnungen und das Verhalten von Solana-Programmen erheblich beeinflussen. Die Funktion try_round_u64() rundet Dezimalwerte auf die nächste ganze Zahl. Aufrunden ist problematisch, da es Werte künstlich erhöhen und so Abweichungen zwischen tatsächlichen und erwarteten Berechnungen verursachen kann.
Betrachte ein Solana-Programm, das Sicherheiten abhängig von den Marktbedingungen in Liquidität umwandelt. Das Programm rundet das Ergebnis einer Division mit try_round_u64():
pub fn collateral_to_liquidity(&self, collateral_amount: u64) -> Result<u64, ProgramError> {
Decimal::from(collateral_amount)
.try_div(self.0)?
.try_round_u64()
}In diesem Szenario kann das Aufrunden dazu führen, dass mehr Liquiditäts-Token ausgegeben werden, als die Höhe der Sicherheiten rechtfertigt. Böswillige Akteure können diese Abweichung für Arbitrageangriffe ausnutzen und durch vorteilhaft beeinflusste Rundungsergebnisse Wert aus dem Protokoll abziehen. Verwende als Gegenmaßnahme try_floor_u64, um auf die nächstkleinere ganze Zahl abzurunden. Dieser Ansatz minimiert das Risiko künstlich erhöhter Werte und stellt sicher, dass die Rundung dem Benutzer keinen Vorteil auf Kosten des Systems verschafft. Alternativ kannst du eine Logik implementieren, die Szenarien behandelt, in denen die Rundung das Ergebnis ausdrücklich beeinflussen könnte. Dazu können feste Schwellenwerte für Rundungsentscheidungen oder unterschiedliche Logiken je nach Größe der beteiligten Werte gehören.
Fehlende Eigentümerprüfung
Die Schwachstelle
Eigentümerprüfungen sind entscheidend, um sicherzustellen, dass ein an einer Transaktion oder Operation beteiligtes Konto dem erwarteten Programm gehört. Konten enthalten ein Feld namens owner, das angibt, welches Programm die Daten des Kontos schreiben darf. Dieses Feld stellt sicher, dass nur autorisierte Programme den Zustand eines Kontos ändern können. Außerdem lässt sich damit prüfen, ob an eine Anweisung übergebene Konten dem erwarteten Programm gehören. Fehlende Eigentümerprüfungen können schwerwiegende Schwachstellen verursachen, darunter unbefugte Überweisungen von Guthaben und die Ausführung privilegierter Operationen.
Beispielszenario
Betrachte eine Programmfunktion, die ausschließlich Administratoren Abhebungen aus einem Tresor erlaubt. Die Funktion nimmt ein Konfigurationskonto (d. h. config) entgegen und prüft anhand seines Felds admin, ob der öffentliche Schlüssel des angegebenen Administratorkontos mit dem im config-Konto gespeicherten Schlüssel übereinstimmt. Sie prüft jedoch nicht, wem das config-Konto gehört, sondern geht davon aus, dass es vertrauenswürdig ist:
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
}Ein böswilliger Akteur könnte dies ausnutzen, indem er ein von ihm kontrolliertes config-Konto mit einem passenden admin-Feld bereitstellt. Dadurch täuscht er das Programm und veranlasst es, die Abhebung auszuführen.
Empfohlene Gegenmaßnahme
Führe eine Eigentümerprüfung aus, die das Feld owner des Kontos überprüft:
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 vereinfacht diese Prüfung mit dem Typ Account. Account<'info, T> ist ein Wrapper um AccountInfo, der den Programmeigentümer überprüft und die zugrunde liegenden Daten in T deserialisiert, also in den angegebenen Kontotyp. So können Entwickler mit Account<'info, T> einfach prüfen, wem ein Konto gehört. Mit dem Attribut #[account] können sie außerdem einem Konto das Trait Owner hinzufügen. Dieses Trait definiert die Adresse, die das Konto voraussichtlich besitzt. Wenn ein Konto einem anderen als dem aktuell ausgeführten Programm gehören soll, können Entwickler das erwartete Programm außerdem mit der Einschränkung owner festlegen. Das ist beispielsweise nützlich, wenn eine Anweisung ein Konto erwartet, das eine von einem anderen Programm abgeleitete PDA ist. Die Einschränkung owner wird als #[account(owner = <expr>)] definiert, wobei <expr> ein beliebiger Ausdruck ist.
Schreibgeschützte Konten
Ebenso wichtig ist es, die Gültigkeit von Konten zu prüfen, die im Ausführungskontext eines Programms als schreibgeschützt angegeben sind. Ein böswilliger Akteur könnte anstelle legitimer Konten solche mit beliebigen oder manipulierten Daten übergeben. Das kann zu unerwartetem oder schädlichem Programmverhalten führen. Entwickler sollten weiterhin prüfen, ob Konten, aus denen ein Programm lesen muss, echt und unverändert sind. Dazu können sie die Adresse des Kontos mit bekannten Werten abgleichen oder prüfen, ob der Eigentümer des Kontos dem erwarteten Eigentümer entspricht. Das gilt besonders für Sysvars, also schreibgeschützte Systemkonten wie Clock oder EpochSchedule. Greife mit der Methode get() auf Sysvars zu. Sie erfordert keine manuellen Adress- oder Eigentümerprüfungen. Das ist der sicherere Zugriff auf diese Konten. Allerdings unterstützen nicht alle Sysvars die Methode get(). Greife in diesem Fall über ihre öffentliche Adresse auf sie zu.
Fehlende Signaturprüfung
Die Schwachstelle
Transaktionen werden mit dem privaten Schlüssel einer Wallet signiert. Dies stellt Authentifizierung, Integrität, Nichtabstreitbarkeit und die Autorisierung einer bestimmten Transaktion durch eine bestimmte Wallet sicher. Da Transaktionen mit dem privaten Schlüssel des Absenders signiert werden müssen, kann die Runtime von Solana prüfen, ob das richtige Konto die Transaktion initiiert und ob sie manipuliert wurde. Dieser Mechanismus bildet die Grundlage für das vertrauenslose Prinzip dezentraler Netzwerke. Ohne diese Prüfung könnte jedes Konto eine Transaktion ausführen, wenn es das richtige Konto als Argument bereitstellt. Das kann zu unbefugtem Zugriff auf privilegierte Informationen, Guthaben oder Funktionen führen. Diese Schwachstelle entsteht, wenn vor der Ausführung bestimmter privilegierter Funktionen nicht geprüft wird, ob eine Operation mit dem privaten Schlüssel des richtigen Kontos signiert wurde.
Beispielszenario
Betrachte die folgende Funktion:
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(())
}Diese Funktion soll den Administrator des Programms aktualisieren. Sie prüft, ob der aktuelle Administrator die Operation initiiert. Das ist eine gute Zugriffskontrolle. Die Funktion prüft jedoch nicht, ob der private Schlüssel des aktuellen Administrators die Transaktion signiert hat. Daher kann jeder Aufrufer dieser Funktion das richtige admin-Konto übergeben, sodass admin.pubkey() = config.admin gilt. Dabei spielt es keine Rolle, ob das aufrufende Konto tatsächlich der aktuelle Administrator ist. Ein böswilliger Akteur kann dadurch die Anweisung ausführen und sein Konto als neuen Administrator übergeben. Die erforderliche Autorisierung durch den aktuellen Administrator wird damit direkt umgangen.
Empfohlene Gegenmaßnahme
Programme müssen prüfen, ob ein Konto von der richtigen Wallet signiert wurde. Dazu kann das Feld AccountInfo::is_signer der an der Transaktion beteiligten Konten geprüft werden. Indem das Programm prüft, ob für das Konto, das die privilegierte Operation ausführt, das Flag is_signer auf true gesetzt ist, kann es festlegen, dass nur autorisierte Konten bestimmte Aktionen ausführen dürfen.
Das aktualisierte Codebeispiel sieht wie folgt aus:
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 vereinfacht diesen gesamten Prozess mit dem Kontotyp Signer<’info>.
Überlauf und Unterlauf
Die Schwachstelle
Eine Ganzzahl ist eine Zahl ohne Nachkommastellen. Rust speichert Ganzzahlen als Variablen fester Größe. Diese Variablen werden durch ihr Vorzeichenverhalten (also mit oder ohne Vorzeichen) und ihren Speicherbedarf definiert. Der Typ u8 bezeichnet beispielsweise eine vorzeichenlose Ganzzahl, die 8 Bit Speicher belegt. Er kann Werte von 0 bis 255 speichern. Wird ein Wert außerhalb dieses Bereichs gespeichert, führt das zu einem Ganzzahlüberlauf oder -unterlauf. Bei einem Ganzzahlüberlauf überschreitet eine Variable ihre maximale Kapazität und springt auf ihren Minimalwert zurück. Bei einem Ganzzahlunterlauf unterschreitet eine Variable ihre minimale Kapazität und springt auf ihren Maximalwert.
Beim Kompilieren im Debug-Modus prüft Rust auf Ganzzahlüberläufe und -unterläufe. Wird ein solcher Zustand erkannt, lösen diese Prüfungen zur Laufzeit eine Panic aus. Beim Kompilieren im Release-Modus mit dem Flag --release enthält Rust jedoch keine Prüfungen, die bei Ganzzahlüberläufen oder -unterläufen eine Panic auslösen. Dieses Verhalten kann schwer erkennbare Schwachstellen verursachen, weil der Überlauf oder Unterlauf unbemerkt auftritt. Die Toolchain Berkley Packet Filter (BPF) ist ein zentraler Bestandteil der Solana-Entwicklungsumgebung, da sie Solana-Programme kompiliert. Der Befehl cargo build-bpf kompiliert Rust-Projekte zur Bereitstellung in BPF-Bytecode. Das Problem dabei ist, dass Programme standardmäßig im Release-Modus kompiliert werden. Dadurch sind Solana-Programme anfällig für Ganzzahlüberläufe und -unterläufe.
Beispielszenario
Ein Angreifer kann diese Schwachstelle ausnutzen, indem er das unbemerkte Überlauf-/Unterlaufverhalten im Release-Modus ausnutzt. Besonders betroffen sind Funktionen, die Token-Guthaben verarbeiten. Betrachte das folgende Beispiel:
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(())
}Der Einfachheit halber nimmt diese Funktion an, dass das Guthaben im ersten Byte gespeichert ist. Sie nimmt das Guthaben des Accounts und zieht tokens_to_subtract davon ab. Ist das Guthaben des Benutzers kleiner als tokens_to_subtract, kommt es zu einem Unterlauf. Bei einem Benutzer mit 10 Token würde das Gesamtguthaben beispielsweise auf 165 Token unterlaufen.
Empfohlene Gegenmaßnahme
overflow-checks
Am einfachsten lässt sich diese Schwachstelle beheben, indem du den Schlüssel overflow-checks in der Datei Cargo.toml des Projekts auf true setzt. Rust fügt dann Überlauf- und Unterlaufprüfungen in den Compiler ein. Diese Prüfungen erhöhen jedoch die Rechenkosten einer Transaktion. Wenn die Rechenleistung optimiert werden muss, kann es sinnvoller sein, overflow-checks auf false zu setzen.
checked_*-Arithmetik
Nutze die arithmetischen Funktionen checked_* von Rust für jeden Ganzzahltyp, um Überläufe und Unterläufe gezielt im gesamten Programm zu prüfen. Diese Funktionen geben None zurück, wenn ein Überlauf oder Unterlauf auftritt. So kann das Programm den Fehler kontrolliert behandeln. Du könntest den vorherigen Code beispielsweise wie folgt umgestalten:
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(())
}Im überarbeiteten Beispiel wird checked_sub verwendet, um tokens_to_subtract von balance abzuziehen. Reicht balance für die Subtraktion aus, gibt checked_sub daher Some(new_balance) zurück. Das Programm aktualisiert das Guthaben des Accounts sicher und protokolliert es. Würde die Subtraktion jedoch zu einem Unterlauf führen, gibt checked_sub den Wert None zurück. Diesen Fall können wir behandeln, indem wir einen Fehler zurückgeben.
Checked-Math-Makro
Checked Math ist ein prozedurales Makro, mit dem sich die Eigenschaften zur Prüfung mathematischer Ausdrücke ändern lassen, ohne die Ausdrücke selbst größtenteils anzupassen. Das Problem der arithmetischen Funktionen checked_* besteht darin, dass die mathematische Notation verloren geht. Statt a + b müssen umständliche Methoden wie a.checked_add(b).unwrap() verwendet werden. Möchten wir beispielsweise (x * y) + z mit den geprüften arithmetischen Funktionen schreiben, verwenden wir x.checked_mul(y).unwrap().checked_add(z).unwrap().
Mit dem Checked-Math-Makro würde derselbe Ausdruck stattdessen so aussehen:
use checked_math::checked_math as cm;
cm!((x * y) + z).unwrap()Das ist einfacher zu schreiben, erhält die mathematische Notation des Ausdrucks und benötigt nur ein .unwrap(). Das liegt daran, dass das Makro normale mathematische Ausdrücke in einen Ausdruck umwandelt, der None zurückgibt, sobald einer der geprüften Schritte None zurückgibt. Bei Erfolg wird Some(_) zurückgegeben. Deshalb entpacken wir den Ausdruck am Ende.
Typumwandlung
Auch die Umwandlung zwischen Ganzzahltypen mit dem Schlüsselwort as kann ohne korrekte Prüfungen eine Schwachstelle für Ganzzahlüberläufe oder -unterläufe verursachen. Bei der Typumwandlung können Werte unbeabsichtigt abgeschnitten oder erweitert werden. Bei der Umwandlung von einem größeren in einen kleineren Ganzzahltyp (z. B. u64 in u32) schneidet Rust die höherwertigen Bits des ursprünglichen Werts ab, die nicht in den Zieltyp passen. Das ist problematisch, wenn der ursprüngliche Wert den Maximalwert überschreitet, den der Zieltyp speichern kann. Bei der Umwandlung von einem kleineren in einen größeren Ganzzahltyp (z. B. i16 in i32) erweitert Rust den Wert. Bei vorzeichenlosen Typen ist das unkompliziert. Bei Ganzzahlen mit Vorzeichen kann dies jedoch zu einer Vorzeichenerweiterung führen, durch die unbeabsichtigt negative Werte entstehen.
Empfohlene Gegenmaßnahme
Nutze die sicheren Methoden von Rust zur Typumwandlung, um diese Schwachstelle zu beheben. Dazu gehören Methoden wie try_from und from. try_from gibt einen Result-Typ zurück. Dadurch kannst du Fälle kontrolliert und explizit behandeln, in denen der Wert nicht in den Zieltyp passt. Die from-Methode von Rust ermöglicht eine sichere, implizite Umwandlung, wenn diese garantiert verlustfrei ist (z. B. u8 in u32). Angenommen, ein Programm muss einen Token-Betrag vom Typ u64 zur Verarbeitung sicher in den Typ u32 umwandeln. Dann kann es Folgendes tun:
pub fn convert_token_amount(amount: u64) -> Result<u32, ProgramError> {
u32::try_from(amount).map_err(|_| ProgramError::InvalidArgument)
}Überschreitet amount in diesem Beispiel den Maximalwert, den u32 aufnehmen kann (also 4 294 967 295), schlägt die Umwandlung fehl und das Programm gibt einen Fehler zurück. Dadurch wird ein möglicher Überlauf oder Unterlauf verhindert.
Gemeinsame Nutzung von PDAs
Die Schwachstelle
Die gemeinsame Nutzung von PDAs ist eine verbreitete Schwachstelle. Sie entsteht, wenn dieselbe PDA in mehreren Autoritätsbereichen oder Rollen verwendet wird. Dadurch könnte ein böswilliger Akteur über den missbräuchlichen Einsatz von PDAs als Signer auf fremde Daten oder Gelder zugreifen, wenn die erforderlichen Zugriffskontrollen fehlen.
Beispielszenario
Betrachte ein Programm, das das Staking von Token und die Verteilung von Belohnungen ermöglicht. Das Programm verwendet eine einzelne PDA, um Token in einen bestimmten Pool zu übertragen und Belohnungen abzuheben. Die PDA wird aus einem statischen Seed abgeleitet, beispielsweise dem Namen des Staking-Pools. Deshalb wird sie für alle Vorgänge gemeinsam verwendet:
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
}Das ist problematisch, weil sowohl das Staking als auch das Abheben von Belohnungen dieselbe, von staking_pool_pda abgeleitete PDA verwenden. Benutzer könnten dadurch den Vertrag so manipulieren, dass sie unbefugt Belohnungen abheben oder das Staking beeinflussen.
Empfohlene Gegenmaßnahme
Verwende für verschiedene Funktionen unterschiedliche PDAs, um diese Schwachstelle zu beheben. Stelle sicher, dass jede PDA einem bestimmten Kontext dient und aus eindeutigen, vorgangsspezifischen Seeds abgeleitet wird:
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
}Im obigen Beispiel werden die PDAs für das Staking von Token und das Abheben von Belohnungen aus unterschiedlichen Seeds (staking_pool beziehungsweise rewards_pool) in Kombination mit dem Schlüssel des jeweiligen Accounts abgeleitet. Dadurch sind die PDAs eindeutig an ihre vorgesehenen Funktionen gebunden, was das Risiko unbefugter Aktionen verringert.
Verbleibende Accounts
Die Schwachstelle
Mit ctx.remaining_accounts kannst du zusätzliche Accounts an eine Funktion übergeben, die ursprünglich nicht in der Struktur Accounts angegeben wurden. Das bietet Entwicklern mehr Flexibilität und ermöglicht Szenarien mit einer dynamischen Anzahl von Accounts, etwa die Verarbeitung einer variablen Anzahl von Benutzern oder die Interaktion mit verschiedenen Programmen. Diese zusätzliche Flexibilität hat jedoch einen Nachteil: Accounts, die über ctx.remaining_accounts übergeben werden, durchlaufen nicht dieselbe Validierung wie Accounts, die in der Struktur Accounts definiert sind. Da ctx.remaining_accounts die übergebenen Accounts nicht validiert, könnte ein böswilliger Akteur Accounts einschleusen, mit denen das Programm nicht interagieren sollte. Dies kann zu unbefugten Aktionen oder Zugriffen führen.
Beispielszenario
Betrachte ein Belohnungsprogramm, das über ctx.remaining_accounts Benutzer-PDAs empfängt und Belohnungen dynamisch berechnet:
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
}Das Problem besteht darin, dass keine expliziten Prüfungen die über ctx.remaining_accounts übergebenen Accounts validieren. Somit ist nicht sichergestellt, dass bei der Berechnung und Verteilung der Belohnungen nur Accounts gültiger und berechtigter Benutzer verarbeitet werden. Ein böswilliger Akteur könnte daher Accounts übergeben, die ihm nicht gehören oder die er selbst erstellt hat, um mehr Belohnungen zu erhalten, als ihm tatsächlich zustehen.
Empfohlene Gegenmaßnahme
Um diese Schwachstelle zu beheben, sollten Entwickler die Gültigkeit jedes Accounts manuell innerhalb der Funktion prüfen. Dazu gehört, den Eigentümer des Accounts zu prüfen und sicherzustellen, dass er dem erwarteten Benutzer entspricht, sowie alle relevanten Daten im Account zu validieren. Mit diesen manuellen Prüfungen können Entwickler die Flexibilität von ctx.remaining_acocunts nutzen und gleichzeitig das Risiko unbefugter Zugriffe oder Manipulationen verringern.
Rust-spezifische Fehler
Rust ist die Lingua franca für die Programmentwicklung auf Solana. Die Entwicklung mit Rust bringt besondere Herausforderungen und Überlegungen mit sich, insbesondere bei unsicherem Code und Rust-spezifischen Fehlern. Wer die Besonderheiten von Rust versteht, kann sichere, effiziente und zuverlässige Programme entwickeln.
Unsicheres Rust
Rust ist für seine Garantien zur Speichersicherheit bekannt, die durch ein striktes System für Eigentum und Ausleihen erreicht werden. Diese Garantien können manchmal jedoch hinderlich sein. Deshalb bietet Rust das Schlüsselwort unsafe, um Sicherheitsprüfungen zu umgehen. unsafe Rust wird in vier grundlegenden Kontexten verwendet:
- Unsichere Funktionen: Funktionen, die Vorgänge ausführen, welche die Sicherheitsgarantien von Rust verletzen können, müssen mit dem Schlüsselwort unsafe gekennzeichnet werden. Beispiel: unsafe fn dangerous_function() {}
- Unsichere Blöcke: Codeblöcke, in denen unsichere Vorgänge erlaubt sind. Beispiel: unsafe { // Unsichere Vorgänge }
- Unsichere Traits: Traits, die bestimmte Invarianten voraussetzen, die der Compiler nicht überprüfen kann. Beispiel: unsafe trait BadTrait {}
- Implementierung unsicherer Traits: Implementierungen von unsafe-Traits müssen ebenfalls als unsafe gekennzeichnet werden. Beispiel: unsafe impl UnsafeTrait for UnsafeType {}
Unsicheres Rust gibt es, weil die statische Analyse konservativ arbeitet. Wenn der Compiler prüft, ob der Code bestimmte Garantien erfüllt, ist es besser, einige gültige Codefälle abzulehnen, als einige ungültige zu akzeptieren. Selbst wenn der Code einwandfrei ausgeführt werden könnte, lehnt ihn der Rust-Compiler ab, wenn ihm nicht genügend Informationen vorliegen, um die Einhaltung der Sicherheitsgarantien von Rust zuverlässig zu beurteilen. Unsicherer Code erlaubt Entwicklern, diese Prüfungen auf eigenes Risiko zu umgehen. Darüber hinaus ist Computerhardware grundsätzlich unsicher. Entwickler müssen unsichere Vorgänge ausführen können, um mit Rust systemnah zu programmieren.
Mit dem Schlüsselwort unsafe können Entwickler:
- Rohzeiger dereferenzieren: ermöglicht direkten Speicherzugriff über Rohzeiger, die auf beliebige Speicheradressen zeigen können, an denen möglicherweise keine gültigen Daten liegen
- Unsichere Funktionen aufrufen: Diese Funktionen halten sich möglicherweise nicht an die Sicherheitsgarantien von Rust und können zu potenziell undefiniertem Verhalten führen
- Auf veränderliche statische Variablen zugreifen: Globaler veränderlicher Zustand kann Datenwettläufe verursachen
Die beste Gegenmaßnahme für unsicheres Rust besteht darin, die Verwendung von unsafe-Blöcken zu minimieren. Ist unsafe-Code aus irgendeinem Grund unbedingt erforderlich, stelle sicher, dass er gut dokumentiert und regelmäßig geprüft wird. Kapsle ihn nach Möglichkeit in einer sicheren Abstraktion, die dem restlichen Programm bereitgestellt werden kann.
Panics und Fehlerbehandlung
Eine Panic tritt auf, wenn ein Rust-Programm auf einen nicht behebbaren Fehler stößt und die Ausführung beendet. Panics werden für unerwartete Fehler verwendet, die nicht abgefangen werden sollen. Bei Solana-Programmen kann eine Panic zu unerwartetem Verhalten führen, da die Laufzeitumgebung erwartet, dass Programme Fehler kontrolliert behandeln, ohne abzustürzen.
Bei einer Panic beginnt Rust, den Stack abzuwickeln und dabei zu bereinigen. Dadurch entsteht ein Stacktrace mit detaillierten Informationen zum jeweiligen Fehler. Ein Angreifer könnte daraus Informationen über die zugrunde liegende Dateistruktur gewinnen. Solana-Programme sind davon zwar nicht direkt betroffen, doch die von einem Programm verwendeten Abhängigkeiten könnten für einen solchen Angriff anfällig sein. Halte Abhängigkeiten aktuell und verwende Versionen ohne bekannte Schwachstellen.
Häufige Panic-Szenarien sind:
- Division durch null: Rust löst beim Versuch, durch null zu teilen, eine Panic aus. Prüfe deshalb vor jeder Division, ob der Divisor null ist
- Array-Index außerhalb des gültigen Bereichs: Der Zugriff auf ein Array mit einem Index außerhalb seiner Grenzen löst eine Panic aus. Nutze zur Vermeidung Methoden, die einen Option-Typ zurückgeben, etwa get, um sicher auf Array-Elemente zuzugreifen
- Entpacken von None-Werten: Der Aufruf von .unwrap() für eine Option mit dem Wert None löst eine Panic aus. Verwende immer Pattern Matching oder Methoden wie unwrap_or, unwrap_or_else beziehungsweise den Operator ? in Funktionen, die ein Result zurückgeben
Um Probleme durch Panics zu vermeiden, solltest du Vorgänge vermeiden, die Panics auslösen. Validiere alle Eingaben und Bedingungen, die problematische Vorgänge verursachen könnten, und verwende die Typen Result und Option zur Fehlerbehandlung. Umfassende Programmtests helfen außerdem dabei, potenzielle Panic-Szenarien vor der Bereitstellung zu erkennen und zu beheben.
Seed-Kollisionen
Die Schwachstelle
Seed-Kollisionen treten auf, wenn unterschiedliche Eingaben, also Seeds und Programm-IDs, bei der Erzeugung einer PDA dieselbe PDA-Adresse ergeben. Das ist problematisch, wenn PDAs innerhalb eines Programms für verschiedene Zwecke verwendet werden. Es kann zu unerwartetem Verhalten bis hin zu Denial-of-Service-Angriffen oder einer vollständigen Kompromittierung führen.
Beispielszenario
Betrachte ein Programm für eine dezentrale Abstimmungsplattform mit verschiedenen Vorschlägen und Initiativen. Jede Abstimmungssitzung für einen bestimmten Vorschlag oder eine Initiative wird mit einer eindeutigen Kennung erstellt, und Benutzer geben ihre Stimmen ab. Das Programm verwendet PDAs sowohl für Abstimmungssitzungen als auch für einzelne Stimmen:
// 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>,
}In diesem Szenario würde ein Angreifer versuchen, eine Abstimmungssitzung gezielt so zu konstruieren, dass sie in Kombination mit dem statischen Seed "session" eine PDA ergibt, die zufällig mit der für eine andere Abstimmungssitzung erzeugten PDA übereinstimmt. Das absichtliche Erstellen einer PDA, die mit der PDA einer anderen Abstimmungssitzung kollidiert, könnte den Betrieb der Plattform stören. So könnten beispielsweise legitime Stimmen für Vorschläge verhindert oder neue Initiativen abgelehnt werden, weil die Solana-Laufzeitumgebung die kollidierenden PDAs nicht unterscheiden kann.
Empfohlene Gegenmaßnahme
Um das Risiko von Seed-Kollisionen zu verringern, können Entwickler:
- Eindeutige Präfixe für Seeds verschiedener PDAs im selben Programm verwenden. Dadurch bleiben die PDAs voneinander unterscheidbar
- Eindeutige Kennungen verwenden, etwa Zeitstempel, Benutzer-IDs oder Nonce-Werte, damit jedes Mal eine eindeutige PDA erzeugt wird
- Programmatisch prüfen, dass eine erzeugte PDA nicht mit vorhandenen PDAs kollidiert
Typverwechslung
Die Schwachstelle
Typverwechslung ist eine Schwachstelle, bei der ein Account-Typ aufgrund fehlender Typprüfungen während der Deserialisierung als ein anderer dargestellt wird. Dies kann zur Ausführung unbefugter Aktionen oder zur Beschädigung von Daten führen, weil das Programm auf einer falschen Annahme über die Rolle oder Berechtigungen des Accounts basiert. Prüfe bei der Deserialisierung immer explizit den vorgesehenen Typ des Accounts.
Beispielszenario
Betrachte ein Programm, das den Zugriff auf Administratorvorgänge anhand der Rolle eines Benutzers verwaltet. Jeder Benutzer-Account enthält einen Rollendiskriminator, der normale Benutzer von Administratoren unterscheidet. Das Programm enthält eine Funktion zum Aktualisieren von Administratoreinstellungen, die ausschließlich für Administratoren vorgesehen ist. Es prüft den Diskriminator des Accounts jedoch nicht und deserialisiert die Daten des Benutzer-Accounts, ohne zu bestätigen, dass der Account einem Administrator gehört:
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,
}Das Problem besteht darin, dass update_admin_settings den übergebenen Benutzer-Account deserialisiert, ohne dessen Rollendiskriminator zu prüfen. Das liegt zum Teil daran, dass in der Struktur User ein Diskriminatorfeld fehlt!
Empfohlene Gegenmaßnahme
Um dieses Problem zu beheben, können Entwickler der Struktur User ein Diskriminatorfeld hinzufügen und dieses während der Deserialisierung prüfen:
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 vereinfacht die Behebung von Typverwechslungs-Schwachstellen, indem es Diskriminatoren für Account-Typen automatisch verwaltet. Dies erfolgt über den Wrapper Account<'info, T>. Anchor gewährleistet dabei Typsicherheit, indem es den Diskriminator während der Deserialisierung automatisch prüft. So können sich Entwickler stärker auf die Geschäftslogik ihres Programms konzentrieren, statt verschiedene Typprüfungen manuell zu implementieren.
Fazit
Die Bedeutung der Programmsicherheit kann kaum überschätzt werden. Dieser Artikel hat das gesamte Spektrum häufiger Schwachstellen behandelt – von Rust-spezifischen Fehlern bis hin zur Komplexität von Anchors realloc-Methode. Jede dieser Schwachstellen und die Programmsicherheit insgesamt zu beherrschen, ist ein fortlaufender Prozess. Er erfordert kontinuierliches Lernen, Anpassung und Zusammenarbeit. Als Entwickler schützen wir mit unserem Einsatz für Sicherheit nicht nur Vermögenswerte. Wir schaffen Vertrauen, gewährleisten die Integrität unserer Anwendungen und tragen zum Wachstum und zur Stabilität von Solana bei.
Wenn du bis hierher gelesen hast: Danke, Anon! Gib unten deine E-Mail-Adresse ein, damit du keine Neuigkeiten zu Solana verpasst. Möchtest du tiefer einsteigen? Entdecke noch heute die neuesten Artikel im Helius-Blog und setze deine Reise mit Solana fort.
Zusätzliche Ressourcen
Ähnliche Artikel
Helius abonnieren
Bleib bei der Solana-Entwicklung auf dem Laufenden und erhalte Updates, wenn wir neue Beiträge veröffentlichen


