Suchen

Analysewerkzeuge Moderne statische Codeanalyse für das Internet der Dinge

| Autor / Redakteur: Paul Anderson * / Franz Graser

Die Vernetzung der Geräte und Schwächen der Programmiersprachen sind zentrale Risiken im Zusammenhang mit IoT-Software. Einige dieser Risiken lassen sich mit statischer Analyse aufdecken und beheben.

Firma zum Thema

Bild 1: Beispiel einer Warnung eines statischen Analysetools
Bild 1: Beispiel einer Warnung eines statischen Analysetools
(Bilder: Grammatech)

Obwohl C in vielerlei Hinsicht eine problematische Programmiersprache ist, kann sie ihre dominierende Stellung bei der Implementierung vieler IoT-Geräte behaupten. C++ ist in einigen Aspekten schon deutlich besser, doch hat diese Sprache einige fundamentale Schwächen geerbt. Zudem sind viele der Konstrukte, die das Programmieren in C++ so komfortabel machen (etwa STL, Exceptions), in Embedded-Systemen ohnehin unzulässig.

Was die statische Typisierung betrifft, besteht eine entscheidende Schwäche darin, dass sich Programme schreiben lassen, die vom Compiler akzeptiert werden, aber keinen Sinn ergeben. Ohne dass der Compiler eine Warnung erzeugt, kann häufig ein Wert eines Typs so behandelt werden, als sei er von einem anderen Typ.

Sorgfältige Programmierer wissen einige dieser Probleme zwar zu umgehen, aber beim Einsatz bestimmter Standardbibliotheken ist das schwierig. So können selbst sehr erfahrenen Programmierern leicht Fehler unterlaufen, die sich einer frühzeitigen Aufdeckung entziehen.

Das zweite wichtige Problem besteht in der mangelnden Speichersicherheit, da jede Speicherstelle als beliebiger Typ behandelt werden kann. Am deutlichsten wird dies beim Zugriff auf Arrays mit Indizes, die außerhalb des zulässigen Bereichs liegen. Solange die dabei referenzierte Speicherstelle innerhalb des virtuellen Speicherbereichs des Prozesses liegt, generiert ein solcher Zugriff keinen umgehend sichtbaren Fehler.

Stattdessen werden Daten im Verborgenen verfälscht, was mysteriöse Probleme in ganz anderen Teilen des Programms hervorrufen kann. Hier liegt auch die Grundursache dafür, dass Pufferüberläufe in C und C++ so gefährlich sind. Würden die Sprachen die Einhaltung von Array-Grenzen überwachen, ließen sich entsprechende Fehler umgehend erkennen und mit definierten Exceptions behandeln.

Aufgrund ihres Alters und ihrer Herkunft hat die C-Sprache mittlerweile ein Stadium erreicht, in dem viele überkommene Verhaltensweisen auch in moderne Versionen der Spezifikation übernommen werden müssen, auch wenn sie alles andere als wünschenswert sind. Der Standard definiert drei Verhaltensweisen:

  • Unspezifiziertes Verhalten: Hier legt sich der Standard nicht fest, wie der Compiler etwas implementieren soll. Ein Beispiel ist die Auswertung von Operanden, die jeder Compiler in einer anderen Reihenfolge vornehmen kann. Dies führt eventuell zu gravierenden Problemen für die Portierbarkeit, da dasselbe Programm viele verschiedene legale Semantiken haben kann.
  • Undefiniertes Verhalten: Hier kann der Compiler beliebige Dinge tun, etwa Schreibzugriffe außerhalb der Grenzen eines Arrays vornehmen. Eine gültige Implementierung kann die Verarbeitung entweder auf unnormale Weise abbrechen oder gar nichts tun.
  • Von der Implementierung definiertes Verhalten: Hier muss die Compiler-Implementierung spezifizieren, was geschieht. Ein gutes Beispiel ist die Größe eines int-Wertes: sie kann je nach Zielplattform als 16 oder 32 Bit angesetzt werden.

Im C99-Standard gibt es zweieinhalb Seiten mit unspezifiziertem Verhalten, 13 Seiten mit undefiniertem Verhalten und sechseinhalb Seiten mit implementierungsdefiniertem Verhalten. Es sind also keine selten vorkommenden Phänomene. Die Achillesferse von C-Programmen ist eindeutig undefiniertes Verhalten, das für die meisten gravierenden Fehler verantwortlich ist (etwa Division durch Null, ungültige Zeigerindirektion, Verwendung von nicht initialisiertem Speicher, Data Races und viele mehr).

