Tipps und Hinweise

Puffer und Überläufe

Gleich zu Anfang mal ein paar Tipps zur Sicherheit. Buffer Overflows waren in der Geschichte der C-Programmierung schon immer beliebt, zum einen weil sie so einfach zu implementieren sind, und zum zweiten weil man sie sehr gerne übersieht.

Puffer werden wir bei der Socket-Programmierung wie bei allen Parsereien und anderen Textverarbeitungsdisziplinen brauchen, keine Frage. Warum also hier gesondert drauf eingehen? Ganz einfach: weil die Gefahr durch Buffer Overflows insofern grösser ist, als daß potentiell mehr Leute die Gelegenheit dazu haben, wenn ein Programm über's Netzwerk oder Internet erreichbar ist, und die Folgen auch meist wesentlich unangenehmer sind, da quasi Unbekannte die Fehler ausnutzen können, und nicht nur Leute, die einen lokalen Account hat (und die man damit kennen sollte).

Bei einem Pufferüberlauf werden im Grunde ganz einfach mehr Daten in einen Puffer geschrieben, als reinpassen. Wenn die Daten, die überlappen, von einer bestimmten Beschaffenheit sind, dann kann der Angreifer damit unter Umständen beliebige Aktionen veranlassen. Wer das genauer wissen will, der sei auf meinen Text Buffer Overflows für Jedermann hingewiesen.

Die Socket-Funktionen wie z.B. recv() haben alle einen Parameter, der angibt wieviel Platz im Zielpuffer verwendbar ist. Wenn man hier den richtigen Wert angibt kann eigentlich nichts passieren. Wenn man Text erwartet, dann ist es günstig hier ein Byte weniger anzugeben, und das letzte, freie Byte, im Puffer nach dem recv() auf '\0' zu setzen.

