Aus Linux-Magazin 07/2019

Statische Codeanalyse findet vermeidbare Fehler

© Irina Kharchenko, 123RF

Seit 1972 sind Buffer-Overflow-Angriffe verstanden, und doch dominieren Pufferüberläufe bis heute die Warn-Listen von Security-Spezialisten. Ein Plädoyer für das Einhalten von Coding-Standards, mehr und bessere Sourcecode-Reviews sowie den Gebrauch guter Tools zur statischen Analyse.

Ein Bonmot des Rechtsmediziners Wolfgang Eisenmenger zur nachlässigen Leichenschau in Deutschland lässt sich problemlos auch auf Software ummünzen: Gliche die Qualität von Autos der von Software, wären alle Pannenstreifen überfüllt. Ausbildungsmängel, unzureichende Qualitätssicherung und Kontrolle sind ursächlich für pannenreiche Software. Der Artikel zeigt wie Codeanalyse die Qualität und Sicherheit steigern kann.

Schon 1972 gab es die erste, wenn auch theoretische Beschreibung des Buffer-Overflow-Angriffs [1], seit 1998 sind SQL-Injections [2] verstanden. Beide stellen heute noch die Mehrheit der Ursachen für IT-Sicherheitslücken, und beide lassen sich leicht vermeiden: Buffer Overflows durch die »n«-Funktionen wie »strncpy()« statt »strcpy()«, SQL-Injections durch »prepared«-Statements. Doch wer prüft, ob Entwickler sich daran halten?

Coding-Standards, die sichere Funktionen vorschreiben, würden helfen solche alten Fehler nicht zu wiederholen. Doch kaum eine Firma und kaum ein Projekt erklären solche Leitlinien als verbindlich, geschweige denn kontrolliert jemand deren Einhaltung. Damit entwickeln die wenigsten Entwickler eine Neigung, sich an Sicherheitsvorgaben zu orientieren.

Klammern und einrücken

Es macht die Code-Review als Arbeitsaufgabe zudem nicht attraktiver, wenn der damit Beauftragte von Hand prüfen muss, ob sein Kollege zum Beispiel seine geschweiften Klammern richtig eingerückt hat. Tools können und sollten solche stupiden Arbeiten übernehmen, doch die müssen flexibel sein.

Schon allein bei der optischen Anordnung der geschweiften Klammern und ihrer Einrück-Tiefe scheiden sich die Geister: Während der Autor des Artikels seine Pascal-Vergangenheit nie ganz abschütteln kann und für ihn ein Block auf einer neuen Zeile mit einer Klammer (Pascal-»begin«-artig) losgeht, sehen viele schon den Schleifenkopf oder die Bedingung als ausreichend an und setzen die öffnende Klammer an deren Ende. Listing 1 verdeutlicht die Unterschiede in Pseudocode.

Auch die Einrücktiefe im Quelltext ist Interpretationssache: Zwei Leerzeichen, oder ein Tab? Und wie viele Leerzeichen maximal sind das Äquivalent zum Tabulator? Klar, dass kriegt sicher ein unterstützendes Tool geregelt, aber setzt es das Verbot von »strcpy()« und Funktionen, die als »deprecated« markiert sind, durch? Ein umfangreiches Beispiel für Coding-Standards für Entwicklung von Embedded-Software in C liefert [3].

Manche Entwicklungsumgebungen, Eclipse oder Vim beispielsweise, unterstützen bereits ab Werk das Einhalten von Standards ganz oder teilweise. Daneben gibt es eigenständige Programme wie Uncrustify [4].

Listing 1

Zwei Arten zu klammern

01 if (x == y) then
02   {
03      do something;
04   }
05
06 while ( x < y) {
07   x = x+1;
08   }

Schule fürs Leben

Bereits optisch aufzuräumen hilft Fehler in Kontrollstrukturen zu erkennen. Den ersten Erfolg hierbei fuhr der Autor bereits als Schüler ein. Als sich einmal das selbst geschriebene Lernprogramm einer Lehrerin unter hämischer Anteilnahme der Klasse eigenartig verhielt, drückte sie dem vorlauten Tobias den Quelltext in Form einer Diskette in die Hand, kombiniert mit der Hausaufgabe, eventuelle Fehler zu entfernen.

Der Quellcode war mal eingerückt, mal nicht, es ging zu wie Kraut und Rüben. Nachdem ein Code-Beautifier drübergelaufen war, zeigte sich, dass an einigen Stellen Blockenden nicht markiert waren. Um dem Compiler das Maulen abzugewöhnen, hatte die Lehrerin die unterzähligen Blockabschlüsse einfach an anderen Stellen platziert. Die Anekdote zeigt: Allein durch optisch saubere Programmierung sind Fehler zu vermeiden.

