Skip to main content

Die 5 größten C++-Sicherheitsrisiken

Artikel von
Headshot of Snyk Security Research Team

Snyk Security Research Team

feature security

16. August 2022

0 Min. Lesezeit

C++ bietet Entwicklerinnen und Entwicklern viele leistungsstarke Funktionen. Deshalb wird es in zahlreichen Branchen und vielen zentralen Systemen eingesetzt. Anders als manche Hochsprachen, die weniger direkten Zugriff auf Ressourcen ermöglichen, birgt C++ verschiedene Sicherheitsrisiken, die Entwicklerinnen und Entwickler beim Schreiben von Code unbedingt kennen sollten, um Schwachstellen in ihren Projekten zu vermeiden.

Als Entwicklerinnen und Entwickler entwickeln wir Anwendungen mit Blick auf die Endnutzer. Diese vertrauen uns ihre Daten, ihre Zeit und den Zugriff auf ihre Geräte an. Wir sind dafür verantwortlich, dass unsere Anwendung und die Daten unserer Nutzer jederzeit sicher sind.

Dieser Artikel beleuchtet die fünf wichtigsten C++-Sicherheitsrisiken bei der Entwicklung und gibt Tipps, wie Sie diese minimieren können.

1. Pufferüberlauf

Eines der häufigsten Sicherheitsrisiken in C++ ist ein Pufferüberlauf. Pufferüberläufe entstehen, weil C++ keine integrierten Funktionen zur Grenzprüfung bietet, die das Risiko des Überschreibens von Speicher verringern. Schreibzugriffe außerhalb des zugewiesenen Speicherbereichs können Programmabstürze und Datenbeschädigungen verursachen. Sie können sogar zur Ausführung von Schadcode führen. Daher wurde der Pufferüberlauf in der Umfrage zu den 25 gefährlichsten Softwareschwächen des CWE für 2021 als gefährlichste Softwareschwäche eingestuft.

Der Pufferüberlauf-Angriff auf den VOIP-Stack der WhatsApp-Anwendung im Jahr 2019 zeigt, wie schwerwiegend die Auswirkungen dieser Schwachstelle sein können. Bei diesem Angriff nutzte der Angreifer eine Pufferüberlauf-Schwachstelle in WhatsApp aus, um Spyware auf den Telefonen bestimmter Nutzer zu installieren.

Sehen wir uns an, wie eine Pufferüberlauf-Schwachstelle in der Praxis aussieht. Im folgenden Codeausschnitt erfassen wir Nutzereingaben mit der Funktion gets und prüfen, ob sie sich auf die Variable important_data ausgewirkt haben.

#include <stdio.h>

int main(int argc, char **argv)
{
  volatile int important_data = 0;
  char user_input[10];

  gets(user_input);

  if(important_data != 0) {
    printf("Warning !!!, the 'important_data' was changed\n");
  } else {
    printf("the 'important_data' was not changed\n");
  }
}

Dieser Code mag harmlos aussehen, ist aber anfällig für einen Pufferüberlauf-Angriff, wenn Nutzer eine Zeichenfolge eingeben, die länger ist als das Array user_input. Der Stack wächst in Richtung einer niedrigeren Speicheradresse. Das bedeutet, dass sich important_data im Speicher unter user_input befindet.

Es gibt verschiedene Möglichkeiten, dieses Problem zu verhindern. Einige davon hängen von Funktionen des Compilers, des Betriebssystems oder des Kernels ab. Zu den wichtigsten Maßnahmen zum Schutz unserer Daten gehören Stack-Canaries, Address Space Layout Randomization (ASLR) und Data Execution Prevention (DEP).

  • Stack-Canaries fügen dem Stack bei jedem Programmstart einen neuen, zufällig ausgewählten geheimen Wert hinzu. Vor der Rückkehr einer Funktion wird dieser Wert überprüft.

  • ASLR verhindert, dass Angreifer das Speicherlayout kennen, und erschwert so einen Pufferüberlauf-Angriff. Wissen Angreifer nicht, wo sich die Daten im Speicher befinden, können sie auch nicht wissen, welchen Puffer sie angreifen sollen.

  • DEP kennzeichnet bestimmte Speicherbereiche, etwa den Stack, als nicht ausführbar.

