Könnten Programmüberprüfungstechniken das Auftreten von Fehlern des Genres Heartbleed verhindern?

9

In Bezug auf den Heartbleed-Bug schrieb Bruce Schneier in seinem Crypto-Gram vom 15. April: "Catastrophic" ist das richtige Wort. Auf der Skala von 1 bis 10 ist dies eine 11. ' Ich habe vor einigen Jahren gelesen, dass ein Kernel eines bestimmten Betriebssystems mit einem modernen Programmüberprüfungssystem rigoros überprüft wurde. Könnten daher durch die Anwendung von Programmverifikationstechniken heute verhindert werden, dass Fehler des Genres Heartbleed auftreten, oder ist dies noch unrealistisch oder sogar grundsätzlich unmöglich?

Mok-Kong Shen
quelle
2
Hier ist eine interessante Analyse dieser Frage von J. Regehr.
Martin Berger

Antworten:

6

Um Ihre Frage so präzise wie möglich zu beantworten: Ja, dieser Fehler wurde möglicherweise von formalen Überprüfungstools behoben. In der Tat ist die Eigenschaft "niemals einen Block senden, der größer als die Größe des gesendeten Hearbeats ist" in den meisten Spezifikationssprachen (z. B. LTL) relativ einfach zu formalisieren.

Das Problem (was eine häufige Kritik an formalen Methoden ist) ist, dass die von Ihnen verwendeten Spezifikationen von Menschen geschrieben wurden. In der Tat verlagern formale Methoden die Herausforderung bei der Fehlersuche nur von der Suche nach Fehlern zur Definition der Fehler. Dies ist eine schwierige Aufgabe.

Außerdem ist die formelle Überprüfung von Software aufgrund des Problems der Staatsexplosion notorisch schwierig. In diesem Fall ist es besonders relevant, da wir oft Grenzen abstrahieren, um die Staatsexplosion zu vermeiden. Wenn wir beispielsweise sagen möchten, dass auf jede Anfrage innerhalb von 100000 Schritten eine Bewilligung folgt, benötigen wir eine sehr lange Formel. Daher abstrahieren wir sie in die Formel "Auf jede Anfrage folgt schließlich eine Bewilligung".

Im Fall von Heartbleed hätte die fragliche Grenze sogar beim Versuch, die Anforderungen zu formalisieren, abstrahiert werden können, was zu demselben Verhalten geführt hätte.

Zusammenfassend lässt sich sagen, dass dieser Fehler möglicherweise mit formalen Methoden vermieden werden könnte, aber es hätte einen Menschen geben müssen, der diese Eigenschaft im Voraus spezifiziert.

Shaull
quelle
5

Kommerzielle Programmprüfer wie Klocwork oder Coverity konnten Heartbleed möglicherweise finden, da es sich um einen relativ einfachen Fehler handelt, bei dem vergessen wurde, einen Grenzprüfungsfehler durchzuführen. Dies ist eines der Hauptprobleme, auf die sie prüfen sollen. Es gibt jedoch einen viel einfacheren Weg: Verwenden Sie undurchsichtige abstrakte Datentypen, die gut getestet wurden, um frei von Pufferüberläufen zu sein.

Für die C-Programmierung stehen eine Reihe von abstrakten Datentypen mit "sicheren Zeichenfolgen" zur Verfügung. Das, mit dem ich am besten vertraut bin, ist Vstr . Der Autor James Antill hat eine großartige Diskussion darüber, warum Sie einen abstrakten String-Datentyp mit eigenen Konstruktoren / Factory-Methoden sowie eine Liste anderer abstrakter String-Datentypen für C benötigen .

Wanderlogik
quelle
2
Coverity findet Heartbleed nicht, siehe diese Analyse von John Regehr.
Martin Berger
Schöner Link! Es zeigt die wahre Moral der Geschichte: Die Programmüberprüfung kann schlecht gestaltete (oder nicht existierende) Abstraktionen nicht ausgleichen.
Wandering Logic
2
Es hängt davon ab, was Sie unter Programmüberprüfung verstehen. Wenn Sie statische Analyse meinen, dann ist dies immer eine Annäherung als direkte Folge des Satzes von Rice. Wenn Sie das vollständige Verhalten in einem interaktiven Theorem-Procer überprüfen, erhalten Sie eine gusseiserne Garantie dafür, dass das Programm seinen Spezifikationen entspricht. Dies ist jedoch äußerst mühsam. Und Sie haben immer noch das Problem, dass Ihre Spezifikationen möglicherweise falsch sind (siehe z. B. die Explosion von Ariane 5).
Martin Berger
1
@ MartinBerger: Coverity findet es jetzt .
Monica wieder herstellen - M. Schröder
4