JS Lint: Kritikerpapst für Javascript-Autoren

Einen Schritt über sauberen Stil hinaus gehen Codeanalyse-Tools: Für Javascript zum Beispiel bietet JS Lint [5] einen Online-Check an, der sich in Abbildung 1 testweise das Skript »quadrat.js« aus [6] vornimmt. Die initial viel zu ausführliche Kritik grenzt ein, wer JS Lint anweist, sich auf einen Browser zu beschränken. Gleichwohl zeigt sich JS Lint von Fall zu Fall überkritisch. So moniert es gern den Umgang mit den Gänsefüßchen, den anerkannte Referenzen wie [7] deutlich entspannter sehen.

Abbildung 1: JS Lint &auml;u&szlig;ert Kritikpunkte zum Beispielprogramm.

Abbildung 1: JS Lint äußert Kritikpunkte zum Beispielprogramm.

C-Programmierer hantieren mit Splint

Analog zu JS Lint liefert Splint [8] für C eine gründliche Analyse des Codes. Listing 2 zeigt ein bewusst misslungenes Beispielprogramm. Schon bei manueller Durchsicht fallen der potenzielle Buffer Overflow in Zeile 8 und die Formatstring-Schwachstelle in Zeile 9 auf. GCC kompiliert den Quellcode aber ohne größeren Widerstand zu einem ausführbaren Programm. Abbildung 2 bestätigt den Eindruck, wie marode das Programm ist.

Bevor Splint zeigen kann, ob es die Mängel auch erkennt, gilt es den klassischen Fünfkampf aus Download, Auspacken, Kompilieren und gegebenenfalls Installieren zu absolvieren:

tar -xzf splint-3.1.2.src.tgz
cd splint-3.1.2
./configure
make
make install  # wenn gewünscht

Ist der Kampf gewonnen, muss Splint noch erfahren, wo es die Informationen zu den Headerfiles findet. Ohne Installation liegen die im Unterverzeichnis »lib«, der Pfad lautet somit zum Beispiel »export LARCH_PATH=home/tobias/code/splint-3.1.2/lib/”«.

Manche Distributionen halten in ihren Repositories auch die aktuellen Splint-Binaries vor. Auf jeden Fall darf es jetzt losgehen mit:

/bin/splint -strict example1.c

Listing 3 zeigt die Ausgabe und damit das volle Ausmaß des digitalen Grauens. Splint deckt unnachgiebig jede Schlamperei auf und bringt mit sieben Warnings deutlich mehr als die eine, die »gcc -Wall«, also die Ausgabe mit allen Warnings, entdeckt (Listing 4).

Abbildung 2: Es ist gut zu erkennen, von dem Beispielprogramm ist in erster Linie Unheil zu erwarten.

Abbildung 2: Es ist gut zu erkennen, von dem Beispielprogramm ist in erster Linie Unheil zu erwarten.

Listing 2

example1.c mit Fehlern

01  #include <strings.h>
02  #include <stdio.h>
03
04  #define BUFSIZE 10
05  int main(int argc, char * argv [])
06    {
07      char buffer [BUFSIZE];
08      strcpy(buffer, argv[1]);
09      printf(buffer);
10    } // end main()

Listing 3

splint -strict example1.c