Zusätzlich zu Betriebssystem- und Compilerfunktionen müssen wir gute Programmierpraktiken umsetzen, darunter die Grenzprüfung. Wir sollten Standardbibliotheksfunktionen vermeiden, die anfällig für Pufferüberlauf-Angriffe sind, etwa get, strcpy, strcat, scanf und printf, da sie keine Grenzprüfung durchführen. Stattdessen sollten wir sie durch entsprechende sichere Funktionen wie fgets ersetzen.

2. Integer-Überlauf und -Unterlauf

Ein Integer-Überlauf tritt auf, wenn der Wert, den wir in einer Integer-Variable speichern möchten, den maximal darstellbaren Wert überschreitet. Ein Integer-Unterlauf liegt vor, wenn der Wert kleiner ist als der minimal darstellbare Wert. In diesem Fall läuft der Wert über und beginnt wieder am anderen Ende des Wertebereichs.

In der Umfrage zu den 25 gefährlichsten Softwareschwächen des CWE für 2021 wurde diese Schwachstelle als zwölftgefährlichste eingestuft. Außerdem können Integer-Über- und -Unterläufe zu einer Pufferüberlauf-Schwachstelle führen.

Der folgende Code enthält einen tatsächlichen Fehler, der in OpenSSH v3.3 gefunden wurde. Er veranschaulicht, wie ein Integer-Überlauf einen Pufferüberlauf-Angriff verursachen kann:

nresp = packet_get_int();
if (nresp > 0) {
response = xmalloc(nresp*sizeof(char*));
for (i = 0; i < nresp; i++) 
    response[i] = packet_get_string(NULL);
}

Dieser Code weist eine Integer-Überlauf-Schwachstelle auf. Zwar prüft der Code auf einen Wert von null, doch wenn die Eingabe nresp gleich 1073741824 ist, kann null Speicher zugewiesen werden. Die Multiplikation dieses Werts mit vier (der Größe eines char-Zeigers) führt zu einem Überlauf der Variable. Dadurch weist xmalloc(nresp*sizeof(char*)) einen Puffer der Größe 0 zu.

Um diese Schwachstelle zu minimieren, sollten wir Wertebereiche prüfen: den Wert null beziehungsweise den Minimalwert sowie den Maximalwert. So schützen wir vor einem Überlauf oder dem Umlauf des Variablenwerts.

3. Initialisierung von Zeigern

Die Initialisierung von Zeigern ist entscheidend. Ein nicht initialisierter Zeiger, der auf eine Speicheradresse oder Funktion verweist, kann zahlreiche Daten preisgeben. Zeigt ein nicht initialisierter Zeiger auf eine Speicheradresse, kann das Programm unerwartet aus diesem Speicherbereich lesen oder dorthin schreiben. Zeigt er auf eine Funktion, kann dies dazu führen, dass eine beliebige Funktion unbeabsichtigt ausgeführt wird.

Zeiger sind außerdem anfällig für Angriffe durch Dereferenzierung von Null-Zeigern, die die Zuverlässigkeit von Programmen beeinträchtigen können. Bei solchen Angriffen wird auf einen Zeiger zugegriffen, der auf null gesetzt ist. Das führt zu undefiniertem Verhalten (UB) im Programm und in den meisten Fällen zu einem Absturz. Angreifer können dies ausnutzen, indem sie eine Null-Zeiger-Dereferenzierung erzwingen. Eine fehlerhafte Initialisierung des Zeigers führt zu unerwartetem und unvorhersehbarem Verhalten. Angreifer können dadurch einige Sicherheitsprüfungen umgehen oder Debugging-Informationen offenlegen, die sie später verwenden können.

Sehen wir uns ein Beispiel für eine Dereferenzierung an, die durch eine fehlerhafte Zeigerinitialisierung verursacht wird:

void main(){
int *ptr;
if (nullptr != ptr)
 {
*ptr = 5;
 }
}