Wenn Sie als "  Programmüberprüfungstechnik  " die Kombination aus Laufzeitüberprüfung und Fuzzing zählen, könnte dieser spezielle Fehler aufgetreten sein .

Richtiges Fuzzing führt dazu, dass der jetzt berüchtigte memcpy(bp, pl, payload);über die Grenze des Speicherblocks liest, zu dem er plgehört. Die gebundene Laufzeitprüfung kann im Prinzip jeden solchen Zugriff abfangen, und in der Praxis hätte in diesem speziellen Fall sogar eine Debug-Version malloc, die die Parameter überprüft memcpy, die Aufgabe erledigt (hätte hier keine Notwendigkeit, sich mit der MMU herumzuschlagen). . Das Problem ist, dass die Durchführung von Fuzzing-Tests für jede Art von Netzwerkpaket mühsam ist.

fgrieu
quelle
1
Während dies im Allgemeinen der Fall ist, implementierten die Autoren im Fall von OpenSSL ihre eigene interne Speicherverwaltung, so dass es viel weniger wahrscheinlich war memcpy, die wahre Grenze der ursprünglich vom System angeforderten (großen) Region zu erreichen malloc.
William Price
Ja, im Fall von OpenSSL hätte es zum Zeitpunkt des Fehlers memcpy(bp, pl, payload)die Grenzen des OpenSSL- mallocErsatzes überprüfen müssen , nicht das System malloc. Dies schließt eine automatisierte Überprüfung der Grenzen auf binärer Ebene aus (zumindest ohne tiefes Wissen über den mallocErsatz). Es muss eine Neukompilierung mit Assistenten auf Quellenebene erfolgen, wobei z. B. C-Makros verwendet werden, die das Token ersetzen, mallocoder welche OpenSSL auch immer verwendet wird. und es scheint, dass wir dasselbe brauchen, memcpyaußer mit sehr cleveren MMU-Tricks.
fgrieu
4

Die Verwendung einer strafferen Sprache verschiebt nicht nur die Zielpfosten von der korrekten Implementierung zur richtigen Spezifikation. Es ist schwer, etwas zu machen, das sehr falsch und doch logisch konsistent ist. Deshalb fangen Compiler so viele Fehler ab.

Zeigerarithmetik, wie sie normalerweise formuliert wird, ist nicht stichhaltig, da das Typsystem nicht wirklich bedeutet, was es bedeuten soll. Sie können dieses Problem vollständig vermeiden, indem Sie in einer Sprache arbeiten, in der Müll gesammelt wird (der normale Ansatz, bei dem Sie auch für die Abstraktion bezahlen). Oder Sie können viel genauer festlegen, welche Arten von Zeigern Sie verwenden, sodass der Compiler alles ablehnen kann, was inkonsistent ist oder einfach nicht als richtig erwiesen werden kann. Dies ist der Ansatz einiger Sprachen wie Rust.

Konstruierte Typen sind gleichbedeutend mit Beweisen. Wenn Sie also ein Typensystem schreiben, das dies vergisst, gehen alle möglichen Dinge schief. Nehmen wir für eine Weile an, wenn wir einen Typ deklarieren, meinen wir tatsächlich, dass wir die Wahrheit über das, was in der Variablen enthalten ist, behaupten.

  • int * x; // Eine falsche Behauptung. x existiert und zeigt nicht auf ein int
  • int * y = z; // Nur wahr, wenn nachgewiesen wird, dass z auf ein int zeigt
  • * (x + 3) = 5; // Nur wahr, wenn (x + 3) auf ein int im selben Array wie x zeigt
  • int c = a / b; // Nur wahr, wenn b ungleich Null ist, wie: "ungleich Null int b = ...;"
  • nullable int * z = NULL; // nullable int * ist nicht dasselbe wie int *
  • int d = * z; // Eine falsche Behauptung, weil z nullbar ist
  • if (z! = NULL) {int * e = z; } // Ok, weil z nicht null ist
  • frei (y); int w = * y; // Falsche Behauptung, weil y bei w nicht mehr existiert