01 Splint 3.1.2 --- 11 May 2019
02
03 example1.c: (in function main)
04 example1.c:9:5: Format string parameter to printf is not a compile-time
05                    constant: buffer
06   Format parameter is not known at compile-time.  This can lead to security
07   vulnerabilities because the arguments cannot be type checked. (Use
08   -formatconst to inhibit warning)
09 example1.c:9:5: Called procedure printf may access file system state, but
10                    globals list does not include globals fileSystem
11   A called function uses internal state, but the globals list for the function
12   being checked does not include internalState (Use -internalglobs to inhibit
13   warning)
14 example1.c:9:5: Undocumented modification of file system state possible from
15                    call to printf: printf(buffer)
16   report undocumented file system modifications (applies to unspecified
17   functions if modnomods is set) (Use -modfilesys to inhibit warning)
18 example1.c:10:4: Path with no return in function declared to return int
19   There is a path through a function declared to return a value on which there
20   is no return statement. This means the execution may fall through without
21   returning a meaningful result to the caller. (Use -noret to inhibit warning)
22 example1.c:8:5: Possible out-of-bounds store: strcpy(buffer, argv[1])
23     Unable to resolve constraint:
24     requires maxRead(argv[1] @ example1.c:8:20) <= 9
25      needed to satisfy precondition:
26     requires maxSet(buffer @ example1.c:8:12) >= maxRead(argv[1] @
27     example1.c:8:20)
28      derived from strcpy precondition: requires maxSet(<parameter 1>) >=
29     maxRead(<parameter 2>)
30   A memory write may write to an address beyond the allocated buffer. (Use
31   -boundswrite to inhibit warning)
32 example1.c:8:20: Possible out-of-bounds read: argv[1]
33     Unable to resolve constraint:
34     requires maxRead(argv @ example1.c:8:20) >= 1
35      needed to satisfy precondition:
36     requires maxRead(argv @ example1.c:8:20) >= 1
37   A memory read references memory beyond the allocated storage. (Use
38   -boundsread to inhibit warning)
39 example1.c:5:14: Parameter argc not used
40   A function parameter is not used in the body of the function. If the argument
41   is needed for type compatibility or future plans, use /*@unused@*/ in the
42   argument declaration. (Use -paramuse to inhibit warning)
43
44 Finished checking --- 7 code warnings

Listing 4

gcc -Wall example1.c

01 example1.c:9:12: warning: format string is not a string literal (potentially insecure) [-Wformat-security]
02     printf(buffer);
03            ^~~~~~
04 example1.c:9:12: note: treat the string as an argument to avoid this
05     printf(buffer);
06            ^
07            "%s",
08 1 warning generated.

Fehlerbericht

Als erstes Warning kommt der Hinweis auf die mögliche Format-String-Schwachstelle in Zeile 9. Das ist für eine Check-Software nicht schwer zu erkennen, denn diese »printf()«-Funktion besitzt einen variablen Parameter, was gefährlich ist. Hier darf ein Angreifer »printf()«, wie Abbildung 2 zeigt, einfach einen Formatstring übergeben. Printf erwartet dann die Daten auf dem Stack und versucht, nicht vorhandene Daten auszulesen. Mit ein paar Kunstgriffen kann ein Angreifer so auch den Stackinhalt überschreiben.

Die Lösung ist einfach: »printf()« braucht immer mindestens einen fixen Formatstring und, soweit nötig, die dort einzusetzenden Zeichenketten. Zeile 9 lautet damit richtig »printf(“%s”, buffer)«.

Die zweite, neue Warnung bezieht sich auf den unsauberen Stil: »main()« ist mit einem Rückgabewert vom Typ Integer deklariert. Doch findet sich im Kontrollfluss keine einzige Return-Anweisung, »main()« bekommt also keinerlei Rückgabewert. Auch das kann zu unschönen Überraschungen führen.

Vor dem Buffer Overflow warnt Splint an dritter Stelle: Es könne nicht erkennen, wie sichergestellt ist, dass niemand über die Array-Grenzen der Variable »buffer« hinausschriebe. Das ist zwar etwas verbrämt formuliert, beschreibt aber genau den hier möglichen Überlauf. Die Lösung dafür heißt »strncpy()« – übrigens 1990 in den ISO-C-Standard aufgenommen und damit alt genug, dass es Programmierer endlich standardmäßig benutzen.

In derselben Zeile entdeckt Splint noch einen weiteren Fehler, einen möglichen Out-of-Bounds-Read: Was passiert bei einem Aufruf ohne weitere Parameter? Statt zuerst »argc« zu checken, nimmt das Programm einfach an, dass es einen Parameter gibt. Wer es ausprobiert, bekommt einen weiteren Segmentation Fault. Auf den Fehler deutet auch das letzte Warning hin: »argc« sei zwar als Funktionsparameter deklariert, doch nie benutzt.

Zwischenfazit

Wer Splint regelmäßig über seine Programme laufen lässt, entdeckt die kleinen Flüchtigkeitsfehler schnell und ist gleichzeitig vor groben Mängeln gewarnt. Eigentlich sollte das Standard sein, denn diese Art der Qualitätsicherung verhindert effektiv Sicherheitslücken.

Splint instruieren

Es kommt durchaus vor, dass Splint warnt, weil ihm Informationen fehlen, um Code richtig einzuschätzen. Der Programmierer kann dies aber über Kommentare im Quellcode beheben. Eine korrigierte Version von Listing 1 zeigt Listing 5. Es enthält einige Steuerinformationen, die bewirken, dass Splint keine Warnings mehr absondert.

