Christian Seiler: Einige C-Fragen

Beitrag lesen

Hallo!

  1. Von scanf sollte man die Finger lassen. Die Funktion ist in meinen Augen die schlimmste Erfindung im C-Standard schlechthin. Zum einen ist sie ein potentielles Sicherheitsrisiko (siehe unten) und zum anderen gaukelt sie einem vor, das Umgekehrte von printf() zu sein, was sie aber nicht wirklich ist (und was auch nicht wirklich geht). %s hört bei scanf() nämlich entweder auf, wenn die maximallänge Erreicht ist (%20s) oder wenn ein Whitespace-Zeichen kommt (Leerzeichen, Neue Zeile, Tab).

Wie kann denn von diesem Beispiel ausgehend eine Sicherheitslücke entstehen? Klar, das Programm kann abstürzen, aber wie genau kann man dies so ausnutzen, dass das Programm z.B. etwas was macht, was es gar nicht soll?

Naja, im Prinzip kannst Du Dir das so überlegen:

void tuWas (void) {
  char buf[20]);
  scanf("%s", buf);
}

Wenn der eingegebene String nun mehr als 19 Zeichen hat (0-Byte muss man ja berücksichtigen), dann wird weiter in den Stack hineingeschrieben. Irgendwann kommt im Stack dann die Rücksprungadresse (die wird dort gespeichert, damit die Funktion wieder zurückkehren kann). Wenn Du diese Rücksprungadresse auch überschreibst, kannst Du den Prozessor beim Verlassen der Funktion an eine beliebige Codestelle springen lassen - und damit z.B. auch an den Anfang von buf (dem Puffer), wo Du dann Deinen eigenen kompilierten Code hingetan hast.

  1. name, ort und alter sind bei Dir als Variablen nicht initialisiert. Sie zeigen also auf einen beliebigen Speicherbereich. Der wird dann mit dem scanf()-Ergebnis noch vollgeschrieben. Damit überschreibst Du beliebigen Speicher Deines Programms (u.U. halt auch den Stack und damit Rücksprungadressen - Hallo liebe Sicherheitslücke) oder das ganze segfaultet eben. Zudem kann man mit scanf() extrem leicht vergessen, eine Begrenzung für die Maximallänge des Puffers mit anzugeben (Du tust es hier ja auch nicht). Keine gute Idee.

Wäre als initialisierung ein *name = NULL ausreichend?

Nein, das würde gar nicht funktionieren. Dann würde die Variable name nämlich auf NULL zeigen und scanf() würde sofort abstürzen. scanf() kann die Variable name selbst nämlich nicht ändern (wie auch, bei Funktionsparametern), scanf() ändert nur den Speicherbereich auf den die Variable zeigt! Und wenn der NULL ist, dann schmiert's Dir halt ab.

Der Witz ist: Initialisieren heißt hier, dass die Variable name auf einen Speicherbereich zeigen muss, der genug Platz für die Daten, die scanf() reinschreiben will hat. Die Standard-C-Funktionen allozieren in der Regel nicht von alleine.

Das folgende Beispiel verdeutlicht beide Varianten. Es ist nicht extrem schön und man kann das ganze mit ordentlich Pointerarithmetik noch viel eleganter machen, ich hoffe jedoch, dass es die Herangehensweise an so ein Problem in C verdeutlicht und verständlich macht, wo die Fallstricke liegen.

Wie würde die Version mit Pointerarithmetik aussehen? Irgendwie lerne ich durch Beispielcode immer am besten.

Die Variante mit dem Heap kann man nicht sonderlich optimieren durch Pointerarithmetik (weil die ja erst wissen muss, wie groß der Puffer sein soll, bevor sie den Puffer füllt), daher muss man immer zuerst nach dem Komma suchen, dann den Puffer anlegen und dann erst kopieren. Ob Du nun das strstr() in einer Schleife selbst nachbaust oder nicht gibt sich nicht viel.

Bei der Variante mit der festen Puffergröße kann man damit die Logik dagegen schon etwas ändern:

void puffer(void) {  
 char name[PUFFER_GROESSE], ort[PUFFER_GROESSE], alter[PUFFER_GROESSE];  
 const char *pos1, *pos2;  
 char *ptr;  
  
 // Alle Puffer mit 0-Bytes initialisieren  
 memset (name, 0, sizeof(name));  
 memset (ort, 0, sizeof(ort));  
 memset (alter, 0, sizeof(alter));  
  
 pos1 = daten;  
 for (pos2 = pos1, ptr = name; *pos2 && *pos2 != ','; pos2++, ptr++) {  
  if (ptr - name < PUFFER_GROESSE - 1) {  
    *ptr = *pos2;  
  }  
 }  
 // , nicht gefunden  
 if (!*pos2) {  
  fehler ();  
  return;  
 }  
 // Nächste position bestimmen  
 pos1 = pos2 + 1;  
 for (pos2 = pos1, ptr = ort; *pos2 && *pos2 != ','; pos2++, ptr++) {  
  if (ptr - ort < PUFFER_GROESSE - 1) {  
    *ptr = *pos2;  
  }  
 }  
 // , nicht gefunden  
 if (!*pos2) {  
  fehler ();  
  return;  
 }  
  
 pos1 = pos2 + 1;  
 for (pos2 = pos1, ptr = alter; *pos2 && *pos2 != ','; pos2++, ptr++) {  
  if (ptr - alter < PUFFER_GROESSE - 1) {  
    *ptr = *pos2;  
  }  
 }  
  
 // Fertig  
 ausgabe (name, ort, alter);  
  
 // Puffer existieren auf dem Stack, müssen nicht aufgeräumt werden  
}