Viele Schwachstellen resultieren aus der irrigen Annahme der Programmierer, dass Datenquellen stets vertrauenswürdig sind. Die ausbleibende Prüfung von Eingangswerten auf Gültigkeit ist auch dann relevant, wenn das Thema Security Top-Priorität hat. Jede Software, die Werte aus einem Sensor einliest, sollte alle Werte als potenziell gefährlich einstufen.

Schließlich könnten diese wegen eines Hardwarefehlers außerhalb des zulässigen Bereichs liegen und einen Softwareabsturz verursachen, wenn sie vom Programm nicht geprüft werden. Die gleichen Techniken können auch zur Abwehr gegen böswillig verfälschte Daten benutzt werden und eignen sich ebenfalls, um Defekte zu finden und die riskantesten Codeabschnitte zu verstehen.

Nicht alle Datenquellen sind vertrauenswürdig

Programmierer können sich vor derartigen Defekten schützen, indem sie Eingangswerte aus potenziell riskanten Kanälen so lange als gefährlich einstufen, bis ihre Gültigkeit geprüft ist. Im Sprachgebrauch der sicheren Programmierung werden ungeprüfte Eingangswerte als ‚tainted‘ (verunreinigt) bezeichnet.

Möglicherweise ist es schwierig zu verstehen, ob ein Programm richtig mit tainted-Daten umgeht, denn hierzu müsste der Weg der Daten durch die Codestruktur verfolgt werden. Dies aber kann selbst bei kleinen Programmen recht mühsam sein und ist bei den meisten realen Applikationen auf manuellem Weg unmöglich.

Moderne statische Analysetools erstellen zunächst ein Modell des gesamten Programms, das aus abstrakten Syntaxbäumen jeder Kompilierungs-Einheit, Kontrollfluss-Graphen für jedes Unterprogramm, Symboltabellen und dem Aufrufdiagramm besteht.

Hochentwickelte Abhilfe: Das Programmmodell analysieren

Diese Darstellungen werden anschließend auf Fehler durchsucht. Bei der Auffindung etwaiger Anomalien erfolgen Warnungen, wobei sich die Defekte in drei Kategorien gliedern lassen:

  • Fehler, die grundlegende Konzeptregeln verletzen und ein undefiniertes Programmverhalten zur Folge haben (Nullzeiger-Dereferenzierungen, Pufferüberläufe, Nebenläufigkeitsfehler oder nicht initialisierter Speicher).
  • Fehler durch Verletzung der Regeln für die Benutzung eines Standard-API. Zum Beispiel gibt die C-Bibliothek nicht vor, was passiert, wenn ein Datei-Deskriptor zweimal geschlossen wird. Dies dürfte kaum absichtlich geschehen und deutet deshalb wahrscheinlich auf einen Fehler hin.
  • Unstimmigkeiten oder Widersprüche im Code, die vielleicht keinen Programmabsturz verursachen, aber ein Indiz dafür sein können, dass der Programmierer eine wesentliche Eigenschaft des Codes falsch verstanden hat. Zum Beispiel wird eine Bedingung, die stets wahr oder stets falsch ist, kaum absichtlich entstanden sein, da sie zu totem Code führt.

Statische Analysetools decken Defekte, die nur unter besonderen Umständen auftreten, sehr gut auf, und auch in einer sehr frühen Entwicklungsphase, noch bevor der Code wirklich zum Testen bereit ist. Sie sollten traditionelle Prüftechniken nicht ersetzen, aber ergänzen.

Quellcode- oder Binärcode-Analyse?

Traditionell wurden statische Analysetools nur für Quellcode eingesetzt, dabei sind sie auch aus folgenden Gründen bei Binärcode nützlich:

  • Der Quellcode steht nicht zur Verfügung (beispielsweise bei zugekauftem oder lizenziertem Code).
  • Compiler können den Quellcode unterschiedlich interpretieren. Durch Analysieren des Binärcodes können diese Abweichungen die Genauigkeit der Untersuchung nicht mehr beeinträchtigen.
  • Der Compiler selbst könnte wegen eines Defekts Code generieren, der fehlerhaft ist oder eine Sicherheitslücke entstehen lässt.

Inzwischen analysieren einige hochentwickelte statische Analysetools sowohl Quell- als auch Binärcode, etwa CodeSonar, das sich für die Analyse beliebiger Kombinationen aus Quellcode und x86- oder x64-Binaries und ARM-Binaries eignet.

Analyse riskanter Informationsflüsse

Um zu verfolgen, wie Informationen von der Angriffsfläche zu den sensiblen Punkten im Programm gelangen, arbeiten moderne Tools mit der Taint-Analyse. Taint-Quellen und -Senken lassen sich visualisieren, und die am Informationsfluss beteiligten Programmelemente können der normalen Codedarstellung überlagert werden. Das hilft den Entwicklern, die Risiken ihres Codes besser zu verstehen und daran anknüpfend zu entscheiden, mit welchen Codeänderungen sich die Schwachstellen beseitigen lassen.