Die Hinweise an Splint sind in C als Kommentar verpackt, der mit einem »@«-Symbol startet und endet, wie beispielsweise in Zeile 6. Hier verspricht der Programmierer, dass anderweitig sichergestellt ist, dass mindestens »argv[1]« existiert. Nun verschwindet das Out-of-Bounds-Read-Warning. Tatsächlich prüft Zeile 12 brav »argc«, und der Programmierer löst das Versprechen ein.

Zeile 7 sagt Splint zu, dass keine Dateisystemoperationen geplant seien, und beschwichtigt es damit bezüglich »printf()« in Zeile 15, das zudem nun um einen ordentlichen Format-String reicher ist. Zeile 10 ist eine Kurzschreibweise, die das gesamte Array mit Ascii 0 befüllt. Diese Syntax kennt Splint nicht und würde monieren, nicht alle Elemente des Arrays seien gefüllt. Zeile 9 schaltet diese Warnung ab. Damit Splint den Rest des Codes weiterhin kritisch beschaut, schaltet Zeile 11 die Überwachung wieder ein. Das Plus beziehungsweise Minus vor dem Wert macht die Musik.

In Zeile 14 ist eine explizite Typkonversion dazugekommen – sonst beschwert sich Splint über den falschen Datentyp. Dort ist übrigens ein klassischer Programmierdenkfehler berücksichtigt: C terminiert Strings mit der binären Null. Auch diese verbraucht ein Byte im Puffer. Daher passen in einen zehn Zeichen fassenden Puffer maximal neun lesbare Zeichen. Wer es ausprobieren möchte, nimmt Listing 6, das mit allen nötigen Splint-Hinweisen ausgestattet explizit warnt (Abbildung 3).

Listing 5

Das Beispiel mit Splint-Anweisungen

01  #include <strings.h>
02  #include <stdio.h>
03
04  #define BUFSIZE 10
05  int main(int argc, char * argv [])
06    /*@requires maxRead(argv) >= 1 @*/
07    /*@-modfilesys@*/
08    {
09      /*@-initallelements@*/
10      char buffer [BUFSIZE] = {'\0'};
11      /*@+initallelements@*/
12      if ( ( argc > 0 )  && (argv != NULL) )
13        {
14          strncpy(buffer, argv[1], (size_t) (BUFSIZE-1));
15          printf("%s\n",buffer);
16        }
17      return 0;
18    } // end main()

Listing 6

Ein Zeichen zu viel

01 #include <stdio.h>
02 #include <string.h>
03 #define BUFSIZE 10
04
05 int main(/*@unused@*/ int argc,
06          /*@unused@*/ char * argv []
07         )
08   /*@-modfilesys@*/
09   {
10     /*@unused@*/
11     char pwd[18] = "It is top secret!";
12     /*@-initallelements@*/
13     char target [BUFSIZE] = { '\0' };
14     /*@+initallelements@*/
15     char source [17] = "0123456789ABCDEF";
16
17     printf("source: %s\ntarget: %s\n",source,target);
18     strncpy(target, source, (size_t) BUFSIZE );
19     printf("source: %s\ntarget: %s\n",source,target);
20     return 0;
21   }

Abbildung 3: Splint warnt, wie der Probelauf zeigt, zurecht.

Abbildung 3: Splint warnt, wie der Probelauf zeigt, zurecht.

Warum es sich lohnt

Für Splint extra Kommentare in den Quelltext einzufügen, klingt unproduktiv. Es gilt aber zu bedenken: Splint moniert nur, was unüblich ist, also worüber ein Dritter, der den Quellcode liest, stolpern würde. (Nach ein paar Monaten wird übrigens der Programmautor selbst praktisch zum Dritten.) Außerdem machen Splint-Kommentare bewusst, wo die Fallen liegen. Gesichtet und dokumentiert, sind sie auch schnell umschifft.

Die Kommentare erinnern an das Hoare-Kalkül, ein Verfahren zur formalen Programmverifikation. Dabei definiert der Entwickler Vorbedingungen, einen Rechenschritt und eine Endbedingung. Die Endbedingung muss sich aus der Vorbedingung durch den Rechenschritt herleiten lassen. Wer sich detaillierter einlesen will, findet den Originalartikel unter [9] und etwas lesefreundlichere Vorlesungsfolien der FU Berlin unter [10].

Diese sehr formale Herangehensweise ist für viele Anwendungsprogramme nur beschränkt geeignet, aber eine gute Denkschule. Denn sie zwingt dazu, die einzelnen Programmschritte nicht “nur einfach” in Codeform zu bringen, sondern wirklich zu überdenken.

RATS-Fänger