// Komma, +1 wegen 0-Byte am Ende
// calloc verwenden, damit der Speicher mit 0-Bytes initialisiert ist
// (alternativ: malloc() und letztes Byte auf 0 setzen)
name = calloc(1, (size_t)(pos2 - pos1 + 1))

Wie würde dann das Beispiel mit malloc() genau aussehen?

name = (char *) malloc((size_t)(pos2 - pos1 + 1));

Ja.

WIe kann ich nun bei einem Pointer das letzte Zeichen ansprechen? Wäre name vom Typ char[] ginge das ja mit name[strlen(name)-1].

Nein! Bzw. beim Ansprechen ist es egal ob es char[] oder char * ist - allerdings musst Du zwei Dinge beachten:

1) strlen() sucht nach dem 0-Byte. Wenn Du's noch nicht eingefügt hast,
    dann fährst Du strlen() damit gegen die Wand!
 2) strncpy() terminiert den Zielstring nur dann mit einem 0-Byte, wenn
    der Quellstring zuende ist. Das ist hier aber nicht der Fall, da der
    Quellstring an der Stelle, wo wir aufhören wollen, ein Komma hat.

Aus dem Grund muss man den 0-String selbst terminieren, wenn man nicht schon den kompletten Puffer vorher auf 0 initialisiert hat (deswegen habe ich calloc() verwendet):

strncpy (name, pos1, (size_t)(pos2 - pos1));  
name[(size_t)(pos2 - pos1)] = '\0';

(Wichtig: name hat hier (pos2 - pos1 + 1) Elemente, siehe den alloc-Aufruf, und nur (pos2 - pos1) davon sind gefüllt, d.h. das (pos2 - pos1 + 1)te Element wird mit der zweiten Zeile auf 0 gesetzt.)

In der Puffer-Variante ohne Zeigerarithmetik:

strncpy (name, pos1, laenge);  
name[laenge] = '\0';

In der Puffer-Variante mit Zeigerarithmetik:

 for (pos2 = pos1, ptr = name; *pos2 && *pos2 != ','; pos2++) {  
  if (ptr - name < PUFFER_GROESSE - 1) {  
    *ptr = *pos2;  
    ptr++;  
  }  
 }  
*ptr = '\0';

(Hier muss man sich halt die letzte gültige Position im name-Array merken, d.h. ptr nur inkrementiren, solange er noch gültig ist.)

(In der Puffer-Variante bräuchte es dann das memset() nicht mehr.)

Warum ist ein Cast auf size_t notwendig?

Ist es nicht, aber es ist IMHO besserer Stil, size_t ist der für alle Größen im Speicher verantwortliche Typ.

// der Puffer ist groß genug, also können wir durchaus strcpy() verwenden
// das ANSONSTEN aber böse ist
strcpy (alter, pos1);

Warum ist strcpy "böse" - auch wieder weil keine Bereichsüberprüfung erfolgt?

Ja. Hier haben wir allerdings den Zielpuffer (alter) vorher alloziert in einer Größe, die den String garantiert halten kann (wir haben ja strlen() reingesteckt), daher ist das hier OK. Wenn man so eine Situation allerdings nicht hat (was viel häufiger der Fall ist), ist strcpy() böse, weil das einfach immer weiterschreibt.

// Alle Puffer mit 0-Bytes initialisieren
memset (name, 0, sizeof(name));
memset (ort, 0, sizeof(ort));
memset (alter, 0, sizeof(alter));

Ist also eine Initialisierung also grundsätzlich notwendig, oder?

Nein, wenn man das 0-Byte manuell setzt, nicht, aber in meinen Augen spart eine vorige Initialisierung des gesamten Puffers mit 0-Bytes eine Menge Ärger später, weil man sich dann nicht von Hand drum kümmern muss. Ist natürlich eine Frage, wie viel Performance man bereit ist, einzubüßen. Bei normalen Strings spielt das keine Rolle, wenn die Daten aber sehr groß werden (Megabytes), sollte man dann doch besser das 0-Byte direkt setzen, dann macht sich das bemerkbar.

Insofern: Wenn Du viel mit Strings machen willst, dann Suche Dir entweder eine brauchbare Bibliothek, die Dir die Drecksarbeit abnimmt oder nimm eine andere Programmiersprache als C. Bei allem anderen schießt Du Dir nur selbst in die Knie (siehst ja an meinem Beispiel, dass es nicht ganz einfach ist, einen derartigen String richtig zu parsen).

Könnte an dieser Stelle eine Empfehlung für eine bestimmte C-Bibliothek erfolgen?

Nein, leider nicht. Ich habe "da draußen" einige in meinen Augen brauchbare Dinge gesehen, aber wirklich begeistert war ich noch von keiner Geschichte.

Viele Grüße,
Christian