Potentiell anfällige Funktionen sind z.B. strcpy(), strcat() und sprintf() aus der C-Bibliothek. Diese haben keinen solchen Parameter, der den Platz im Zielpuffer angibt. Sie sollten nur dort verwendet werden, wo ganz sicher nichts passieren kann (z.B. wenn man mit sprintf() einen Integer-Wert in einen Puffer von 4096 Bytes schreiben will). In C90 (ANSI-C) gab es bereits teilweise Abhilfe in Form von strncpy() und strncat(), die ebenfalls eine Grösse angegeben bekommen, neu in C99 hinzugekommen ist snprintf() (das gab's zwar zumeist schon früher, aber jetzt ist die Existenz vorgeschrieben, so sich das System für C99-kompatibel hält). Diese sollten immer verwendet werden, wenn es möglich ist dadurch Fehler zu vermeiden. Achtung, strncpy() hat ein etwas komisches Verhalten beim Auffüllen des Zielpuffers, auf einigen Plattformen existiert eine performantere Implementierung namens strlcpy(), die allerdings wiederum keinem Standard entspricht.

Puffergrössen

Früher habe ich meistens 1024 als BUFFER_SIZE definiert und überall verwendet. Davon möchte ich abraten, meine neue Marotte ist ein BUF_SIZ von 4096 Bytes. Netzwerkmässig ist nur interessant, daß ein Paket das reinkommt so groß sein kann, wie die MTU (Maximum Transfer Unit) des Netzes ist. Das wären 1500 Byte für Ethernet. Für ein recv() wäre also ein hinreichend großer Puffer eine nützliche Sache. Es sei jedoch angemerkt, daß beliebig größere Datagramme ankommen können, wenn die sendende Einrichtung die MTU nicht beachtet, und es zur Fragmentierung von Datagrammen kommt.

Auf i386-Systemen ist die Pagesize auch 4 KB, daher kann es manchmal ungeschickt sein solche Anforderungen an malloc zu stellen, da dieses ja auch Verwaltungsinformationen braucht, und dann am Ende zwei Seiten holt, und eine davon nur mit ein paar Byte beschreibt. Irgendwer sagte mir mal, daß es im Allgemeinen am besten ist ein paar Bytes unter einer 2er Potenz zu holen (ich glaube das war die Beschreibung zu kmalloc im O'Reilly-Buch zu Gerätetreiber-Programmierung unter Linux). Falls irgendwann mal die Performance an einem malloc hängen sollte, wisst ihr nun was man daran noch drehen könnte. Ich bleibe trotzdem bei 4096 Bytes.

Schrott im Puffer

Puffer legt man meistens als lokale ("automatische") Variable auf dem Stack an. Dadurch ist der Puffer mit irgendeinem Müll vorbelegt. Man darf also einem Puffer wirklich nur so weit vertrauen, wie man ihn selbst mit Inhalt gefüllt hat! Insbesondere was Null-Terminierung von Strings angeht. Nur so als Tipp am Rande, ist ein gern gemachter Fehler.

Zeilenumbrüche im Netzwerk

Zeilen werden in der Netzwerkwelt zumeist mit CRLF (Carriage Return, Line Feed, also \r\n) abgeschlossen. Kann zu komischen Überraschungen führen, wenn man dann z.B. nur \n abschneidet, oder mit einem Server spricht, und der einfach so tut, als würde er einen nicht hören ;-)

Blockierende Systemcalls sind Deine Freunde

Viele glauben aus irgendwelchen Gründen, daß sie das Blockieren von Systemcalls unbedingt verhindern müssen. Das Blockieren ist ein Service vom Kernel, er will damit helfen CPU-Zeit zu sparen. Ein Systemcall blockiert genau dann, wenn irgendwelche Ressourcen nicht da sind, die er braucht um eine Anfrage zufriedenzustellen. Wenn man z.B. gerne von einem Socket lesen will, in dem nichts drin steht, dann lässt er einen solange schlafen, bis es etwas zu lesen gibt. Das Programm merkt also gar nicht, daß es wartet, und man spart sich sehr viel hässlichen Code, der einen Kompromiss zwischen dem Verbrennen von CPU-Zeit und unnötig langen Wartezeiten (in denen im Socket was steht, aber der Prozeß nicht draus liest) sucht. Diese performancekritische Kram steckt schon komplett im Kernel, und da jener die Situation viel besser überblicken kann, als man es im Userspace hinbekommt, ist es nur klug ihn die Arbeit erledigen zu lassen.

Es gibt Fälle, in denen kann man es sich tatsächlich nicht leisten in einem recv() auf einen Socket zu blockieren. Da liegt die Betonung allerdings nicht auf blockieren, sondern auf einen. In den meisten Fällen will man von mehreren Sockets (oder anderen Deskriptoren wie z.B. STDIN_FILENO) lesen, und muß auf den reagieren, auf dem als erstes was ankommt. Das sieht im Userspace dann so aus, daß man nicht-blockierende Sockets verwendet, und ständig alle Sockets abklappert und nach Neuigkeiten fragt. "Haste was? Haste was? Haste was? Haste was?". Die Lösung, die man will, ist meistens select(), analog dazu wäre hier die Anfrage "Ich will was von den folgenden Sockets: a, b, c, ... Sag mir bescheid, sobald was auf einem davon passiert, ich schlafe solange eine Runde". Das ist nicht nur augenscheinlich besser, sondern tatsächlich.

Binärdaten

Es ist meistens eine schlechte Idee Zahlen (z.B. einen int) binär über's Netzwerk zu senden, da neben der Byteorder noch andere Dinge auf der Zielplattform anders sein können. Noch fieser wird das bei Fließkommazahlen. Solche Werte sollte man als ASCII-Darstellung versenden.

Ansonsten ist es eine gute Idee den Binärdaten eine Länge voranzustellen, denn wie schon erwähnt müssen bei einem TCP-Socket die Pakete nicht in der gleichen Form ankommen, wie man sie in den Socket gestopft hat, da TCP nur als Bytestream-Protokoll gedacht ist, also die Reihenfolge der einzelnen Bytes garantiert wird, mehr nicht. Wird jetzt ein Brocken von 4096 Byte reingeschrieben, kann es sein, daß man zunächst nur 1200 Byte lesen kann. Liegt die Information, wo ein Datenstück aufhört, durch die Aufteilung in Brocken beim Senden vor, und diese geht verloren, ist man aufgeschmissen.

Im Allgemeinen ist es eine gute Idee ASCII-Protokolle mit verständlichem Text zu schreiben. Mag im Schnitt ein paar Bytes länger ausfallen, als irgendeine kryptische Pfuscherei, aber beim Debuggen ist es unendlich bequem wenn man mit einem Netzwerksniffer einfach mitlesen kann. Das weiß jeder, der einen Server für z.B. HTTP implementieren will, und dann mal gemütlich mit netcat oder telnet mit seinem Server plaudern kann, um zu sehen wo's denn jetzt genau klemmt.