Bild 2: Diese ‚Tainted Buffer Access‘-Warnung zeigt unterstrichen die Auswirkungen
Bild 2: Diese ‚Tainted Buffer Access‘-Warnung zeigt unterstrichen die Auswirkungen
(Bild: Grammatech)

Das Beispiel in Bild 2 zeigt mit der roten Unterstreichung in Zeile 467, dass der Wert der Variable msg verfälscht ist. Die Unterstreichung in der vorigen Zeile verdeutlicht, dass die Verfälschung über den Parameter epd_line in den Prozess SolvePosition gelangt. Der Fläche rechts ist zu entnehmen, dass sie durch den Aufruf von strcpy in Zeile 77 von epd.c entstanden ist.

Das Bild 3 zeigt alternativ den Taint-Verlauf als Baumdiagramm.

Den maximalen Nutzen ausschöpfen

Moderne statische Analysetools sind ausgefeilte und komplexe Softwaresysteme, die sich auf vielerlei Weise konfigurieren und einsetzen lassen. Es bedarf sorgfältiger Planung, um ihren Nutzen zu maximieren. Hier einige Empfehlungen:

Optimierung der Konfiguration: Die meisten modernen Tools können auf Dutzende unterschiedlicher Defekte prüfen, und die Arbeitsweise der einzelnen Checks lässt sich mit zahlreichen Parametern kontrollieren. Es lohnt sich, die Grundkonfiguration zu modifizieren. Am besten wendet man das Tool dazu auf bereits vertrautem Code an. Unter Umständen treten dabei Warnungs-Gattungen auf, die irrelevant sind und daher deaktiviert werden können.

Bild 3: Die Baumdarstellung des Programms zeigt die Module als Hierarchie gemäß der physischen Anordnung des Codes in Dateien und Ordnern. Während die Module mit den meisten Taint-Quellen rot markiert sind, kennzeichnet die blaue Umrandung Module mit Taint-Senken.
Bild 3: Die Baumdarstellung des Programms zeigt die Module als Hierarchie gemäß der physischen Anordnung des Codes in Dateien und Ordnern. Während die Module mit den meisten Taint-Quellen rot markiert sind, kennzeichnet die blaue Umrandung Module mit Taint-Senken.
(Bild: Grammatech)

Ebenso sind Falsch-Positivmeldungen möglich. Hierbei sind Kompromisse erlaubt: Zum Beispiel kann man die Fähigkeit des Tools zum Aufdecken bestimmter Defektklassen verbessern, indem man ihm mehr Zeit einräumt oder die Genauigkeit reduziert. Grundsätzlich ist Augenmaß gefragt: Obwohl Falsch-Positivmeldungen lästig sind, sollte man sie – auch in größerer Zahl – tolerieren, wenn das Tool dadurch mehr echte Defekte findet.

Rollout-Planung: Wird die statische Analyse erstmals an einem Programm vorgenommen, lässt sich die Vielzahl der Warnungen unter Umständen nicht sofort bewältigen. Es ist möglich, diese als Baseline-Warnungen zu markieren, die später bearbeitet und aus der Default-Ansicht ausgeblendet werden, um sicherzustellen, dass neuer Code keine neuen Warnungen generiert.

Nutzung des Continuous-Integration-Konzepts (CI): Defekte lassen sich umso leichter beseitigen, je früher sie entdeckt werden. Die fortschrittliche statische Analyse ist also dann am wertvollsten, wenn sie sowohl von den einzelnen Entwicklern als auch im Rahmen eines CI-Systems genutzt wird.

Integration mit anderen Tools: Es ist sinnvoll, fortschrittliche statische Analysetools mit anderen Entwicklungswerkzeugen wie etwa Bug-Trackern zu integrieren.

Individualisierung: Die meisten Codebasen weisen bereichsspezifische Besonderheiten auf, die es zu beachten gilt. Während moderne statische Analysetools auf das Finden allgemeiner Defekte spezialisiert sind, lassen sie sich durch individuelle Checks für diese bereichsspezifischen Regeln erweitern. Die ausgefeiltesten Tools bieten ein API, das den Endanwendern den Zugang zu den internen Repräsentationen gestattet.

Dieser Beitrag ist ursprünglich auf unserem Schwesterportal Elektronikpraxis.de erschienen.

* Paul Anderson ist Vice President für Engineering beim Codeanalyse-Spezialisten Grammatech. Er lebt und arbeitet in Ithaca im US-Bundesstaat New York.

(ID:44417212)