Wie Sie sehen, ist der Zeiger nicht initialisiert. Ihm wird daher ein zufälliger Wert zugewiesen, und die Prüfung ist erfolgreich, da der Wert möglicherweise ungleich null ist.

Wir sollten uns nicht allein auf Prüfungen oder die Behandlung von Ausnahmen verlassen, um Angriffe durch Zeiger-Dereferenzierung zu verhindern. Wenn möglich, sollten wir auf Zeiger verzichten und stattdessen Referenzen verwenden. Falls wir Zeiger einsetzen, sollten wir Smart Pointer anstelle von rohen Zeigern verwenden.

Eine weitere Alternative zu Zeigern ist die Verwendung der Technik Resource Acquisition Is Initialization (RAII) in unserer Implementierung. Sie stellt sicher, dass eine Ressource jeder Funktion zur Verfügung steht, die darauf zugreifen kann.

4. Falsche Typumwandlung

Eine weitere häufige Schwachstelle ist eine falsche Typumwandlung. Die meisten Probleme mit Typumwandlungen entstehen beim Wechsel von vorzeichenbehafteten zu vorzeichenlosen Typen, der häufig bei Funktionsaufrufen durch die Übergabe eines Parameters mit falschem Typ auftritt. Ein weiteres häufiges Problem ist die Umwandlung längerer Datentypen, etwa von double in float oder von long int in int. Bei einer impliziten Umwandlung gehen dabei Daten verloren.

Hier sehen Sie ein einfaches Beispiel für eine Schwachstelle durch falsche Typumwandlung:

#include <iostream>
#include <string>
using namespace std;

int main()
{
    string str;
    cout << "Please enter your string: \n";
    getline(cin, str);
    unsigned int len = str.length();
    if (len > -1)
    {
    cout << "string length is " << len << "which is bigger than -1 " <<std::endl;
    }else 
    {
    cout << "string length is " << len << " which is less than -1 " <<std::endl;
    }
    return 0;
}

Es scheint unmöglich, die else-Anweisung zu erreichen, da jede Eingabezeichenfolge länger als -1 sein müsste. Dieser Code funktioniert jedoch nicht und landet immer in der else-Anweisung. Laut dem C++-Standard und dem Konzept der Integer-Promotion wird die Darstellung der Werte geändert, wenn zwei Werte unterschiedlichen Datentyps miteinander verglichen werden.

In unserem Fall wird der Wert von signed short int in den größeren Typ unsigned int umgewandelt. Dadurch wird der Wert -1 in einen vorzeichenlosen Integer-Wert umgewandelt, der 4294967295 entspricht. Der Programmablauf gelangt dadurch zur else-Anweisung.

Dasselbe Problem kann auftreten, wenn Sie vorzeichenlose Integer verwenden, um zwei vom Nutzer eingegebene Werte voneinander abzuziehen, und dabei davon ausgehen, dass der Nutzer niemals zuerst einen kleineren Wert eingibt. Das Ergebnis der Subtraktion kann dann negativ sein.

Laut dem Google C++ Style Guide lassen sich die meisten Probleme mit Typumwandlungen vermeiden, wenn Sie keine Berechnungen mit vorzeichenlosen Integern durchführen (außer zur Darstellung von Bitfeldern). Außerdem wird empfohlen, Vorzeichen nicht zu mischen und Iterators sowie Container anstelle von Zeigern und Größenangaben zu verwenden.

5. Format-String-Schwachstelle

Eine Format-String-Schwachstelle umfasst zwei Komponenten: die Formatierungsfunktion und den Format-String. Bevor wir uns Format-String-Schwachstellen genauer ansehen, betrachten wir zunächst die Funktion der Formatierungsfunktion und des Format-Strings.

Die Formatierungsfunktion wandelt Variablen der Programmiersprache in ein für Menschen lesbares Format um. Beispiele für Formatierungsfunktionen sind printf und fprintf.

Der Format-String ist das Argument der Formatierungsfunktion. Er enthält Text und Format-String-Parameter. Sehen wir uns ein Beispiel an:

