1. Prinzip
In C++ kann man über
class MyClass { virtual void doSomething(int params) =0; ... }
oder mit einer Defaultimplementierung das Verhalten in abgeleiteten Klassen ändern, auch wenn man die betreffenden Instanzen nur über einen Basistypzeiger kennt. Das ist das 'dynamische Binden', in der ObjektOrientierung ein wichtiges Grundprinzip.
Die Realisierung in C++ erfolgt so, dass Daten-Instanzen der class-Typen eine oder mehrere Zeiger auf Adresstabellen (vtbl, virtual Table) vor oder zwischen den Daten plazieren. Der eigentliche Aufruf ist ein indirekter call (Maschinenbefehl), zuvor wird der vtbl-Zeiger aus dem Datenbereich gelesen, etwas Adressrechnung betrieben.
2. Gefahr
Wenn ein beliebiger Softwarefehler irgendwelche Datenbereich fehlerhaft verändert,
dann kann davon auch die Referenz auf die vtbl betroffen sein. In den meisten Fällen
wird es daraufhin ein memory segmentation fault
geben, weil die Adresse eben kaputt ist
und der Prozessor irgendwohin, meist auf nicht vorhandene Speicheradressen springt.
Das kann aufgefangen werden durch Excpeptionhandling. Der Fehler wird bemerkt, es gibt
halt einen Fehler in der Applikation, Neustarten hilft.
Die Wahrscheinlichkeit, dass so etwas auftritt, ist nicht hoch, wenn ordentlich programmiert ist.
In Embedded Anwendungen gibt es
-
teils kein Exceptionhandling,
-
Weniger Speicherschutz
-
Langlaufende Anwendungen
-
Evtl. sicherheitskritische Anforderungen
D.h. die Auswirkung eines solchen Fehlers kann schon nicht mehr tolerierbar sein.
3. "State of the art"
Ich kann mich daran erinnern, dass vor einigen (zig) Jahren das Thema präsent war, so stark, dass es hieß: "Für SIL kein virtual!"
Wenn man jetzt im Netz recherchiert, findet man aber keinerlei solcher Hinweise mehr. Möglicherweise sagen die einen, die C++ anwenden "Es geht doch alles", und die anderen sagen nicht, dass sie genau deswegen bei C verharren. Daher auch meine Anmerkung: Bei PC-Applikationen sehe ich da keinen Handlungsbedarf. Mir geht es eher um die Diskussion "C++ für Embedded".
4. Dies ist ausschließlich ein C/C++-Problem
Andere Sprachen, etwa Java haben die gleichen Mechanismen für dynamische Calls,
um die Polymorphie objektorientierter Konstrukte abzubilden.
In Java schreibt man nur nicht virtual
, jede Operation ist dort virtual
wenn sie nicht mit final
gekennzeichnet ist.
Warum besteht dieses Problem in Java, C# und allen anderen Programmiersprachen nicht?
Diese Programmiersprachen verhindern Speicherfehler indem beispielsweise Array-Indizes
überprüft werden (IndexOutOfBoundsException
in Java, IndexOutOfRangeException
in C#).
Die im Folgekapitel als 'Abhilfe' vorgestellte Herangehensweise kann problemlos
in die JRE-Ablaufumgebung in adäquater Art implementiert sein. Da der einheitliche
Java-Bytecode vor der Ausführung in Maschinencode umgesetzt wird, der automatisch
generiert auch gut optimiert werden kann, ist dies alles kein Problem. Es entstehen
nicht einmal lange Laufzeiten. Es entstehen nur für kritische Dinge etwas längere
Abarbeitungszeiten im ns-Bereich.
C und genauso C arbeiten ohne dieses Auffangnetz, man arbeitet wesentlich näher am eigentlichen Maschinencode. Das ist nun oft auch ein Vorteil. Der C/-Programmierer muss muss ein Auffangnetz selbst gestalten. Das tut er aber häufig nicht. Im Nachfolgekapitel wird ein solches Auffangnetz vorgestellt.
5. Abhilfen
Ich habe selbst relativ schnell eine Alternativprogrammierung getestet, bei der
die vtbl manuell angelegt ist, mit Funktionspointern, die C-Funktionen sind und nach
static_cast<>()
vom Interface- auf den Implementierungszeiger die nicht virtuelle
class-Operation aufrufen. Das ist genau das gleiche Prinzip wie der dynamische call
über die vtbl erfolgt, nur dass die Referenz auf die vtbl manuell geholt wird, im Stack
bzw. in Registern gehalten wird, und nach dem Holen über ein erstes Element in der vtbl
signifikanzgeprüft werden kann. Damit ist dies sicher, wenn man voraussetzt das ein
fremdes Programmteil nicht den Stackbereich beeinflusst. Dann würde aber sowieso nichts gehen.
Der Zwischenschritt zwischen vtbl-Referenz holen und Aufrufen ist also unter Kontrolle.
Der zweite Vorteil ist sogar ein Zeitvorteil: Man braucht nur einmal die Referenz zu holen
und kann mehrfach verschiedene Operations aus dieser vtbl aufrufen.
Der Aufwand für die Alternativprogrammierung und -Pflege ist aus meiner Sicht nicht allzu hoch, normaler Programmieraufwand.
Es gibt eine adäquate Beschreibung mit Details der Implementierung in
insgesamt mit einer Testimplemenierung, siehe links dort.
Eine andere Alternative ist schon einige Jahre bei mir getestet, läuft über Reflection-
Informationen über eine ObjectJc-Basisstruktur und ist rein in C realisiert, funktioniert gut.
Bei dieser Alternative gibt es kein Mehrfachvererbungsproblem, dafür aber einen
längeren Suchalgorithmus auf die richtige vtbl wenn man einen Basistypzeiger hat.
Die Vererbung wird über geschachtelte struct
gemacht, also eine reine C-Lösung,
die aber auch für C++ anwendbar ist.
6. Umfrage:
Ich hatte eine kleine Umfrage erstellt. Ergebnis am 24. April, nach 6 Tage Laufzeit:
-
3 Stimmen für: C++ ist sicher. Man sollte es so verwenden wie es gedacht ist, selbstverständlich mit virtual
-
2 Stimmen für: …jedoch sollte man für embedded Anwendungen nachdenken (langlaufend, wenig Schutz, ,,,)
-
1 Stimme für: Für embedded Anwendungen sollte man auch im nicht SIL-Bereich virtual meiden. Alternativen nutzen für dynamische Aufrufe
-
keine Stimme für: Man braucht keine dynamischen Operationen, kein virtual, auch keine "Alternativlösungen"
-
1 Stimme für: Man sollte dann gleich bei C bleiben.
das sind nicht sehr viele Beteiligte. Ich habe ca. 40 Personen aus meinem Netwerken bei Xing und LinkedIn angesprochen. Man hat mir aber bestätigt, dass dies eine hohe Beteiligung sei, 1:10, normal sei 1:1000 oder so. Immerhin, das Thema ist ein spezifisches Fachthema. Es zeigt sich, dass das Thema "virtual" nicht unbedingt als sicher und "gegessen" in C angesehen wird. Es gibt aber das starke Argument, dass Sonderlösungen in C möglicherweise wieder fehlerträchtig sein könnten, man sollte genau deshalb doch bei dem Standardweg in C++ bleiben.
7. Schlussfolgerungen
7.1. Sichere Programmierung
Folgende Überlegung dazu, auch aus einem Zusammenhang wie se-trends.de/"6 Lehren aus dem Boeing 737 MAX Desaster für Systems Engineers" Wenn man voraussetzt, dass
-
a) … der Prozessor seine Maschinenbefehle immer richtig abarbeitet. Das ist nicht zu 100% sicher, aber man rechnet im Normalfall nicht mit falschen Befehlsararbeitungen.
-
b) … der ROM, oder Programmcode im RAM stabil ist. Das lässt sich überprüfen, indem mittelzyklisch (Sekundenbereich) ein CRC-Check oder dergleichen abläuft.
-
c) … der Stackbereich nicht durch Programmfehler überschrieben wird. Das ist eine Aufgabe an CPU-Hardwareentwickler. Es könnte immerhin einen Speicherschutz auf CPU-Speicherzugriffsebene geben, der aktuell beim Stackframewechsel Ober- und Untergrenze des gültigen Stackframes zum Schreiben freigibt. Man braucht dazu nicht unbedingt ein priviligiertes Level der CPU, wenn nicht bösartige Verwendung der entsprechenden Systemregister-Schreibbefehle unterstellt wird. Nach b) ist der Programmcode sicher.
-
d) … vor dem Aufruf von virtual Operations die Korrektheit der Referenz auf die vtbl geprüft wird. Da die vtbl selbst im Programmspeicherbereich steht, ist sie nach b) sicher. Aber der Zeiger darauf in den Daten ist unsicher. Es genügt, diesen Zeiger in eine Register oder in eine Stackvariable zu laden und zu überprüfen, ob er dem Typ angepasst eine korrekte vtbl refernziert, bevor er benutzt wird.
Die Punkte a) bis c) sind nicht absolut sicher. Sie werden für eine normale Anwendung jedoch als gegeben sicher betrachtet. Bei entsprechenden SIL-Level helfen diesbezüglich nur die bekannten Lösungen wie 3-aus-2-Erkennung oder einfache Rückversicherungssysteme, wozu auch der verantwortungsvolle manuelle Eingriff gehört.
7.2. Datenfehler können den Programmlauf über virtual ändern
Wenn ein beliebiger unerkannter Programmfehler beliebige Bereiche in Daten ändert, dann ändert sich der Programmlauf nur bei Nutzung von virtual Operations, oder von C-Funktionspointern im Datenbereich. Ohne virtual Operation oder Funktionspointer in den Daten wird der Programmlauf als solches, also die Programmbereiche, die als Maschinencodebefehle abgearbeitet werden, nicht beeinträchtigt. Das Programm macht also nur Dinge, die programmiert sind. Die Fehler in den Daten lassen sich an kritischen Stellen durch Signifikanzprüfungen erkennen. In diesem Sinne sind also virtual Operations (und C-Funktionspointer in den Daten) die kritische Sollbruchstelle für eine nicht beherrschte Gesamtfunktionalität.
Diese Sollbruchstelle wird verhindert, wenn entweder der Punkt d) gilt, also die Prüfung des vtbl-Pointers vor dem Aufruf, oder der gesamte Programmcode auf Datenfehler geprüft ist. Die richtige und sichere Verwendung des Punkt d) lässt sich lokal im jeweils kritischen Programmbereich prüfen. Ist d) nicht verwendet, wie es derzeit in C++ der Fall ist, dann muss der gesamte Programmcode auf mögliche Fehler geprüft werden.
7.3. Programmierrichtlinien und Codeanalyse verhindern solche Datenfehler
Vergleicht man die Situation mit der Abarbeitung von Java-Bytecode, dann gelten die gleichen Überlegungen. Der Punkt d) ist aber gesichert, wenn das Java-Laufzeitsystem (JRE) entsprechend geprüft ist. Es hängt nicht an der Anwenderprogrammierung. Denn: In der JRE wird verhindert, dass Befehle in nicht vorgesehene Speicherbereiche schreiben, egal was der Anwender programmiert. Bei Programmfehlern bei Arrayzugriffen werden die Indizes überwacht, bei castings werden die Typen überwacht. Es gibt nur typsichere castings.
In C++ ist Punkt d) nativ nicht gesichert, wenn in irgendeiner Anwendung falsche castings verwendet werden oder C-like Arrayzugriffe mit Indexfehlern ausgeführt werden. Das sind die wichtigsten Fehlerquellen. Diese müssen durch Reviewaufwand ausgeschlossen werden. Dieser Aufwand wird selbstverständlich geführt wenn SIL (Sicherheitsrelevante Software) erforderlich ist. So die Theorie.
Es gibt Hilfen, die automatischen Code-Analyse-Syteme. Hält der Anwender beim codieren bestimmte Regeln ein, so ist auch das Programmieren in C++ sicher, ähnlich wie in Java. Die beiden wichtigsten Regeln sind schon oben genannt: Keine falschen castings und keine C-Array-Zugriffe. Dies kann eine automatische Codeanalyse erkennen. Wird nun an wenigen Stellen doch begründet ein C-Array-Zugriff ausgeführt, dann meldet dies das Codeanalyse, und genau diese Stelle wird vom Reviewer genauer geprüft.
7.4. Anforderung an Compiler: vtbl-Referenz explizit und prüfbar
Eine andere mögliche und zielführende Lösung wäre es, wenn der C++-Compiler selbst die Lösung anbieten würde:
-
Es wird ganz normal mit
virtual
eine vtbl gebildet. -
Es gäbe einen automatisch generierten (nativen) Datentype, etwa
MyClass_vtbl_t
und eine entsprechende OperationmyClassData→getVtbl()
, die eine Referenz mit diesem Typ in eine lokale Variable speichern lässt.MyClass_vtbl_t vPtr = myClassData->getVtbl();
-
Es würde die Check-Routine
vPtr→check()
, die automatisch gegen einen typgerechten Checktext oder einen speziellen Prüfcode testet der automatisch erzeugt wird, wie oben beschrieben. Hinweis: Auch die RTTI (RuntimeTypeInformation) werden intern in die Gegend der vtbl geschrieben, compiler-spezifisch und ggf. nicht dokumentiert. -
⇒ dann ist im C++-Rahmen mit wenig Aufwand, mit oder ohne Excpetionbehandlung, die im Embedded-Bereich ebenfalls noch ein diskutiertes Thema ist, ein sicherer Aufruf von virtual Operations möglich. Der Aufwand eines automatischen Codeanalysetools wird gespart, was insbesondere während der Entwicklung sich gut macht.
Die Anforderung, explizite vtbl-Referenz, muss allerdings an die Compilerbauer und die Normungsgremien gestellt werden. Das wäre die Herausforderung.
Die Nutzung von typeid
im C++-Standard definiert ist zwar in diesem Bereich angesiedelt,
bringt aber nicht den notwendigen Nutzeffekt:
-
Ergebnisse von
typeid(*ref)
(diese sind vom nicht direkt verwendbaren Typetype_info
) liefern einen hash auf die Instanz, sie testen nicht ob die vtbl dem Interface entspricht. -
Wenn die vtbl gestört ist bzw. es eine Störung in den Daten gibt, die die virtuellen Operationen stört, dann wirft
typeid(…)
eine Exception, es ist ein stark fehlerhafter Zugriff. Damit ist nicht mehr checkbar ob … es noch stimmt sondern es ist bereits alles kaputt.
Ergo: Das Prinzip typeid muss verbessert werden, von den Compilerbauern.