In dieser Welt können Zeiger nicht null sein. NullPointer-Dereferenzen existieren nicht und Zeiger müssen nirgendwo auf Null geprüft werden. Stattdessen ist ein "nullable int *" ein anderer Typ, dessen Wert entweder auf null oder auf einen Zeiger extrahiert werden kann. Dies bedeutet, dass Sie an dem Punkt, an dem die Nicht-Null- Annahme beginnt, entweder Ihre Ausnahme protokollieren oder einen Null-Zweig durchlaufen.

In dieser Welt gibt es auch keine Array-Out-of-Bound-Fehler. Wenn der Compiler nicht beweisen kann, dass er in Grenzen liegt, versuchen Sie, ihn neu zu schreiben, damit der Compiler dies beweisen kann. Wenn dies nicht möglich ist, müssen Sie die Annahme an dieser Stelle manuell eingeben. Der Compiler kann später einen Widerspruch dazu finden.

Wenn Sie keinen Zeiger haben können, der nicht initialisiert ist, haben Sie auch keine Zeiger auf den nicht initialisierten Speicher. Wenn Sie einen Zeiger auf freigegebenen Speicher haben, sollte dieser vom Compiler abgelehnt werden. In Rust gibt es verschiedene Zeigertypen, um diese Art von Beweisen vernünftig zu erwarten. Es gibt ausschließlich eigene Zeiger (dh keine Aliase), Zeiger auf tief unveränderliche Strukturen. Der Standardspeichertyp ist unveränderlich usw.

Es gibt auch das Problem, eine tatsächlich genau definierte Grammatik für Protokolle (einschließlich Schnittstellenelementen) durchzusetzen, um die Eingabeoberfläche auf genau das zu beschränken, was erwartet wird. Die Sache mit "Korrektheit" ist: 1) Alle undefinierten Zustände loswerden 2) Logische Konsistenz sicherstellen . Die Schwierigkeit, dorthin zu gelangen, hat viel mit der Verwendung extrem schlechter Werkzeuge zu tun (unter dem Gesichtspunkt der Korrektheit).

Dies ist genau der Grund, warum die beiden schlechtesten Praktiken globale Variablen und Gotos sind. Diese Dinge verhindern, dass vor / nach / invariante Bedingungen um irgendetwas gelegt werden. Es ist auch der Grund, warum Typen so effektiv sind. Wenn Typen stärker werden (wobei letztendlich abhängige Typen verwendet werden, um den tatsächlichen Wert zu berücksichtigen), nähern sie sich als konstruktive Korrektheitsbeweise an sich. Inkonsistente Programme können nicht kompiliert werden.

Denken Sie daran, dass es nicht nur um dumme Fehler geht. Es geht auch darum, die Codebasis vor cleveren Infiltratoren zu schützen. Es wird Fälle geben, in denen Sie eine Einreichung ohne einen überzeugenden maschinengenerierten Nachweis wichtiger Eigenschaften wie "folgt dem formal festgelegten Protokoll" ablehnen müssen.

rauben
quelle
1

Eine automatisierte / formale Softwareüberprüfung ist nützlich und kann in einigen Fällen hilfreich sein, aber wie andere bereits betont haben, handelt es sich nicht um eine Silberkugel. Man könnte darauf hinweisen, dass OpenSSL insofern anfällig ist, als es Open Source ist und dennoch kommerziell und branchenweit verwendet wird, weit verbreitet ist und vor der Veröffentlichung nicht unbedingt von Fachleuten begutachtet wird (man fragt sich, ob es überhaupt bezahlte Entwickler im Projekt gibt). Der Fehler wurde im Wesentlichen durch die Codeüberprüfung nach der Veröffentlichung entdeckt, und der Code wurde anscheinend vor der Veröffentlichung überprüft (beachten Sie, dass es wahrscheinlich keine Möglichkeit gibt, nachzuverfolgen, wer die interne Codeüberprüfung durchgeführt hat). Der "lehrbare Moment" mit Herzblut (unter anderem) ist im Grunde genommen eine bessere Codeüberprüfung, idealerweise vor der Veröffentlichung von hochsensiblem Code, möglicherweise besser nachverfolgt. Vielleicht wird OpenSSL jetzt einer genaueren Prüfung unterzogen.

mehr bkg von medien, die ihre herkunft beschreiben:

vzn
quelle