Wem Splint zu pingelig und zu ausführlich arbeitet, sollte versuchen RATS (Rough Auditing Tool for Security, [11]) zu domestizieren. Statt konkreter Warnungen gibt das Tool zu kritischen Code-Stellen allgemeinere Hinweise. Installiert kriegt man es wieder entlang der klassischen Fünferkette:

unzip rough-auditing-tool-for-security-master.zip
cd rough-auditing-tool-for-security-master
./configure
make
make install # wenn gewünscht

Was »rats« mit den Parametern »-w3 example1.c« aus Listing 2 macht, zeigt Listing 7. Wer das Tool nur im aktuellen Verzeichnis liegen und nicht installiert hat, muss mit »-d« noch den Pfad zur »rats-c.xml« übergeben, die typische C-Fehler beschreibt.

Anhand der RATS-Meldungen finden Entwickler im Code die kritischen Stellen auch, die Erklärungen sind gerade in Bezug auf die Sicherheitsprobleme etwas verständlicher. Allerdings bietet Splint den Vorteil, über die bekannten Sicherheitsthemen hinaus auch weitere Probleme im Quelltext aufzuzeigen. Splint prüft auch konkreter. Zum Vergleich: Die korrigierte Programm-Fassung aus Listing 5 wirft bei RATS zwei Warnungen aus, die beide nicht zutreffen, getriggert durch die Erkennungsmerkmale “feste Puffergröße” und »strncpy()«.

Listing 7

rats -w3 example1.c

01 Entries in c database: 310
02 Analyzing example1.c
03 example1.c:7: High: fixed size local buffer
04 Extra care should be taken to ensure that character arrays that are allocated on the stack are used safely. They are prime targets for buffer overflow attacks.
05
06 example1.c:8: High: strcpy
07 Check to be sure that argument 2 passed to this function call will not copy more data than can be handled, resulting in a buffer overflow.
08
09 example1.c:9: High: printf
10 Check to be sure that the non-constant format string passed as argument 1 to this function call does not come from an untrusted source that could have added formatting characters that the code is not prepared to handle.
11
12 Total lines analyzed: 11
13 Total time 0.000192 seconds
14 57291 lines per second

Problem mit der Fangquote

Treten substanzlose Warnungen an den Tag, steigt die Gefahr des Überblätterns echter Hinweise. Der entstehende Eindruck, alles in Sachen Sicherheit erledigt zu haben, ist natürlich fatal. Auf die Haben-Seite kann RATS sich aber buchen, dass es Code in vielerlei Sprachen zu prüfen in der Lage ist.

Löhnen für Coverity

Keine freie Software, aber wegen seiner Aufspür-Fähigkeiten in der Open-Source-Szene gut beleumundet [12] ist Coverity [13], ein kommerzielles Werkzeug zur statischen Codeanalyse von C-, C++-, C#- und Java-Programmen. Dabei gibt es eine lokale Variante und eine Lösung als Clouddienst. Wie letztere sich mit den eigenen Unternehmenszielen verträgt, muss jeder selbst schauen.

Wer ein vom Hersteller anerkanntes Open-Source-Projekt verwaltet, darf auf den Cloud-Ableger [14] zurückgreifen und gratis eine Codeanalyse durchführen. Den Service nutzt unter anderem der Linux-Kernel – seither hat sich gemessen an der Zahl der Findings die Codequalität deutlich erhöht.

Aus der Not eine Tugend

Wer sich einen gründlichen und sauberen Programmierstil angewöhnen möchte, ist zweifellos mit Splint gut beraten und in guter Gesellschaft. Entwickler, die auch jedem False Positive gründlich nachgehen wollen, finden in RATS einen helfenden Weggefährten.

Wichtig ist in allen Fällen das Ergebnis: Zwang zur Qualitätssicherung, Umdenken und Umlernen durch die stete, unnachgiebige Kritik der Kontrollprogramme und dadurch Sicherheitspannen-ärmere Software. Dass statische Codeanalyse, Reviews und Coding-Standards in der Realität etwas bringen, zeigt Open BSD mit nur zwei remote ausnutzbaren Sicherheitslücken in 20 Jahren.

Der Autor

Tobias Eggendorfer ist Professor für IT-Sicherheit in Ravensburg, freiberuflicher IT-Berater und fest überzeugt, dass Software sicher sein kann – sichert man endlich mal ihre Qualität. Die Nutzer müssen die Qualität aber einfordern, statt sich resigniert mit Abstürzen abzufinden.

DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 6 HeftseitenPreis €0,99
(inkl. 19% MwSt.)
LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Nach oben