printf ("This is a test text of number: %d ", 11);
  • "This is a test text of number: %d ", 11" ist der Format-String.

  • "%d" ist der Format-String-Parameter, der das Umwandlungsformat festlegt.

Format-String-Angriffe treten auf, wenn wir die an die Formatierungsfunktion übergebenen Parameter nicht prüfen. Nehmen wir zum Beispiel an, wir hätten eine Anwendung wie die folgende implementiert:

#include  <stdio.h> 

int main(int argc, char **argv)
{
printf(argv[1]);
return 0;
}

Dieser Code ist anfällig für Format-String-Angriffe, da er die Nutzereingabe nicht prüft. Ein Angriff erfolgt beispielsweise durch die Übergabe eines Format-String-Parameters und einer Eingabe wie dieser:

"./program "Snyk %p %p %p %p %p" " 

Diese Eingabe kann Daten aus dem Stack auslesen. Die Programmausgabe sieht dann etwa so aus:

>> ./program "Snyk %p %p %p"
 Snyk 0x7ffcc40aafd8 0x7ffcc40aaff0 0x558581c18180%

Diese Ausgabe entsteht, weil printf %p als Verweis auf einen void-Zeiger behandelt, der Speicheradressen interpretiert.

Um diese Schwachstelle zu verhindern, müssen wir unserem Code ein Formatargument hinzufügen, wie unten gezeigt:

#include  <stdio.h> 

int main(int argc, char **argv)
{
// safe code
printf("%s\n",argv[1]);
return 0;
}

Der obige Codeausschnitt ist sicher, da die Zeichenfolge nicht interpretiert wird. Wenn wir den Code beispielsweise kompilieren und ausführen, sähe die Ausgabe so aus:

>> ./program "Snyk %p %p %p"
 Snyk %p %p %p

Die Ausgabe enthält nur die an das Programm übergebenen Zeichen. Sie werden nicht als Verweis auf einen void-Zeiger interpretiert.

Noch besser ist es, printf nach Möglichkeit zu vermeiden, sofern Sie nicht zwingend darauf angewiesen sind, und stattdessen std::format und std::vformat (eingeführt in C++ 20) zu verwenden. Diese Funktionen prüfen die Eingabe zur Laufzeit oder beim Kompilieren anhand der Typen und lösen bei einer Nichtübereinstimmung einen format_error aus.

Verringern Sie Ihre C++-Sicherheitsrisiken

In diesem Artikel haben wir die fünf größten Sicherheitsrisiken bei der Arbeit mit C++ beleuchtet. Wir haben uns angesehen, wie Angreifer diese Schwachstellen ausnutzen können, um auf Nutzerdaten zuzugreifen oder Informationen aus unseren Anwendungen auszulesen, und wie sie im Code aussehen. Außerdem haben wir Maßnahmen vorgestellt, mit denen sich verhindern lässt, dass diese Schwachstellen zu schwerwiegenden Sicherheitsverletzungen führen.

Anders als Hochsprachen, die weniger direkten Zugriff auf Ressourcen ermöglichen, birgt C++ verschiedene Schwachstellen, die Entwicklerinnen und Entwickler beim Schreiben von Code kennen sollten, um sie nicht in ihre Projekte einzubringen. Schwachstellen können auf vielfältige Weise ausgenutzt werden, und Angreifer entwickeln ihre Angriffsmethoden ständig weiter. Weitere Informationen zu Schwachstellen in C++ finden Sie in diesem Blogbeitrag des Security-Research-Teams von Snyk sowie in diesem Artikel über Directory-Traversal-Schwachstellen in C/C++.

Als Entwicklerinnen und Entwickler sind wir dafür verantwortlich, Sicherheitsmaßnahmen umzusetzen, damit unsere Software zuverlässig ist und vor Angriffen geschützt bleibt, die Nutzerdaten offenlegen könnten. Mit den richtigen Strategien lassen sich alle in diesem Artikel behandelten Schwachstellen vermeiden.

Starten Sie mit Capture the Flag

Erfahren Sie in unserem virtuellen On-Demand-Workshop für Einsteiger, wie Sie Capture-the-Flag-Herausforderungen lösen.