Selbstständigkeitserklärung Hiermit versichere ich, dass ich diese Diplomarbeit selbstständig und nur unter Zuhilfenahme der angegebenen Quellen und Hilfsmittel angefertigt habe. Alle wörtlich oder inhaltlich zitierten Stellen sind als solche kenntlich gemacht. Paderborn, 28. Februar 2003 Universität Paderborn Fachbereich 17, Informatik Proof-Carrying Code Techniken zur speichereffizienten Verifikation von Java-Bytecode Diplomarbeit Dirk Jansen Februar 2003 Vorgelegt bei: Prof. Dr. Uwe Kastens Prof. Dr. Stefan Böttcher Inhaltsverzeichnis 1 Einleitung 11 2 Grundlagen 2.1 Proof-Carrying Code . . . . . . . . . . . . . . . . . 2.1.1 Überblick . . . . . . . . . . . . . . . . . . . 2.1.2 Erstellung des Beweises . . . . . . . . . . . 2.1.3 Ablauf der Beweisüberprüfung . . . . . . . 2.2 Die Java Virtual Machine . . . . . . . . . . . . . . 2.2.1 Verifikation von Klassendateien . . . . . . . 2.2.2 Die Bytecode-Verifikation . . . . . . . . . . 2.3 Datenflussanalyse . . . . . . . . . . . . . . . . . . . 2.3.1 Verbände . . . . . . . . . . . . . . . . . . . 2.3.2 Transferfunktionen . . . . . . . . . . . . . . 2.3.3 Iterative Vorwärtsanalyse . . . . . . . . . . 2.3.4 Modellierung von komplexen Verbänden . . 2.3.5 Zusammenfassung . . . . . . . . . . . . . . 2.4 Natural Semantics . . . . . . . . . . . . . . . . . . 2.4.1 Inferenzregeln und Axiome . . . . . . . . . 2.4.2 Folgerungen . . . . . . . . . . . . . . . . . . 2.4.3 semantische Definitionen . . . . . . . . . . . 2.4.4 Die verschiedenen Formen von Folgerungen 2.4.5 Beispiel . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 13 13 14 15 16 16 16 17 19 19 20 21 21 22 22 23 23 23 24 24 3 Konzeption 3.1 Überblick über die leichtgewichtige Verifikation 3.2 Die Zertifizierungsphase . . . . . . . . . . . . . 3.2.1 Die Datenflussanalyse . . . . . . . . . . 3.2.2 Die Kompression des Zertifikats . . . . . 3.3 Die Überprüfungsphase . . . . . . . . . . . . . 3.3.1 Der Überprüfungsalgorithmus . . . . . . 3.3.2 Beispiel 1 . . . . . . . . . . . . . . . . . 3.3.3 Beispiel 2 . . . . . . . . . . . . . . . . . 3.3.4 Zusammenfassung . . . . . . . . . . . . 3.4 Erweiterungen . . . . . . . . . . . . . . . . . . 3.4.1 triviale Erweiterungen . . . . . . . . . . 3.4.2 Reihungen (arrays) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 27 27 28 29 35 38 39 44 48 49 49 49 50 7 . . . . . . . . . . . . . . . . . . . . . . . . 8 INHALTSVERZEICHNIS 3.5 3.4.3 Ausnahmebehandlungen . . . . . 3.4.4 Instruktionen mit mehr als einem 3.4.5 mehrwortige Datentypen . . . . . 3.4.6 Objektinitialisierung . . . . . . . Schlussfolgerung . . . . . . . . . . . . . 4 Korrektheit 4.1 Die herkömmliche Bytecode-Verifikation 4.1.1 Der Konstantenbereich . . . . . . 4.1.2 Schachteltypen . . . . . . . . . . 4.1.3 Verifikation . . . . . . . . . . . . 4.1.4 Methodenprüfung . . . . . . . . 4.1.5 Prüfung von Instruktionsfolgen . 4.1.6 einzelne Instruktionen . . . . . . 4.2 Die leichtgewichtige Verifikation . . . . . 4.2.1 Zertifizierung . . . . . . . . . . . 4.2.2 Überprüfung . . . . . . . . . . . 4.3 Korrektheit und Fälschungssicherheit . . 5 Realisierung 5.1 benutzte Bibliotheken . . . . . . . . . 5.1.1 jDFA . . . . . . . . . . . . . . 5.1.2 BCEL . . . . . . . . . . . . . . 5.2 Überblick über die Implementierung . 5.2.1 Zertifizierung . . . . . . . . . . 5.2.2 Die Überprüfungsphase . . . . 5.2.3 Die Kompression des Zertifikats 5.3 Einige interessante Aspekte . . . . . . 5.3.1 Verband . . . . . . . . . . . . . 5.3.2 Zertifikate . . . . . . . . . . . . 5.3.3 Transferfunktionen . . . . . . . 5.4 Schlussfolgerung . . . . . . . . . . . . . . . . . . . Sprungziel . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 51 53 57 58 61 . . . . . . . . . . . 63 63 64 64 66 66 67 68 70 71 72 74 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 77 77 77 78 79 80 81 81 82 82 83 84 84 6 Evaluierung 6.1 Ziele der Evaluierung . . . . . . . . . . . . . . . . . . . 6.1.1 Die Größe der Zertifikate . . . . . . . . . . . . 6.1.2 Speicherplatzbedarf während der Überprüfung 6.1.3 untersuchte Klassen . . . . . . . . . . . . . . . 6.2 Resultate . . . . . . . . . . . . . . . . . . . . . . . . . 6.2.1 Methodengröße . . . . . . . . . . . . . . . . . . 6.2.2 Größe der Zertifikate . . . . . . . . . . . . . . . 6.2.3 Speicherplatzbedarf während der Überprüfung 6.3 weitere Tests . . . . . . . . . . . . . . . . . . . . . . . 6.3.1 typunsichere Methoden . . . . . . . . . . . . . 6.3.2 Änderungen an Zertifikaten . . . . . . . . . . . 6.3.3 Änderungen am Bytecode . . . . . . . . . . . . 6.4 Bewertung der Ergebnisse . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 85 85 85 85 86 86 87 87 93 99 99 99 100 101 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . INHALTSVERZEICHNIS 7 Zusammenfassung 9 103 10 INHALTSVERZEICHNIS Kapitel 1 Einleitung Java-Programme werden in eine portable Zwischensprache übersetzt und dann von einer abstrakten Maschine interpretiert. Damit dieser sogenannte Bytecode schnell ausgeführt werden kann, werden wichtige Sicherheitsprüfungen – wie z.B. der Zugriff auf ungültige Speicherbereiche – einmalig vor der Ausführung im Rahmen einer Verifikation durchgeführt. Gerade im Hinblick auf Java Cards, die oft sicherheitsrelevante Informationen speichern, muss durch diese Verifikation garantiert werden, dass JavaProgramme keine Speicherbereiche mit sicherheitskritischen Informationen lesen. Da diese Bytecode-Verifikation im Allgemeinen sehr speicherplatzintensiv ist und Java Cards über vergleichsweise wenig Speicher verfügen, kommen hier spezielle Verfahren zum Einsatz. Eines dieser Verfahren führt die Verifikation außerhalb der Java Card durch und signiert danach den verifizierten Bytecode mittels eines kryptographischen Verfahrens. Auf der Karte ist dann vor der Ausführung nur noch die Gültigkeit der digitalen Signatur zu prüfen. Dies hat jedoch einen entscheidenen Nachteil: Sowohl der Anwender der Java Card als auch der Ersteller des Programms müssen der Signierungsstelle vertrauen, die darüberhinaus nach jeder Übersetzung den Bytecode neu signieren muss. Andere Ansätze versuchen die Verifikation auf der Karte trotz der beschränkten Resourcen zu ermöglichen. X. Leroy [Le02] verfolgt die Idee, den Bytecode nach der Übersetzung so zu modifizieren, dass die Verifikation garantiert mit wenig Speicher auskommt. Ein Nachteil dieser Lösung ist der erhöhte Speicherplatzbedarf während der Ausführung dieses modifizierten Bytecodes. In dieser Arbeit wird ein anderer Ansatz verfolgt, der ohne Änderungen des Bytecode auskommt und kein Vertrauen gegenüber Dritten benötigt. Als wesentliche Grundlage dient dabei die leichtgewichtige Bytecode-Verifikation [Ro98], die auf der Technik des Proof-Carrying Code [Nec97] basiert. Der Bytecode wird mit einem Beweis versehen, der als Nachweis für die Korrektheit der Verifikation interpretiert werden kann. Auf der Java Card ist dann zur Verifikation lediglich ein Nachrechnen des Beweises notwendig. Die leichtgewichtige Verifikation unterstützt nur eine Untermenge des Java11 12 KAPITEL 1. EINLEITUNG Bytecodes. Dieses Konzept wird daher von mir zunächst auf den vollständigen Java-Bytecode-Befehlssatz erweitert1 und anschließend implementiert. Diese Implementierung ermöglicht eine ausführliche Evaluierung hinsichtlich der Praxistauglichkeit und vor allem des Speicherplatzbedarf dieses Ansatzes – mit zum Teil vielversprechenden Ergebnissen. Im folgenden Kapitel 2 werde ich ich als erstes die Grundlagen dieser Arbeit vorstellen. Dies sind zum einen das Konzept des Proof-Carrying Code und zum anderen die Bytecode-Verifikation, die im wesentlichen aus einer Datenflussanalyse besteht. Kapitel 3 beschreibt zunächst die leichtgewichtige Verifikation und erläutert den Zusammenhang zum Proof-Carrying Code. Die ebenfalls in diesem Kapitel vorgestellten konzeptionellen Erweiterungen bilden den ersten wesentlichen Teil meiner Arbeit. Der Beweis der Korrektheit des Konzepts wird in Kapitel 4 nur skizziert und nicht in aller Vollständigkeit geführt, da hier der Schwerpunkt in erster Linie auf der Implementierung und Evaluierung liegt. Die Realisierung des Prototyps gefolgt von den Ergebnissen der Evaluierung machen den zweiten großen Teil dieser Arbeit aus und werden in den Kapiteln 5 und 6 vorgestellt. Dabei untersucht die Evaluierung hauptsächlich die Größe des Sicherheitsbeweises und den Speicherplatzbedarf der Beweisüberprüfung. 1 mit Ausnahme von Unterprogrammen Kapitel 2 Grundlagen Dieses Kapitel soll einen Überblick über die Thematik der Bytecode-Verifikation und die Grundlagen der hier verfolgten Lösungsansätze vermitteln. Ich werde zunächst das Prinzip des Proof-Carrying Codes vorstellen, das die Grundlage des Ansatzes von E. Rose [Ro98] bildet. Proof-Carrying Code soll hier dazu dienen, die auf der Java Card ablaufende Überprüfung des Bytecode möglichst einfach zu gestalten. Dabei wird in Kauf genommen, dass der Java-Bytecode mit zusätzlichen Informationen zu versehen ist, die relativ aufwändig zu erzeugen sind. Dieser Zusatzaufwand ist jedoch unkritisch, da dies außerhalb der Java Card durchgeführt wird. Für das weitere Verständnis ist darüberhinaus die Arbeitsweise der Java Virtual Machine (JVM) und der Bytecode-Verifikation sehr wichtig. E. Rose verwendet zur formalen Spezifikation der JVM und der Bytecode-Verifikation die Natural Semantics. Eine kurze Einführung in diesen Formalismus schließt dieses Kapitel. 2.1 Proof-Carrying Code Um die eigentliche Verifikation des Java-Bytecodes ausserhalb der Java Card durchführen zu können, bedient sich E. Rose der Technik des Proof-Carrying Code (PCC) [Nec97]. Die Idee dieser Technik ist es, Programme mit einem Beweis zu versehen. Durch Überprüfung dieses Beweises lässt sich dann vor der Ausführung die Korrektheit des Programms nachweisen. Es gibt also zwei Phasen: Die Beweiserzeugung und die Beweisüberprüfung. Der Vorteil dieser Methode liegt darin, dass die Beweiserzeugung und die Beweisüberprüfung völlig unabhängig voneinander durchgeführt werden können. Der Produzent des Programms kann durch diesen Beweis dem Konsumenten bestimmte Programmeigenschaften garantieren. Gemeinsame Basis bilden hier die Sicherheitsrichtlinien, die festlegen, wann ein Programm als korrekt bzw. als sicher angesehen werden kann. Diese Sicherheitsrichtlinien werden von dem Konsumenten festgelegt und sind die einzigen Dinge, denen der Konsument vertrauen muss. Es gibt also nicht wie bei kryptographischen Verfahren eine 13 14 KAPITEL 2. GRUNDLAGEN zentrale Zertifizierungsstelle, der sowohl Produzent als auch Kosument vertrauen müssen. Diese Sicherheitsrichtlinien werden bei der Beweiserzeugung und bei der Beweisüberprüfung benutzt. Ein Beispiel für die Verwendung von Proof-Carrying Code ist ein Paketfilter für Netzwerke [NL96]. Dabei soll ein in Maschinensprache geschriebener Paketfilter vom Benutzer in den Kern eines Betriebssystems eingebunden werden können, ohne Sicherheitslücken zu öffnen. Die direkte Einbindung des Filters in den Kern – und damit die Ausführung im gleichen Adressraum – führt dazu, dass kein Kontextwechsel nötig ist und so der Filter mit optimaler Geschwindigkeit ausgeführt werden kann. Der Maschinen-Code des Paketfilters wird mit einem Beweis versehen, der nachweist, dass nur auf Speicherbereiche zugegriffen wird, die für den Paketfilter wichtig sind. Wenn nun der Benutzer einen Paketfilter in den Betriebssystemkern einbinden möchte, prüft das Betriebssystem vor der Benutzung des Maschinen-Codes einmalig den mitgelieferten Beweis und benutzt den Filter nur, wenn dieser Beweis gültig ist. Produzent Programm (Quelltext) Phase 1 Übersetzung und Zertifizierung PCC−Binärdatei Konsument Abbruch Nein Beweis korrekt? Ja Phase 2 Maschinen− code Sicherheits− beweis Beweisüberprüfung Sicherheits− richtlinien Sicherheitsregeln Schnittstelle Ausführung auf CPU Abbildung 2.1: Übersicht über Proof-Carrying Code (PCC) 2.1.1 Überblick Abbildung 2.1 zeigt den Lebenszyklus eines PCC-Programms vom Quelltext bis zur Ausführung. Beim Produzenten des Programms wird die Zertifizierung durchgeführt. Diese Phase erzeugt nach oder während der Übersetzung einen Beweis, der belegt, dass das Programm den Sicherheitsrichtlinien entspricht. 2.1. PROOF-CARRYING CODE 15 Man könnte auch sagen, dass eine Verifikation entsprechend den Sicherheitsrichtlinien durchgeführt wird. Zum Abschluss dieser Phase wird der Beweis kodiert und zusammen mit dem Programm-Code an den Konsumenten geschickt. Der Kosument führt nun die zweite Phase durch. Die Beweisüberprüfung kontrolliert, ob der Beweis tatsächlich für das entsprechende Programm die Sicherheitsrichtlinien nachweist. Ist der Beweis gültig, so ist das Programm korrekt und kann beliebig oft ausgeführt werden. Die vom Konsumenten festgelegten Sicherheitsrichtlinien bestehen aus Sicherheitsregeln, die alle erlaubten Operationen mit ihren entsprechenden Sicherheitsvorbedingungen enthalten. Für das Beispiel der Paketfilters würden die Sicherheitsrichtlinien aus einer abstrakten Beschreibung der Maschinensprache bestehen, die der CPU der Zielmaschine entspricht. Zusätzlich enthält diese Beschreibung Regeln, die für Speicherzugriffsinstruktionen angeben, ob diese Instruktionen erlaubt sind oder nicht. Die Sicherheitsregeln hängen in diesem Fall von dem Operanden der Instruktion ab. Die Schnittstelle beschreibt die Aufrufkonventionen zwischen Konsument und fremdem Programm, d.h. welche Invarianten beim Aufruf des fremden Programms gelten müssen und welche Invarianten gelten müssen, wenn das fremde Programm Funktionen des Kosumenten aufruft oder wenn es nach Ausführung zum Konsumenten zurückspringt. 2.1.2 Erstellung des Beweises Die Hauptschwierigkeit bei der Beweiserzeugung ist die Formalisierung der Sicherheitsrichtlinien. Dazu muss zuerst die Semantik der Zielmaschine beschrieben werden. Dabei werden Sicherheitsprüfungen, die den Sicherheitsrichtlinien entsprechen, in die Beschreibung integriert. Dies bedeutet, dass unsichere Programme nach der abstrakten Beschreibung nicht definiert sind. Da keine Fehlerzustände eingeführt werden, blockiert die abstrakte Maschine bei unsicheren Programmen. Ein Programm ist daher nur dann sicher, wenn es die abstrakte Maschine nicht blockiert. Aus der formalen Beschreibung der Maschine lässt sich jetzt für jedes konkrete Programm die Semantik in diesem Formalismus beschreiben. Diese formale Darstellung des Programms wird Sicherheitsprädikat genannt. Um nun den Beweis zu erstellen, muss zuerst dieses Sicherheitsprädikat generiert werden. Zu diesem Sicherheitsprädikat wird dann ein Beweis in einer überprüfbaren Form erstellt. Wie dies zu geschehen hat, hängt von der Problemstellung ab und kann nicht immer automatisch durchgeführt werden. Die Beweiserstellung ist ähnlich einer Programmverifikation, die im Allgemeinen auch nicht vollständig automatisiert geschehen kann. In solchen Fällen ist eine Benutzerinteraktion notwendig. Beispielsweise könnte es notwendig sein, Schleifeninvarianten anzugeben. Dieser Beweis wird schließlich geeignet kodiert, um zusammen mit dem Programm übermittelt zu werden. 16 2.1.3 KAPITEL 2. GRUNDLAGEN Ablauf der Beweisüberprüfung Die Überprüfung des Beweises muss vor der ggf. mehrfachen Ausführung des Programms nur einmal durchgeführt werden und ist im Vergleich zur Erstellung sehr effizient im Bezug auf Rechenzeit und Speicherbedarf. Auch hier ist das Vorgehen abhängig vom jeweiligen Anwendungsfall. In jedem Fall wird kontrolliert, ob der Beweis für das gegebene Programm korrekt ist. Um die Proof-Carrying Code Technik auf Java-Bytecode-Programme anwenden zu können, muss die Ablaufumgebung von Java-Programmen – die Java Virtual Machine – formalisiert werden. In diesem Formalismus sollten sich auch die Sicherheitsregeln ausdrücken lassen, die sichere Java-Bytecode-Programme modellieren. Dazu muss zuerst festgelegt werden, was man überhaupt unter sicherem Bytecode zu verstehen hat. Ich gebe zunächst eine Einführung in die Java Virtual Machine, um dann auf die Bytecode-Sicherheit einzugehen. 2.2 Die Java Virtual Machine Java-Programme werden vom Java-Übersetzer in Bytecode übersetzt. Dieser Bytecode ist eine abstrakte Maschinensprache, die von der Java Virtual Machine [JVM96] (JVM) ausgeführt wird. Das besondere an der JVM ist der Operandenkeller. Fast alle Rechenoperationen werden auf diesem Keller durchgeführt. Desweiteren gibt es einen Speicherbereich für lokale Variablen. Um Berechnungen durchzuführen, werden Daten aus den lokalen Variablen auf dem Keller abgelegt, um dann durch entsprechende Kellerinstruktionen verknüpft zu werden. Das Ergebnis kann dann vom Keller gelesen und wieder in den lokalen Variablen abgespeichert werden. Die verschiedenen Operationen können auf unterschiedlichen Typen arbeiten. So kennt die JVM die Typen int, long, short, byte, char, float, double und reference. Dabei werden die Typen byte, char und short wie int-Typen behandelt. Die Instruktionen der JVM sind typisiert, d.h. eine Instruktion operiert auf bestimmten Typen und erwartet, dass auf dem Keller oder in den lokalen Variablen zu der Instruktion passende Typen vorliegen. Die JVM hat unter anderem sicherzustellen, dass diese Voraussetzungen erfüllt sind. Dies geschieht während der Verifikation, die im nächsten Abschnitt beschrieben werden soll. 2.2.1 Verifikation von Klassendateien Vor der Ausführung eines Java-Programms führt die JVM eine statische und strukturelle Überprüfung der auszuführenden Klassendatei durch. Statische Bedingungen sind z.B. der korrekte Aufbau der Klassendatei, aber auch die Forderung, dass Instruktionen keine Elemente des Konstantenbereichs ansprechen, die gar nicht vorhanden sind (Überprüfung der Indexgrenzen). Die strukturellen Überprüfung betrachtet den Zusammenhang zwischen den einzelnen BytecodeInstruktionen. So wird z.B. geprüft, dass Instruktionen nur auf zu dieser Instruktion passenden Typen operieren und dass kein Zugriff auf noch nicht zu- 2.2. DIE JAVA VIRTUAL MACHINE 17 gewiesene lokale Variablen geschieht. Der Sinn dieser strukturellen Überprüfung liegt vor allem darin, dass diese Bedingungen nicht mehr zur Laufzeit geprüft werden müssen und so die Ausführung des Java-Bytecodes beschleunigen. Die Verifikation einer Klassendatei läuft in vier Phasen ab: Phase 1: Prüfung des grundsätzlichen Aufbaus der Klassendatei. Phase 2: Überprüfung der einzelnen Komponenten der Klassendatei außer dem Code-Bereich. Dabei wird auch die Klassenhierarchie geprüft (z.B. ob jede Klasse außer Object eine Oberklasse besitzt). Phase 3: Überprüfung des Code-Bereichs. Hierbei wird neben der strukturellen Korrektheit des Bytecodes eine statische Typrekonstruktion des Operandenkellers und der lokalen Variablen durchgeführt. Phase 4: dynamische Prüfungen. Diese Phase wird während der Ausführung des Bytecodes durchgeführt. Der schwierigste Teil dieser Verifikation ist die statische Typrekonstruktion der dritten Phase. Ich werde mich in dieser Arbeit auf diesen Teilbereich der Verifikation beschränken. Der nächste Abschnitt soll das Prinzip dieser BytecodeVerifikation beschreiben. 2.2.2 Die Bytecode-Verifikation Die Bytecode-Verifikation ist der komplexeste Teil der Verifikation. Da jede Methode einen eigenen Operandenkeller und eigene lokale Variablen besitzt, kann jede Methode einzeln und unabhängig von den anderen Methoden überprüft werden. Bei Methodenaufrufen werden lediglich die korrekte Anzahl und die passenden Typen der Parameter geprüft. Es wird angenommen, dass die aufgerufene Methode korrekt ist und den der Signatur entsprechenden Ergebnistyp liefert. Die folgenden Bedingungen werden von der Verifikation für jede Methode einzeln geprüft: • Der Operandenkeller hat an einer Programmstelle immer die gleiche Größe und enthält immer die gleichen Typen. • Methoden werden mit passenden Parametern aufgerufen (richtige Anzahl und passende Typen). • Feldern werden nur Werte des passenden Typs zugewiesen. • Alle Instruktionen haben für alle Argumente die passenden Typen auf dem Keller bzw. in den lokalen Variablen vorliegen. Insbesondere bedeutet dies, dass eine lokale Variable nicht gelesen werden darf, wenn ihr Typ nicht bekannt oder eindeutig bestimmbar ist. 18 KAPITEL 2. GRUNDLAGEN Der Typ einer lokalen Variable oder des Kellers kann erst dann bestimmt werden, wenn das erste Mal ein Wert gespeichert wird. Problematisch wird dies, wenn z.B. in verschiedenen Programmpfaden verschiedene Typen in der gleichen lokalen Variable gespeichert werden und diese Programmpfade zusammenlaufen. Wird diese Variable später benutzt, müssen diese beiden Typen kombiniert werden. Bei Referenzen ist dies der speziellste gemeinsame Obertyp der entsprechenden Klassen. Wird jedoch in dem einen Pfad ein int-Wert gespeichert und in dem anderen Pfad eine Referenz, so darf auf diese lokale Variable nach der Vereinigung der beiden Programmpfade nicht mehr lesend zugegriffen werden, da der Typ nicht eindeutig bestimmbar ist. Abbildung 2.2 zeigt dazu ein Beispiel. Das Programm besteht aus vier Blöcken. Der erste Block enthält einen bedingten Sprung, so dass sich der Kontrollfluss in zwei Pfade aufteilt. Der zweite Block besitzt einen Sprung zum vierten Block, an dem sich die beiden Programmpfade wieder treffen. Neben dem Kontrollflussgraphen ist eine Tabelle zu sehen, in der die Typen des Operandenkellers und der lokalen Variable für jede Programmstelle nach Ausführung der Operation angeben sind. Ein Fragezeichen bedeutet, dass keine Information verfügbar sind und dass die entsprechende Variable daher nicht gelesen werden darf. 1 Keller 0: iconst_0 1: iload_1 2: ifle 10 2 5: iload_1 3 6: istore_2 10: aload_0 11: astore_2 7: goto 12 4 12: return lokale Variablen 0 1 2 () (A, int, ?) 0: iconst_0 (int) (A, int, ?) 1: iload_1 (int,int) (A, int, ?) 2: ifle 10 () (A, int, ?) 5: iload_1 (int) (A, int, ?) 6: istore_2 () (A, int, int) 7: goto 12 () (A, int, int) 10: aload_0 (A) (A, int, ?) 11: astore_2 () (A, int, A) () (A, int, ?) () (A, int, ?) 12: return Vorbelegung Vereinigung aus 7 und 12 Abbildung 2.2: Beispiel für eine Vereinigung zweier Typen beim Zusammentreffen zweier Programmpfade Die einzige Stelle, an der Variablen vereinigt werden müssen, ist die Programmstelle 12. Hier treffen die beiden Blöcke 2 und 3 wieder zusammen. Daher werden hier die Typen der Programmstellen 7 und 12 zusammengefasst. Durch die Zusammenfassung erhält die lokale Variable 2 einen ungültigen Eintrag symbolisiert durch das Fragezeichen, weil hier der Block 2 ein int-Wert und der Block 3 eine Referenz in diese lokale Variable schreibt. Würde in Block 4 die Variable gelesen werden, so wäre dieses Programm fehlerhaft und die Verifikation müsste es ablehnen. 2.3. DATENFLUSSANALYSE 19 Dieses Beispiel deutet auch an, dass die Typrekonstruktion nicht in einem linearen Durchlauf durchzuführen ist, weil es zwischen den einzelnen Blöcken Abhängigkeiten gibt und die abhängigen Typen nicht unbedingt schon berechnet wurden, wenn sie benötigt werden. Um die Typen zu bestimmen wird daher eine Datenflussanalyse durchgeführt. 2.3 Datenflussanalyse Eine Datenflussanalyse dient dazu, für ein Programm vorherzusagen, wie es Daten während der Ausführung manipuliert [Muc97]. Bei der Datenflussanalyse werden Datenflussgleichungen aufgestellt, die die Abhängigkeiten von Eigenschaften zwischen Programmgrundblöcken1 definieren. Bei diesen Eigenschaften handelt es sich um abstrakte Informationen über Variablen, Ausdrücke oder anderen Programmkonstrukten, die für alle möglichen Ausführungen des untersuchten Programms gültig sind. Diese Analyse ist unabhängig von den Eingabedaten. So bleibt bei Kontrollkonstrukten unberücksichtigt, wie oft z.B. eine Schleife durchlaufen oder welcher Programmzweig einer bedingten Anweisung abgearbeitet wird. Die Eigenschaften der Blöcke und deren Beziehung zueinander werden in der Datenflussanalyse als Verband modelliert. 2.3.1 Verbände Ein Verband ist eine algebraische Struktur mit zwei Verknüpfungen, mit dem sich die Eigenschaften modellieren lassen, die mit einer Datenflussanalyse bestimmt werden sollen. Dabei wird jedem Programmblock ein Element des Verbands zugeordnet. Die Verknüpfungen des Verbands werden dazu benutzt, Informationen über Programmblöcke an Stellen zu verschmelzen, an denen der Kontrollfluss zusammenläuft. Allgemein ist ein Verband folgendermaßen definiert: Definition 2.1 (Verband). Ein Verband (V, u, t) ist eine Menge V, auf der zwei Verknüpfungen meet u und join t definiert sind, für die folgendes gilt: • u und t sind abgeschlossen, d.h. für alle x, y ∈ V gilt: x u y ∈ V und xty ∈V. • u und t sind kommutativ, d.h. für alle x, y ∈ V gilt: x u y = y u x und x t y = y t x. • u und t sind assoziativ, d.h. für alle x, y, z ∈ V gilt: (xuy)uz = xu(yuz) und (x t y) t z = x t (y t z). 1 Ein Grundblock kann nur am Anfang betreten und am Ende verlassen werden – es gibt also keine Sprünge und Sprungziele innerhalb des Blocks 20 KAPITEL 2. GRUNDLAGEN • Es existieren zwei eindeutig bestimmte Elemente Bottom ⊥ und Top > in V für die gilt: x u ⊥ = ⊥ und x t > = > für alle x ∈ V . Verbände lassen sich einfach grafisch darstellen. Bild 2.3 zeigt ein einfaches Beispiel eines Verbands dargestellt als sogenanntes Hasse-Diagramm. 1 2 3 4 Abbildung 2.3: Hasse-Diagramm eines einfachen Verbands Wie sich aus dem Bild erahnen lässt, besteht ein enger Zusammenhang zwischen Verbänden und partiellen Ordnungen. Eine partielle Ordnung (V, v) erhält man, wenn man für einen Verband (V, u, t) folgendes definiert: Es gilt x v y genau dann, wenn x u y = x gilt. Diese partielle Ordnung wird auch die auf einem Verband induzierte partielle Ordnung genannt. Ähnlich lässt sich aus einer partiellen Ordnung ein Verband konstruieren. Dabei ergeben sich die beiden Verbandsoperationen aus dem Infimum und dem Supremum der beiden Operatoren. 2.3.2 Transferfunktionen Die einzelnen Programmblöcke manipulieren – ihrer Semantik entsprechend – die durch den Verband modellierten Eigenschaften. Diese Veränderung der Eigenschaften werden durch eine Transferfunktion angegeben. Diese Transferfunktion bildet Elemente des Verbandes wieder auf Elemente des Verbands ab. Ist (V, u, t) ein Verband und B ein Programmblock, dann ist die Transferfunktion für den Programmblock B definiert durch fB : V → V . Damit die Datenflussanalyse durchgeführt werden kann, muss die Transferfunktion f monoton sein. D.h. für alle x, y ∈ V mit x v y gilt: f (x) v f (y). Ist diese Voraussetzung nicht erfüllt, konvergieren die Datenflussgleichungen i.A. nicht und die Datenflussanalyse terminiert nicht. Die Forderung der Monotonie drückt in etwa aus, dass im Laufe der Analyse nur weitere Informationen gewonnen werden, aber niemals Informationen verloren gehen dürfen. Ist zusätzlich neben der Monotonie der Transferfunktion die Höhe des Verbands endlich, so ist die Terminierung der Datenflussanalyse garantiert. Die Höhe eines Verbands V ist ein maximales n, so dass gilt: ⊥ = x1 @ x2 @ · · · @ xn = > mit xi ∈ V . 2.3. DATENFLUSSANALYSE 2.3.3 21 Iterative Vorwärtsanalyse Die iterative Vorwärtsanalyse ist eine Möglichkeit, eine Datenflussanalyse durchzuführen. Dabei werden jedem Programmblock B zwei Eigenschaften zugeordnet: Die hereinfließenden Eigenschaften IN (B) und die herausfließenden Eigenschaften OU T (B). Um die eingehenden Eigenschaften für einen Programmblock zu ermitteln, werden alle herausgehenden Eigenschaften der Vorgänger mit der Verbandsverknüpfung kombiniert, die die Zusammenführung der Flussinformationen modelliert. Welche der beiden Operationen den Zusammenfluss modelliert, hängt von dem konkreten Verband ab. Hier wird davon ausgegangen, dass die meet-Operation den Zusammenfluss modelliert: IN (B) = u OU T (C) C∈V org(B) Diese neu berechnete Eigenschaft wird mit der Transferfunktion fB : V → V entsprechend der Semantik des Blocks B verändert und liefert so die herausfließende Eigenschaft des Grundblocks: OU T (B) = fB (IN (B)) Nach Festlegung einer geeigneten Startbelegung wird diese Berechnung für jeden Programmblock durchgeführt und solange iteriert, bis sich die Eigenschaften nicht mehr ändern. Man spricht dann davon, dass die Datenflussgleichungen einen Fixpunkt erreicht haben. 2.3.4 Modellierung von komplexen Verbänden Je nach Art der Datenflussanalyse wird ein mehr oder weniger komplexer Verband benötigt. Es gibt nun mehrere Techniken, komplexe Verbände durch Kombination einfacher Verbände zu konstruieren. Mit Hilfe des folgenden Satzes lassen sich z.B. die lokalen Variablen und der Operandenkeller gesondert als Verbände modellieren, um dann zu einem Schachteltypverband kombiniert zu werden. Satz 2.1 (Kreuzprodukt von zwei Verbänden). Gegeben seien die Verbände (V1 , u0 , t0 ) und (V2 , u00 , t00 ). Dann ist (V, u, t) mit • V := (V1 × V2 ) = {(v, w)|v ∈ V1 , w ∈ V2 }, • x u y = (x1 , x2 ) u (y1 , y2 ) := ((x1 u0 y1 ), (x2 u00 y2 )) und • xty = (x1 , x2 )t(y1 , y2 ) := ((x1 t0 y1 ), (x2 t00 y2 )) mit x, y ∈ V , x1 , y1 ∈ V1 und x2 , y2 ∈ V2 ebenfalls ein Verband. Dieser Satz lässt sich verallgemeinern, so dass auch mehr als zwei Verbände kombiniert werden können. Die Äquivalenz von partiellen Ordnungen und Verbänden ermöglicht es auch, einen Verband über eine partielle Ordnung zu definieren. Dies bietet sich 22 KAPITEL 2. GRUNDLAGEN beispielsweise bei der Vererbungshierarchie zwischen Java-Klassen an, da es sich bei der Klassenhierarchie um eine partielle Ordnung handelt. V1 V2 ... Vn V1 V2 ... Vn Abbildung 2.4: Vereinigung paarweiser disjunkter Verbände zu einem neuen Verband Durch Vereinigung mehrerer paarweise disjunkter Verbände lässt sich ein neuer Verbanden erzeugen, wenn ein neues Top- und Bottom-Element eingefügt wird. Bild 2.4 zeigt ein Schema für dieses Vorgehen. Die Elemente der Verbände werden vereinigt und die Operationen meet und join werden dem Bild entsprechend definiert. 2.3.5 Zusammenfassung Mit einem geeigneten Verband und den Transferfunktionen lässt sich nun die Datenflussanalyse durchführen, um die Typen für jede Programmstelle zu rekonstruieren. Diese Informationen dienen als Grundlage für den Sicherheitsbeweis im Sinne des Proof-Carrying Code. Die Transferfunktionen lassen sich in einem funktionalen Kalkül modellieren. Dies hätte jedoch den Nachteil, dass die Modellierung sehr unübersichtlich und weit entfernt von einer Implementierung wäre. Daher benutzt E. Rose einen operationalen Ansatz: Die Natural Semantics. 2.4 Natural Semantics Um mit Beweisen arbeiten zu können, ist ein Formalismus notwendig, auf dem diese Beweise aufbauen. Daher sind bei der Anwendung von Proof-Carrying Code die Sicherheitsrichtlinien geeignet zu formalisieren, damit überhaupt Beweise formuliert bzw. erzeugt werden können, die die Einhaltung dieser Sicherheitsrichtlinien beweisen. E. Rose benutzt zur Beschreibung der Sicherheitsrichtlinien, die in diesem Fall der Semantik der JVM entsprechen, den Formalismus der Natural Semantics [Ka87]. 2.4. NATURAL SEMANTICS 2.4.1 23 Inferenzregeln und Axiome Die Natural Semantics ist eine operationale Methode zur Beschreibung von Semantiken. Grundelemente der semantischen Beschreibung sind Inferenzregeln und Axiome. Im Allgemeinen hat eine Regel folgende Form: J1 ∧ J2 ∧ ... ∧ Jk J0 C Der obere Teil dieser Regel besteht aus einer Menge von Formeln und wird Prämisse genannt. Wenn alle Formeln der Prämisse wahr sind, so gilt die Formel J0 im unteren Teil. J0 wird auch Konklusion genannt. Es gibt zwei Arten von Formeln: Folgerungen und Bedingungen. Die Konklusion – also der untere Teil der Regel – ist immer eine Folgerung. C ist immer eine Bedingung, die die Anwendbarkeit der Regel einschränken soll. Sie wird rechts neben die Regel geschrieben. Fehlt die Bedingung, dann gibt es keine Einschränkung in der Anwendbarkeit der Regel. Besitzt eine Regel keine Folgerungen in der Prämisse, so nennt man die Regel ein Axiom. Man beachte, dass ein Axiom dennoch durch Bedingungen eingeschränkt werden kann. 2.4.2 Folgerungen Eine Folgerung hat die Form a ` c. Links vom Drehkreuz steht der Bedingungsteil a und rechts davon der Schluss c. Der Schluss ist ein Prädikat, das in mehreren Formen auftreten kann. Diese Formen werden dadurch unterschieden, dass unterschiedliche Symbole innerhalb des Prädikats benutzt werden. Das erste Argument vor diesem Symbol ist das Subjekt dieser Folgerung. Wenn man beispielsweise in der statischen Semantik ausdrücken möchte, dass ein Ausdruck E einen bestimmten Typ hat, dann würde die Folgerung folgende Form haben: p ` E : τ . Man sagt dann, dass in der Umgebung p der Ausdruck E den Typ τ besitzt. Der Doppelpunkt symbolisiert hier, dass es sich um eine statische Semantik handelt. Vor dem Doppelpunkt steht als Subjekt der Regel der Ausdruck E. 2.4.3 semantische Definitionen Mit mehreren Axiomen und Inferenzregeln lassen sich nun semantische Definitionen aufstellen. Das Prinzip ist es, aus diesen Axiomen mit Hilfe der Regeln neue Informationen zu gewinnen. Ein solches Regelsystem ist vergleichbar mit einer Grammatik. Eine Grammatik lässt sich auf zwei Arten benutzen: Einmal als Konstruktionsanleitung, um Worte der Grammatik zu erzeugen, und zum anderen als 24 KAPITEL 2. GRUNDLAGEN Erkennungssystem, mit dem geprüft werden kann, ob bestimmte Worte der Grammatik angehören. Diese beiden Betrachtungsweisen einer Grammatik gibt es auch bei der Natural Semantics. 2.4.4 Die verschiedenen Formen von Folgerungen Mit der Natural Semantics lassen sich nicht nur statische Semantiken beschreiben, sondern auch dynamische Semantiken und Übersetzungen von einer Sprache in eine andere. Die Folgerung s1 ` E ⇒ s2 bedeutet z.B., dass in Zustand s1 die Auswertung des Ausdrucks E zu dem neuen Zustand s2 führt. Der Zustand s1 enthält dabei alle Variablen, die in dem Ausdruck vorkommen. Dies ist ein sehr einfaches Beispiel. In der Praxis hängt das Aussehen der Folgerungen stark von den Eigenschaften der betrachteten Sprache ab. Übersetzungen werden in der Form p ` E1 → E2 ausgedrückt. Dabei enthält p Annahmen über die Bezeichner. E1 ist ein Ausdruck aus der Quellsprache und E2 der entsprechende Ausdruck der Zielsprache. E. Rose benutzt die zuletztgenannte Form, um die Übersetzung von JavaProgrammen nach Java-Bytecode zu modellieren. Die Typsicherheit, die an dieser Stelle wesentlich wichtiger ist, spezifiziert sie durch eine statische Semantik. 2.4.5 Beispiel Die dynamische Semantik der bedingten Anweisung if-then-else könnte folgendermaßen modelliert werden: p ` Cond, S ⇒ true, S 0 ∧ p ` B1 , S 0 ⇒ S 00 p ` if (Cond) B1 else B2 , S ⇒ S 00 p ` Cond, S ⇒ false, S 0 ∧ p ` B2 , S 0 ⇒ S 00 p ` if (Cond) B1 else B2 , S ⇒ S 00 Es gibt hier zwei Regeln, die sich nur in der Prämisse unterscheiden. Die Konklusion besagt: In der Umgebung p geht der Zustand S durch die Ausführung von if (Cond) B1 else B2 in den Zustand S 00 über. Damit die Konklusion gültig ist, müssen die Prämissen ebenfalls gültig sein. Es stellt sich hier auch die Frage, wie der Zustand S 00 ermittelt wird. Die erste Regel enthält in der Prämisse die Aussage, dass die Auswertung der Bedingung Cond wahr ergibt und der Zustand von S nach S 0 wechselt. Weiterhin ergibt die Ausführung des Blocks B1 einen Zustandsübergang von S 0 nach S 00 . Falls die Aussagen der Prämisse gelten, ist dieser Zustand S 00 auch der Zustand, der durch die Ausführung des if-Konstrukts erreicht wird. Analog verhält sich die Prämisse der zweiten Regel. Hier wird jedoch davon ausgegangen, dass die Bedingung Cond nicht wahr ist und somit der Block B2 ausgeführt wird. S 00 ist wieder der Zustand, der nach Ausführung des Blocks 2.4. NATURAL SEMANTICS 25 B2 gilt und somit auch nach Ausführung des if-Konstrukts. In einer vollständigen semantischen Beschreibung müssten noch Regeln für die Ausführung von Programmblöcken und für die Auswertung von Ausdrücken existieren, die wieder Zustandsübergänge modellieren. Am Ende dieser Regelkette müssten schließlich Axiome stehen, um die Gültigkeit der Regel zu beweisen. Auch die Zustände selbst müssten genauer definiert werden, um z.B. Variablenbindungen zu modellieren. 26 KAPITEL 2. GRUNDLAGEN Kapitel 3 Konzeption In diesem Kapitel soll der Ablauf der leichtgewichtigen Bytecode-Verifikation (Lightweight Bytecode Verification) beschrieben werden [RR98] [Ro98]. Weiterhin sollen Erweiterungsmöglichkeiten diskutiert werden. 3.1 Überblick über die leichtgewichtige Verifikation Produzent Phase 1 Java Programm Java− Übersetzer Zertifizierer zertifizierter Bytecode Konsument Abbruch Nein Prüfung korrekt? Phase 2 JVM− Bytecode Zertifikat Bytecode−Überprüfung Typmodell der JVM Ja Ausführung auf JVM Abbildung 3.1: Ablauf der Lightweight Bytecode Verification Die leichtgewichtige Verifikation von Java-Bytecode basiert auf der in Kapitel 2.1 beschriebenen Technik des Proof-Carrying Codes. Bild 3.1 zeigt den Ablauf der beiden Phasen. In der ersten Phase wird nach der Übersetzung des 27 28 KAPITEL 3. KONZEPTION Bytecode ein Zertifikat erstellt. Dieses Zertifikat beinhaltet Informationen, aus denen sich leicht für jede Stelle im Bytecode sämtliche Typinformationen des Kellers und der lokalen Variablen rekonstruieren lassen. Die zweite Phase prüft vor der Ausführung, ob die aus dem Zertifikat rekonstruierten Typinformationen zu dem Bytecode passen. Für jede dieser beiden Phasen werde ich ein Programm implementieren. Als Eingabe erhält das erste Programm eine Java-Klassendatei, die von einem JavaÜbersetzer erzeugt wurde. Für jede Methode in dieser Klassendatei erzeugt das Programm ein Zertifikat. Diese Zertifikate bilden dann zusammen mit der Klassendatei die Eingabe für das zweite Programm. In der zweiten Phase kann mit Hilfe der Zertifikate jede Methode der Klassendatei effizient auf Typsicherheit geprüft werden. Es folgt nun eine genauere Beschreibung dieser beiden Phasen. 3.2 Die Zertifizierungsphase Java−Klassendatei Verband Datenflussanalyse Schachteltypen Zertifikaterstellung (Komprimierung) Datenstruktur für Schachteltypen mit Abbildung gemäß Bytecode− Semantik auf Typebene für jede Programmstelle/Grundblock in jeder Methode Welche Informationen können nicht bei linearem Durchlauf durch den Bytecode ermittelt werden? Zertifikat Abbildung 3.2: grober Ablauf der Zertifizierung Bild 3.2 illustiert die erste Phase der leichtgewichtigen Verifikation, in der für jede Methode ein Zertifikat erstellt wird, aus dem sich für jede Programmstelle die Typinformationen bestimmen lassen. Diese Typinformationen enthalten Informationen über alle Speicherbereiche der JVM, in denen dieselbe Speicherzelle zu verschiedenen Zeitpunkten der Programmausführung unterschiedliche Typen enthalten kann. Felder von Klassen werden daher nicht erfasst, da diese zu jedem Zeitpunkt denselben Typ enthalten. Übrig bleiben somit die lokalen Variablen und der Operandenkeller. Alle Typen dieser beiden Bereiche werden 3.2. DIE ZERTIFIZIERUNGSPHASE 29 zusammenfassend Schachteltyp genannt. Die Schachteltypen jeder Programmstelle einer Methode können durch eine Datenflussanalyse ermittelt werden (Kapitel 2.3). Anschließend werden die für die zweite Phase wichtigen Informationen aus diesen Schachteltypen extrahiert und bilden das zu dem Bytecode gehörende Zertifikat. 3.2.1 Die Datenflussanalyse Eine zentrale Rolle bei der Datenflussanalyse spielen der Verband und die Transferfunktionen. Der Verband modelliert die Schachteltypen und deren Beziehungen untereinander, d.h. wie z.B. Schachteltypen kombiniert werden müssen, wenn eine Instruktion mehr als einen Vorgänger besitzt. Die Transferfunktionen bilden die Semantik der Instruktionen auf die Schachteltypen ab. Damit wird modelliert, welche Typen die einzelnen Intstruktionen auf dem Operandenkeller und in den lokalen Variablen erwarten und wie dieser Schachteltyp von der Instruktion verändert wird. Zum Beispiel erwartet die Instruktion iadd auf dem Operandenkeller zwei int-Typen, nimmt diese von Keller und legt schließlich die Summe dieser beiden Operanden wieder auf den Keller. Jede Methode der Klasse wird unabhängig von den anderen Methoden bearbeitet. Ruft eine Methode eine zweite Methode auf, so wird angenommen, dass die zweite Methode korrekt ist. Es wird lediglich geprüft, ob die auf dem Keller abgelegten Parameter zu den Typen der Methodensignatur passen. Die Datenflussanalyse wird also für jede Methode der Java-Klasse einzeln durchgeführt. Damit erhält auch jede Methode ihr eigenes Zertifikat. Initialisierung der Schachteltypen Vor der Ausführung einer Methode erhalten die lokalen Variablen die der Methode übergebenen Parameter. Dies bedeutet für die Datenflussanalyse, dass der Schachteltyp der ersten Instruktion die Typen der Methodenparameter enthalten muss. Wenn die Methode also zwei int-Parameter besitzt, müssen die ersten beiden lokalen Variablen des Schachteltyps der ersten Instruktion ebenfalls int-Typen enthalten. Dies gilt jedoch nur für statische Methoden. Bei nicht statischen Methoden beinhaltet die erste lokale Variable den Typ der zugehörigen Klasse – dies entspricht der this-Referenz. Die Typen der Methodenparameter folgen dann ab der zweiten lokalen Variable. Die übrigen lokalen Variablen dieser Methode werden mit dem ⊥-Element des Verbands initialisiert. Dies signalisiert, dass diese lokalen Variablen nicht lesend benutzt werden dürfen, da ihr Inhalt unbekannt ist. Der Operandenkeller ist an dieser Stelle leer. Abbildung 3.3 zeigt ein Beispiel für die Initialisierung des Schachteltyps der ersten Instruktion. Die übrigen Schachteltypen, die nicht zur ersten Instruktion der Methode gehören, werden mit >-Elementen initialisiert. Dies zeigt an, dass noch keine 30 KAPITEL 3. KONZEPTION class A { static void m1 (int a, int b) { ... } void m2 (int a, String b) { ... } }; Schachteltyp lokale Variablen Initialisierung des Schachteltyps der ersten Instruktion Keller Methode m1 int int leer Methode m2 A int String leer Abbildung 3.3: Beispiel für die Initialisierung des Schachteltyps der ersten Instruktion anhand der Methodensignatur Informationen über die Typen verfügbar sind. Der zugrundeliegende Verband Der Verband für die Schachteltypen wird sukzessive aus einfacheren Verbänden kombiniert (siehe Kapitel 2.3.4). Grundlage bildet hier ein Verband, der Grundtypen und Klassen enthält. Die Abhängigkeiten zwischen den einzelnen Elementen werden im wesentlichen durch die Klasenhierarchie bestimmt. Aus diesem Grundkomponentenverband wird zum einen ein Verband für die lokalen Variablen bestehend aus einer konstanten Anzahl von Elementen erzeugt und zum anderen ein Verband für den Keller, dessen Elementanzahl variabel ist. Diese beiden Verbände werden schließlich zu dem endgültigen Schachteltypverband kombiniert. Die einzelnen Elemente des Schachteltypverbands werden in der Implementierung nicht komplett im Speicher erzeugt. Stattdessen werden die Elemente und ihre Abhängigkeiten bei Bedarf aus den Grundelementen berechnet. Die Implementierung wird also nur die folgende Konstruktionsvorschrift enthalten, die definiert, wie der Schachteltypverband aus dem Grundkomponentenverband zusammengesetzt wird. Abbildung 3.4 zeigt einen einfachen Grundkomponentenverband, der vier Klassen enthält. Die Klassen C und D sind von der Klasse A abgeleitet. Jede Klasse ist direkt oder indirekt von Object abgeleitet. Der Grunddatentyp int besitzt keine Relation zu den Klassen. Die Elemente des Verbands enthalten von oben nach unten immer mehr Informationen. Das >-Element symbolisiert, dass noch keine Informationen verfügbar sind und das ⊥-Element gibt an, dass ein Typ nicht eindeutig bestimmbar ist. Zwischen diesen beiden Extremen liegen die Typen, die in der JVM auftreten können. Während der Datenflussanalyse ist es natürlich sinnvoll, in dem Verband nur die Elemente zu haben, die auch tatsächlich in der untersuchten Methode benutzt werden. Dazu wird der Verband während er Datenflussanalyse erweitert, sobald ein noch nicht in dem Verband vorhandener Typ z.B. aus dem Konstantenbereich auf dem Operandenkeller abgelegt wird. Während der Datenflussanalyse werden neue Typinformationen dadurch gewonnen, dass sich ein Typ bezogen auf den Verband dahingehend verändert, 3.2. DIE ZERTIFIZIERUNGSPHASE 31 D int C A B java.lang.Object Abbildung 3.4: Beispiel für einen Grundkomponentenverband, aus dem der Schachteltypverband konstruiert wird dass er sich dem ⊥-Symbol annähert. Wenn die u-Operation (meet) auf zwei Elemente des Verbands ausgeführt wird, so befindet sich das Ergebnis entweder unterhalb der beiden Elemente oder es ist gleich einem der beiden Elemente. Zum Beispiel ergibt D u B = java.lang.Object oder int u top = int. Spätere Erweiterungen werden neben den Transferfunktionen diesen Grundkomponentenverband erweitern. Die Art und Weise, wie der Schachteltypverband aus diesem Verband konstruiert wird, ist von der Erweiterung des Grundkomponentenverbands unabhängig. In Abbildung 3.5 ist ein Verband zu sehen, der nach Satz 2.1 aus einen dreielementigen Grundkomponentenverband konstruiert wurde (mit den Elementen ⊥, > und int). Dieser konstruierte Verband modelliert den Teil des Schachteltyps, der den Typen der lokalen Variablen entspricht. Die Anzahl der enthaltenen Elemente ist davon abhängig, wie viele lokale Variablen die zu untersuchende Methode benötigt. In dem Beispiel sind dies drei lokale Variablen. Ein ähnlicher Verband wird für die Kellertypen angeben. Der Hauptunterschied zu dem Verband der lokalen Variablen ist der, dass die Anzahl der enthaltenen Grundkomponenten variabel ist, da der Keller ja an verschiedenen Programmstellen unterschiedliche viele Elemente enthalten kann. Dieser Verband wird daher durch Vereinigung mehrerer Verbände konstruiert (vgl. Abbildung 2.4 in Kapitel 2.3.4). Wenn der Operandenkeller maximal zwei Elemente enthalten kann, werden drei Verbände vereinigt: Der Verband bestehend aus dem leeren Keller, ein Verband mit einem einelementigen Keller und ein Verband mit einem zweielementigen Keller. Abbildung 3.6 zeigt das Ergebnis dieser Vereinigung. Die Beispiele aus Abbildung 3.5 und 3.6 zeigen, dass der Schachteltypver- 32 KAPITEL 3. KONZEPTION (int, ( ( , , , ( , , ) , int) ( , int, ) ) ( , int, int) ( , int, (int, ) ) , int) (int, , ( , (int, int, ) , ) (int, int, int) , ) (int, int, ( , , int) ( , , ( , , ) ( , , int) ( , , ) (int, , ) ) ( ( , , int) ) , int, int) ( (int, (int, , int, , int, ( , ) , ( ( , , ) ) ) Abbildung 3.5: Ein Verband für die Typen der lokalen Variablen unknown stack ] [ , [int] [ , int] [ [ [ ] [] , ] ] [int, int] [int, ] [ ] , [int, [ ] [ , ] , int] invalid stack Abbildung 3.6: Ein Verband für die Typen des Operandenkeller ) , , int) 3.2. DIE ZERTIFIZIERUNGSPHASE 33 band, der durch eine Kreuzproduktbildung aus dem Verband der lokalen Variablen und dem Verband des Operandenkellers zusammengesetzt wird, sehr komplex sein kann – insbesondere, wenn der Grundkomponentenverband aus mehr als drei Elementen besteht. Für die Beispiele ergäbe sich ein Schachteltypverband mit 405 Elementen. An diesem Beispiel lässt sich auch erkennen, dass es keinen Sinn macht, in der Implementierung die Elemente des Schachteltypverbandes und deren Beziehungen untereinander vollständig aufzuzählen und im Speicher zu halten. In dem Ansatz von E. Rose wird nur der Grunddatentyp int berücksichtigt. Es fehlen also die Grunddatentypen float, double und long. Die Typen boolean, char, byte und short brauchen nicht in dem Verband modelliert zu werden, da diese von der JVM auf int-Typen abgebildet werden. Dies gilt jedoch nur solange, wie keine Reihungen (arrays) unterstützt werden. Denn eine Referenz auf eine boolean-Reihung ist nicht kompatibel zu einer Referenz auf eine byte-Reihung. Wie sich Reihungen in den Grundkomponentenverband einfügen, werde ich neben anderen Erweiterungen in Abschnitt 3.4 diskutieren. Die Transferfunktionen Fast jede Bytecode-Instruktion verändert den Keller oder die lokalen Variablen. Für die Schachteltypen bedeutet dies, dass sich die enthaltenen Typen verändern. Diese Typsemantik der Instruktionen wird durch die Transferfunktionen modelliert. Um jedes Java-Bytecode-Programm analysieren zu können, muss für jede Bytecode-Instruktion eine Transferfunktion definiert sein. E. Rose beschränkt sich in ihrem Ansatz auf eine Untermenge der Instruktionen der Java Virtual Machine. Der Hauptgrund für diese Beschränkung ist die Komplexität der Formalisierung. Viele Instruktionen wurden weggelassen, weil sie zu den unterstützten Instruktionen konzeptuell sehr ähnlich sind und den Korrektheitsbeweis entsprechend unübersichtlich hätten werden lassen. In dieser Arbeit muss darauf keine Rücksicht genommen werden, da der Schwerpunkt in der Implementierung und Evaluierung liegt. Die Implementierung dieser Instruktionen ist daher trivial. Andere Instruktionen hingegen sind sehr schwierig zu modellieren wie z.B. Unterprogrammaufrufe durch die jsr-Instruktion. Der Grund dafür ist zum einen, dass nach Abarbeitung des Unterprogramms nicht unbedingt wieder zurück zur Aufrufstelle gesprungen wird. Desweiteren können die von dem Unterprogramm nicht benutzten lokalen Variablen unterschiedliche Typen enthalten abhängig davon, von welcher Programmstelle aus das Unterprogramm aufgerufen wurde. Diese verschiedenen Typen müssen innerhalb des Unterprogramms erhalten bleiben und dürfen keinesfalls mit der u-Operation des Verbands zusammengefasst werden. Es gibt also je nach Aufrufstelle innerhalb des Unterprogramms pro Instruktion mehrere verschiedene Schachteltypen. Dies macht die Datenflussanalyse wesentlich schwieriger [Le01]. Eine sehr einfache Lösung für das Unterprogrammproblem wäre das Ersetzen der Unter- 34 KAPITEL 3. KONZEPTION programmaufrufe durch das Unterprogramm selbst. Dies würde natürlich gerade bei großen Unterprogrammen den Bytecode erheblich vergrößern. Da Unterprogramme von dem Sun-Java-Übersetzer jedoch nur durch finally-Konstrukte erzeugt werden, sind sie relativ selten. Daher werde ich mich in meiner Implementierung auf andere Erweiterungen konzentrieren. Neben der Modifikation des Schachteltyps prüfen die Transferfunktionen, ob die Ausführung entsprechend der JVM-Spezifikation mit dem vorliegenden Schachteltyp überhaupt zulässig ist. So wird ein Kellerüberlauf geprüft und ob passende Typen auf dem Keller und in den lokalen Variablen für die jeweilige Instruktion vorliegen. class A { int mein_feld; }; Konstantenbereich[1] lokale Variablen Vor der Ausführung (...) Nach der Ausführung ... A int ... A putfield[1] ... (...) Feldreferenz Klassenname Feldname und Typ Keller pop pop A int mein_feld int Prüfung der Typkompatiblität A Prüfung der Typkompatiblität ... Abbildung 3.7: Die Transferfunktion für die Instruktion putfield Beispielsweise benötigt die Instruktion putfield als Operanden eine Referenz und einen Wert auf dem Keller. Das Feld, das diesen Wert erhalten soll, wird über den Konstantenbereich ermittelt. Ein entsprechender Index in den Konstantenbereich ist Teil der Instruktion. Die Feldreferenz aus dem Konstantenbereich enthält einen Klassennamen, einen Feldnamen und den Typ des Feldes. Der Wert auf dem Keller muss einen zum Feld kompatiblen Typ besitzen und die Referenz auf dem Keller muss von gleichen Typ oder eine Unterklasse des Klassennamens sein. Bild 3.7 zeigt die Arbeitsweise der Transferfunktion für die Instruktion putfield, bei der nur der Operandenkeller verändert wird. Die lokalen Variablen bleiben unverändert. Die Typkompatiblität hängt mit der Klassenhierarchie zusammen. Da diese auch in dem Verband enthalten ist, wird die Typkompatiblität auch durch den Verband modelliert. Dies hat den Vorteil, dass Grundtypen und Klassen nicht gesondert behandelt werden müssen. Angenommen der Typ auf dem Keller ist t1 . Dann kann ein Wert dieses Typs nur einem Feld vom Typ t2 zugewiesen werden, wenn t1 u t2 = t2 ist. Bild 3.8 zeigt, wie man dies auch grafisch anhand des Hasse-Diagramms ermittelt. Um zu prüfen, ob A kompatibel zu Object ist, wird ein Weg von A nach unten Richtung ⊥-Symbol zu Object gesucht. Existiert dieser Weg, so ist A kompatibel zu Object. Genau dies drückt die Bedingung A u Object = 3.2. DIE ZERTIFIZIERUNGSPHASE 35 A int Object Abbildung 3.8: Ein einfacher Verband mit zwei Klassen Object aus. Es sollte auch klar sein, dass int kompatibel zu sich selbst ist. Wenn die Transferfunktionen und der Verband implementiert sind, lässt sich die Datenflussanalyse wie in Kapitel 2.3.3 beschrieben durchführen. Man erhält – wie in Abbildung 3.2 gezeigt – einen Schachteltyp für jede Instruktion jeder Methode. Aus diesen Typinformationen wird nun im zweiten Teil der Zertifizierungsphase ein Zertifikat erzeugt, dass zwei wesentliche Eigenschaften besitzen muss: Zum einen muss es möglichst klein sein und zum anderen muss die Fälschungssicherheit gegeben sein. 3.2.2 Die Kompression des Zertifikats Die geringe Größe des Zertifikats wird dadurch erreicht, dass nur die Informationen gespeichert werden, die bei einem sequentiellen Durchlaufen des Bytecodes nicht ermittelt werden können. Dies bedeutet für die zweite Phase, dass durch das Zertifikat die Datenflussanalyse – und insbesondere die Fixpunktsuche – nicht durchgeführt werden muss. Durch das Fehlen dieser Fixpunktsuche entfällt der ansonsten hohe Speicherplatzbedarf der Datenflussanalyse. Dies ist gerade deshalb wichtig, weil die zweite Phase der leichtgewichtigen Verifikation auf der Java Card durchgeführt werden soll und dort der Speicher sehr begrenzt ist. Beim sequentiellen Durchlaufen durch den Bytecode kann der Schachteltyp nicht immer aus den bereits bekannten Informationen bestimmt werden. Gerade an Programmstellen, für deren Schachteltypberechnung die Datenflussanalyse mehrere Iterationen benötigt, unterscheidet sich der sequentiell bestimmte Schachteltyp von dem aus der Datenflussanalyse. Diese Schachteltypen werden in der Datenflussanalyse durch eine Verknüpfung mit der u-Operation des Verbands berechnet. Beim sequentiellen Durchlauf sind in der Regel für diese Verknüpfung nicht alle Operanden verfügbar. Statt die übrigen Operanden in dem Zertifikat zu speichern, wird sinnvollerweise nur das Endergebnis dieser Operation angegeben. Dies hat den Vorteil, dass die aufwändige u-Operation beim sequentiellen Durchlauf überhaupt nicht mehr durchgeführt werden muss. In den Zertifikaten werden außerdem keine vollständigen Schachteltypen gespeichert, sondern nur die Differenz des durch den sequentiellen Durchlauf bestimmten Schachteltyps und dem aus der Datenflussanalyse ermittelten Schach- 36 KAPITEL 3. KONZEPTION teltyp. Dies bietet sich an, weil sich die meisten Schachteltypen im Laufe der Datenflussanalyse nicht oder nur wenig ändern und damit die Zertifikate entsprechend wenig Einträge besitzen. Die Berechnung der Differenzen ist sehr einfach und soll durch folgendes Beispiel erläutert werden. Angenommen die beiden Schachteltypen haben folgenden Inhalt: Schachteltyp bei sequentiellem Durchlauf aus Datenflussanalyse lokale Variablen (int, int) (int, ⊥) Operandenkeller () () Der einzige Unterschied in diesen beiden Schachteltypen ist die zweite lokale Variable. Daher braucht in dem Zertifikat auch nur gespeichert zu werden, dass die zweite lokale Variable das ⊥-Symbol enthält. Es wird also ein Index und ein Typ in dem Zertifikat gespeichert. Die Typen des Kellers lassen sich ebenfalls über Indizes ansprechen. Um in dem Zertifikat keine Unterscheidung zwischen lokalen Variablen und Keller machen zu müssen, könnte der Kellerindex bei der Anzahl der lokalen Variablen beginnen. Ist die Anzahl der lokalen Variablen wie in dem obigen Beispiel 2, dann werden die lokalen Variablen mit 0 und 1 indiziert und das unterste Kellerelement entspricht dem Index 2. Damit das Zertifikat nur noch aus Zahlen besteht, könnten die Typen selbst auch indiziert gespeichert werden. Dazu muss in dem Zertifikat eine Liste der verwendeten Typen angegeben werden. Diese weitere Indizierung lohnt sich spätestens dann, wenn zwei gleiche Typen in dem Zertifikat auftreten. In den einzelnen Einträgen des Zertifikats werden dann nur noch aus Zahlen bestehende Tupel gespeichert. Ein Tupel besteht somit aus einem Index innerhalb des Schachteltyps und aus einem Index auf die Typenliste. Ein weiterer Vorteil dieser Indizierung liegt darin, dass häufig in den Zertifikaten auftretene Typen einen festen Index bekommen könnten, so dass diese Typen nicht in der Liste gespeichert werden müssen. Beispielsweise könnte das ⊥-Element, das oft in den Zertifikaten vorkommt, grundsätzlich den Index 0 erhalten. Diese weitere Indizierung bringt nur einen Speicherplatzvorteil um einen konstanten Faktor. Aus diesem Grund wird diese Indizierung in der folgenden formalen Beschreibung nicht berücksichtigt. f in e ∈ Eintraege = Programmstellen −→ Schachteldelta pp ∈ Programmstellen = N0 δ ∈ Schachteldelta = (Index × Typ)∗ i ∈ Index = N0 mt ∈ T yp = KlassenID | Grunddatentyp | ⊥ Die Schachteltypeinträge werden hier als endliche Abbildung modelliert. Dadurch werden alle Programmstellen der Methode auf Einträge abgebildet, die entweder leer sind oder aus einem oder mehreren Tupeln bestehen. Diese Tupel wiederum bestehen aus einem bei Null beginnenden Index und aus einem 3.2. DIE ZERTIFIZIERUNGSPHASE 37 Element aus dem Grundkomponentenverband. Die Indizes bezeichnen wie oben beschrieben entweder lokale Variablen oder Kellerelemente des Schachteltyps. E. Rose schlägt vor, statt des Typindex eine Typdifferenz zu speichern. Diese Differenz bezeichnet, um wieviele Stufen ein Typ Richtung ⊥-Symbol durch die Anwendung des Zertifikats verändert wird. Für Referenzen bedeutet dies, dass bei einer Differenz von x ein Typ zu seinem x-ten Obertyp verändert wird. Der Typ Object und alle Grunddatentypen wie int, float usw. werden bei dieser Typveränderung zum ⊥-Symbol. Allgemein ist diese Differenz der Abstand der beiden Typen in dem Verband. Diese Speicherung der Abstände funktioniert jedoch nur, wenn jeder Typ in dem Verband einen eindeutigen Typ Richtung ⊥-Symbol besitzt. Das >-Symbol spielt hier keine Rolle, da dies in der zweiten Phase nicht auftritt. Probleme gibt es jedoch, wenn in dem Verband der Typ null auftritt, der für eine nicht initialisierte Referenz steht, die beispielsweise durch die Instruktion aconst null erzeugt wurde. Von der null-Referenz kann es dann in dem Verband mehrere Wege zu dem dem ⊥-Symbol geben. Die Angabe einer Differenz ist somit nicht mehr eindeutig. Abbildung 3.9 zeigt einen solchen Verband, in dem die Null-Referenz keinen eindeutigen Weg Richtung ⊥-Symbol besitzt. null−Referenz int[] int java.lang.Object[] A java.lang.Object Abbildung 3.9: Ein Verband mit der null-Referenz Diese Differenzen sind auch problematisch, wenn die leichtgewichtige Verifikation um Schnittstellen erweitert werden soll. Bei Schnittstellen gibt es Mehrfachvererbung und somit kann ein Typ auch mehrere Obertypen besitzen. Neben den Schachteltypeinträgen benötigt das Zertifikat noch eine Menge von Programmstellen, für die während der Überprüfungsphase Schachteltypen zwischengespeichert werden müssen. Diese Schachteltypen werden im Laufe des Überprüfungsalgorithmus mehrfach für Vergleiche benötigt. Dieser Algorithmus wird in Abschnitt 3.3.1 beschrieben. Dort werde ich auch näher erläutern, warum in dem Zertifikat neben den Schachteltypeinträgen zusätzlich diese Liste von Programmstellen benötigt wird. Das Zertifikat hat damit folgenden Aufbau: 38 KAPITEL 3. KONZEPTION zert ∈ Zertifikat = Eintraege × Markierungen f in l ∈ Markierungen = Programmstellen −→ (true | false) Die Markierungen bezeichnen die oben erwähnten zu speichernden Schachteltypen. Diese werden ähnlich wie die Einträge über eine endliche Abbildung modelliert. Um die einzelnen Einträge des Zertifikats zu bestimmen, ist es am einfachsten, den Überprüfungsalgorithmus der zweiten Phase direkt nach der Datenflussanalyse einmal durchzuführen. Dabei wird das Zertfikat durch einen Vergleich der aus der Datenflussanalyse ermittelten und der während der Ausführung des Überprüfungsalgorithmus bestimmten Schachteltypen erzeugt. Wenn die beiden Schachteltypen unterschiedlich sind, ist ein Eintrag im Zertifikat notwendig. Ähnlich verhält es sich mit den Markierungen. Die Programmstellen, deren Schachteltyp später noch einmal benötigt werden, werden während der Ausführung des Algorithmus markiert. Am Ende lässt sich aus den Markierungen und den Schachteltypunterschieden das gewünschte Zertifikat erzeugen. 3.3 Die Überprüfungsphase Java−Klassendatei Zertifikat Bytecode−Überprüfung (Algorithmus von E.Rose) Abbruch Nein Verband Datenstruktur für Schachteltypen mit Abbildung gemäß Bytecode− Semantik auf Typebene Zertifikat gültig? Ja Ausführung auf JVM Abbildung 3.10: grober Ablauf der Bytecode-Überprüfung Die zweite Phase benutzt das Zertifikat aus der ersten Phase, um die in der Datenflussanalyse berechneten Typinformationen zu rekonstruieren. Der Bytecode wird einmal sequentiell durchlaufen. An den Stellen, an denen die Datenflussanalyse mehrere Durchläufe gebraucht hat, um die Typinformationen zu berechnen, existieren entsprechende Einträge in dem Zertifikat. Bild 3.10 zeigt, dass auch hier der Verband aus der ersten Phase sehr wichtig für die Überprüfung ist. 3.3. DIE ÜBERPRÜFUNGSPHASE 39 Da die Gültigkeit der aus dem Zertifikat rekonstruierten Typen überprüft wird, sind die Zertifikate fälschungssicher, d.h. durch geschickt geänderte Zertifikate lässt sich ein typunsicheres Programm nicht durch die Überprüfungsphase schleusen. Diese Phase führt im wesentlichen die letzte Iteration der Datenflussanalyse durch, ohne alle Schachteltypen zwischenzuspeichern. Insbesondere wird geprüft, ob alle Instruktionen mit den rekonstruierten Schachteltypen ausführbar sind. Es wird also in dieser Phase eine Beweisüberprüfung im Sinne des Proof-Carrying Code durchgeführt. Ein formaler Beweis der Korrektheit und Fäschungssicherheit der Überprüfung würde die Äquivalenz der zweiten Phase mit der normalen BytecodeVerifikation nachweisen. Dazu ist selbstverständlich eine Formalisierung der normalen Bytecode-Verifikation und der leichtgewichtigen Verifikation notwendig. Das dazu notwendige Vorgehen werde ich in Kapitel 4 skizzieren. 3.3.1 Der Überprüfungsalgorithmus Der folgende Algorithmus ist aus [RR98] entnommen. Die wesentlichen Datenstrukturen neben dem Zertifikat und dem Bytecode sind zwei Mengen, die gemerkte und zurückgestellte Schachteltypen mit ihrer zugehörigen Programmstelle enthalten. Der Algorithmus speichert die rekonstruierten Typen nur in Ausnahmefällen. Das heißt, dass im Optimalfall nur der aktuelle Schachteltyp im Speicher gehalten wird. Jedoch können z.B. bedingte Sprünge dazu führen, dass Schachteltypen später noch einmal für Vergleiche benötigt werden. Diese Schachteltypen werden dazu in der Menge gemerkt abgespeichert. Auf diese Menge greift der Algorithmus zurück, wenn der Bytecode einen Rücksprung in bereits geprüfte Bereiche enthält. Dann muss nämlich geprüft werden, ob der aktuelle Schachteltyp mit dem Schachteltyp des Sprungziels kompatibel ist. Diese Prüfung kann natürlich erst dann durchgeführt werden, wenn beide Schachteltypen bekannt sind. Es muss also grundsätzlich bei Instruktionen, die zwei verschiedene Vorgängerinstruktionen besitzen, ein Schachteltyp zwischengespeichert werden. Der Schachteltyp der zuerst besuchten Vorgängerinstruktion wird mit seiner Programmstelle in der Menge gemerkt gespeichert, der andere Schachteltyp wird später der aktuelle Schachteltyp sein. Abbildung 3.11 zeigt dazu ein Beispiel. Hierbei handelt es sich um ein if-then-else-Konstrukt. Die Programmstelle 11 muss dabei gespeichert werden, weil diese beim Bearbeiten von Block 4 noch einmal benötigt wird. Hier muss geprüft werden, ob der Ergebnisschachteltyp von Programmstelle 10 zu dem vorher berechneten Eingangsschachteltyp von Programmstelle 11 kompatibel ist. Welche Stellen mit ihrem Schachteltyp konkret gespeichert werden müssen, wird in dem Zertifikat angegeben. Der Algorithmus kann nicht vorhersehen, welche Schachteltypen später noch einmal benötigt werden, ohne das ganze Programm betrachtet zu haben. Fehlt dem Algorithmus bei der späteren Prüfung die gemerkte Programmstelle (in diesem Beispiel die Programmstelle 11 mit ihrem Schachteltyp) zum Vergleich der Schachteltypen, so wird die Methode als unsicher zurückgewiesen. An diesem Beispiel ist auch die Prüfreihenfolge zu erkennen. Bei beding- 40 KAPITEL 3. KONZEPTION boolean boolsches_nicht ( boolean x ) { if ( x ) x = false; else x = true; return x; } 1 2 4: 5: 6: 0: 1: iconst_0 istore_0 goto −> 11 3 iload_0 ifeq −> 9 4 Block 4 wird zuletzt geprüft 9: iconst_1 10: istore_0 11: iload_0 12: ireturn Vergleich der Schachteltypen von Anweisung 10 und 11 Abbildung 3.11: Beispiel für eine zu merkende Programmstelle ten Sprüngen setzt der Algorithmus die Überprüfung beim direkten Nachfolger fort, falls dieser noch nicht geprüft wurde. Damit auch das bedingte Sprungziel nicht vergessen wird, wird dieses zusammen mit dem Schachteltyp in der Menge zurueckgestellt gespeichert. Hier wäre das die Programmstelle 9. Wenn es von einer Instruktion keinen direkten Nachfolger gibt, wie z.B. ein return, dann bedient sich der Algorithmus aus der Menge zurueckgestellt, um die als nächstes zu prüfende Programmstelle mit ihrem Schachteltyp zu ermitteln. Die fertig geprüften Programmstellen werden in einer Menge geprueft gesammelt, bis alle Instruktionen der Methode abgearbeitet wurden. Der Algorithmus ist fertig, wenn alle Programmstellen in der Menge geprueft vorhanden sind. Es werden somit folgende Datenstrukturen während der Überprüfungsphase benötigt: gemerkt, zurueckgestellt ∈ Pruefliste = (Programmstellen × Schachteltyp) geprueft ∈ PruefMenge = P(Programmstellen) p ∈ Programmstellen ∪ {⊥} Die im Algorithmus verwendeten Programmstellen werden durch ein weiteres Symbol ⊥ erweitert, um anzuzeigen, dass eine Instruktion kein bedingtes Sprungziel enthält oder wenn eine Instruktion keinen Nachfolger besitzt wie beispielsweise return. Als Eingabe erhält der Algorithmus folgende Informationen: • Die Klassenhierarchie, um Schachteltypen vergleichen zu können • Den Namen der Klasse, zu der die zu prüfende Methode gehört, um den Schachteltyp der ersten Instruktion bestimmen zu können. 3.3. DIE ÜBERPRÜFUNGSPHASE 41 • Den Konstantenbereich • Die Methode selbst, insbesondere die Signatur, den Ergebnistyp, die Anzahl lokaler Variablen, die maximale Größe des Kellers und die Instruktionen der Methode. In der folgenden Beschreibung entspricht C(p) der Instruktion an Programmstelle p. Die meisten dieser Informationen werden für die Entscheidung benötigt, ob eine Instruktion mit dem aktuellen Schachteltyp ausgeführt werden kann. Es folgt die Beschreibung der einzelnen Schritte des Algorithmus, der in Abbildung 3.12 zu sehen ist. die Anweisungen AKZEPTIERT und ABGELEHNT beenden den Algorithmus. 1. Initialisierung Die drei Mengen gemerkt, zurueckgestellt und geprueft werden mit der leeren Menge, p wird mit der ersten Programmstelle und f t mit dem sich aus der Methodensignatur ergebenen ersten Schachteltyp initialisiert. 2. Benutze Zertifikat Der aktuelle Schachteltyp f t wird gemäß den Informationen im Zertifikat angepasst, falls ein Eintrag vorhanden ist. Falls der aktuelle Schachteltyp laut Zertifikat später benötigt wird (Die Menge Markierungen enthält einen Eintrag für diese Programmstelle), wird (p, f t) der Menge gemerkt hinzugefügt. 3. Prüfe Bedingungen der Instruktionen Es wird geprüft, ob die aktuelle Instruktion C(p) mit dem aktuellen Schachteltyp f t ausgeführt werden kann (Passende Typen auf dem Keller und in den lokalen Variablen, keine Überschreitung der maximalen Kellergröße, usw.). Falls die Instruktion nicht ausgeführt werden kann, dann wird die Methode ABGELEHNT. 4. Prüfe zurückgestellte Bedingungen Falls der Algorithmus auf eine zurückgestellte Programmstelle trifft, werden die Schachteltypen verglichen. Falls f t nicht schwächer definiert ist als f t 0 wird die Methode ABGELEHNT, ansonsten konnte die zurückgestellte Programmstelle erfolgreich überprüft werden und der Eintrag (p 0 , f t 0 ) wird aus der Menge zurueckgestellt entfernt. 5. Instruktion erfolgreich geprüft Die aktuelle Programmstelle p wird zu der Menge der schon geprüften Instruktionen geprueft hinzugefügt. 6. Bestimme und verarbeite mögliche Fortsetzungsstellen Falls die aktuelle Instruktion C(p) kein goto und kein return ist und somit einen direkten Nachfolger besitzt, dann ergeben sich p1 und f t1 durch 42 KAPITEL 3. KONZEPTION 1. gemerkt := ; geprueft := ∅; zurueckgestellt := ; C := Bytecode der Methode; p := 0; ft := initialer Schachteltyp gemäß Methodenparameter ; 2. ft := δ(p)(ft); if p ∈ Markierungen then gemerkt := gemerkt ∪ (p, ft) fi; 3. if not InstruktionAusfuehrbar(p, ft) then ABGELEHNT fi; 4. for each (p, ft’) ∈ zurueckgestellt do if ft 6v ft’ then ABGELEHNT; zurueckgestellt := zurueckgestellt \ (p, ft’) od; 5. geprueft := geprueft ∪ p; 6. (p1 , ft1 ) := PrimaererNachfolger(C, p, ft); (p2 , ft2 ) := SekundaererNachfolger(C, p, ft); if p1 6= ⊥ and p1 6∈ geprueft then p := p1 ; ft := ft1 ; p1 := ⊥ elseif p2 6= ⊥ and p2 6∈ geprueft then p := p2 ; ft := ft2 ; p2 := ⊥ elseif (p’, ft’) ∈ zurueckgestellt then p := p’; ft := ft’; zurueckgestellt := zurueckgestellt \ (p’, ft’) else p := ⊥ fi; 7. if p1 6= ⊥ and p1 ∈ geprueft then if (p1 , ft’) 6∈ gemerkt then ABGELEHNT fi; if (p1 , ft’) ∈ gemerkt and ft’ 6v ft1 then ABGELEHNT fi fi; 8. if p2 6= ⊥ then if p2 ∈ geprueft then if (p2 , ft’) 6∈ gemerkt then ABGELEHNT fi; if (p2 , ft’) ∈ gemerkt and ft’ 6v ft2 then ABGELEHNT fi else zurueckgestellt := zurueckgestellt ∪ (p2 , ft2 ) fi fi; 9. if p = ⊥ then if geprueft = AlleProgrammstellenDerMethode then AKZEPTIERT else ABGELEHNT fi else goto 2 fi; Abbildung 3.12: Der Algorithmus zur leichtgewichtigen Bytecode-Überprüfung 3.3. DIE ÜBERPRÜFUNGSPHASE 43 Ausführung der Instruktion und Fortsetzung bei der direkten Nachfolgeinstruktion von C(p). p1 wird als primäre Fortsetzungsstelle bezeichnet. f t1 ist der Schachteltyp, der sich aus f t durch Ausführung der Instruktion ergibt. Gibt es keine primäre Fortsetzungsstelle, so wird p1 = ⊥ und f t1 = f t gesetzt. Ist C(p) eine Sprunginstruktion (bedingt oder unbedingt), dann wird das Sprungziel in p2 geschrieben und f t2 ist der Schachteltyp, der durch Ausführung der aktuellen Instruktion auf dem Schachteltyp f t entstanden ist. p2 wird als sekundäre Fortsetzungsstelle bezeichnet. Wenn es keine sekundäre Fortsetzungsstelle gibt, wird p2 = ⊥ und f t2 = f t gesetzt. Die nächste zu prüfende Programmstelle wird nun folgendermaßen bestimmt: • Ist p1 6= ⊥ und p1 noch nicht geprueft, dann setze p = p1 , f t = f t1 und schließlich p1 = ⊥ (Benutze die primäre Fortsetzungsstelle, falls diese noch nicht geprüft wurde). • Andernfalls falls p2 6= ⊥ und p2 noch nicht geprueft, dann setze p = p2 , f t = f t2 und schließlich p2 = ⊥ (Benutze die sekundäre Fortsetzungsstelle, falls die primäre schon geprüft wurde und die sekundäre noch nicht). • Andernfalls falls es noch zurueckgestellte Instruktionen gibt, wähle ein Element (p 0 , f t 0 ) aus zurueckgestellt und setze p = p 0 und f t = f t 0 (Falls sowohl primäre als auch sekundäre Fortsetzungsstelle schon geprüft wurden, benutze eine zurückgestellte Programmstelle). • Andernfalls setze p = ⊥ (Es wurde keine ungeprüfte Fortsetzungsstelle gefunden). 7. Prüfe gemerkte primäre Fortsetzungsstelle Falls die primäre Fortsetzungsstelle p1 existiert und schon geprüft wurde, dann muss ein Element (p1 , f t 0 ) in der Menge gemerkt vorhanden sein und f t1 muss schwächer definiert sein als f t 0 . Andernfalls wird die Methode ABGELEHNT. p1 ist somit eine Programmstelle, die mehrere Vorgänger besitzt. 8. Prüfe gemerkte oder stelle noch nicht gemerkte Fortsetzungsstellen zurück • Falls die sekundäre Fortsetzungsstelle p2 existiert und noch nicht geprüft wurde, dann wird p2 mit dem Schachteltyp f t2 zur Menge zurueckgestellt hinzugefügt. • Falls die sekundäre Fortsetzungsstelle p2 existiert, aber schon geprüft wurde, dann muss ein Element (p2 , f t 0 ) in der Menge gemerkt vorhanden sein und f t2 muss schwächer definiert sein als f t 0 . Trifft dies nicht zu, dann wird die Methode ABGELEHNT. p2 ist somit eine Programmstelle, die mehrere Vorgänger besitzt. 44 KAPITEL 3. KONZEPTION 9. Terminiere oder Iteriere Gibt es keine ungeprüfte Fortsetzungsstelle (p = ⊥) und sind alle Programmstellen überprüft worden (die Menge geprueft enthält alle Programmstellen der Methode), dann wird die Methode AKZEPTIERT. Gibt es keine ungeprüfte Fortsetzungsstelle, aber es wurden noch nicht alle Programmstellen geprüft, dann wird die Methode ABGELEHNT. Dies könnte ein Hinweis auf nicht erreichbaren Code sein. Gibt es eine Fortsetzungsstelle in p dann wird die Überprüfung in Schritt 2 fortgesetzt. Da der Algorithmus nur eine sekundäre Fortsetzungsstelle unterstützt, werden die Instruktionen tableswitch und lookupswitch, die im Allgemeinen mehr als ein Sprungziel enthalten, nicht unterstützt. Desweiteren können Ausnahmen nicht abgefangen werden. Jede Instruktion, die durch eine Ausnahmebehandlung geschützt ist und theoretisch eine Ausnahme auslösen kann, hätte die erste Instruktion der Ausnahmebehandlung (exception handler) als sekundäre Fortsetzungsstelle. Eine mögliche Erweiterung des Algorithmus werde ich in Abschnitt 3.4 diskutieren. Komplexität des Algorithmus Die Zeitkomplexität ist linear bezüglich der Länge des zu prüfenden Bytecodes. Jede der Anweisungen 2-9 wird für jede Bytecode-Instruktion der Methode höchstens einmal ausgeführt, weil in jeder Iteration eine Instruktion der Menge geprueft hinzugefügt wird. Der Algorithmus terminiert spätestens, wenn alle Instruktionen der Methode geprüft wurden. Sind die Mengenoperationen in konstanter Zeit durchzuführen, so ist der Gesamtaufwand linear. Die entscheidende Frage bei der Platzkomplexität ist die Größe der beiden Mengen gemerkt und zurueckgestellt. Die Menge zurueckgestellt enthält Schachteltypen von Instruktionen, die einen sekundären Vorwärtssprung auf noch nicht geprüfte Instruktionen besitzen. Wieviele Elemente diese Menge maximal enthalten wird, hängt von dem Bytecode ab. Im schlimmsten Fall kann diese Menge linear mit der Bytecode-Größe wachsen. Die Größe der Menge gemerkt ist durch die Markierungen-Einträge des Zertifikats vorgegeben. Auch hier könnte die Größe der Menge linear mit der Bytecode-Größe wachsen. Der bei der praktischen Anwendung anfallende Speicherplatzbedarf des Algorithmus kann daher nur durch einen intensiven Test mit möglichst vielen verschiedenen Klassen bestimmt werden. In Kapitel 6 werde ich auf die Ergebnisse dieser Evaluierung näher eingehen. Es folgen nun zwei Beispiele, die die Anwendung der leichtgewichtigen Verifikation illustrieren sollen. 3.3.2 Beispiel 1 Die folgende Methode zur Berechnung des größten gemeinsamen Teilers zweier Zahlen nach dem Euklidischen Algorithmus soll zertifiziert werden: 3.3. DIE ÜBERPRÜFUNGSPHASE 45 static int ggt(int a, int b) { int x = a, y = b, z; while (y != 0 ) { z = x % y; x = y; y = z; }//while return x; } Datenflussanalyse Das Ergebnis der Datenflussanalyse der übersetzten Methode ist in Abbildung 3.13 zu sehen. Instruktionen lokale Variablen Keller 0 1 2 3 4 iload_0 istore_2 iload_1 istore_3 goto 17 int int int int int int int int int int int int int int ε int ε int ε 7 8 9 2 10 12 13 14 16 iload_2 iload_3 irem istore 4 iload_3 istore_2 iload 4 istore_3 int int int int int int int int int int int int int int int int ε int int,int int ε int ε int 1 int int int int int int int int int int int int int int int int int int int int 3 17 18 iload_3 ifne 7 int int int int int int int int ε int 4 21 22 iload_2 ireturn int int int int int int int int ε int Abbildung 3.13: Ergebnis der Datenflussanalyse für das Beispiel größter ge” meinsamer Teiler“ Die interessanteste Stelle ist hier die Programmstelle 17, die die beiden Vorgängerinstruktionen 16 und 4 besitzt. Die beiden Schachteltypen der Instruktionen 16 und 4 werden gemäß der u-Operation des Verbandes kombiniert, so dass im Schachteltyp der Programmstelle 17 die fünfte lokale Variable statt eines int-Typs das ⊥-Symbol enthält. Kompression des Zertifikats Hier wird nun der Überprüfungsalgorithmus durchgeführt (vgl. Abbildung 3.12), wobei die von der Datenflussanalyse berechneteten Schachteltypen benutzt werden. Während der Überprüfung wird das Zertifikat erzeugt. In Schritt 2 werden statt des Zertifikats – was ja hier noch gar nicht existiert – die vollständigen Schachteltypen aus der Datenflussanalyse benutzt, d.h. die Funktion δ liefert die Ergebnisse der Datenflussanalyse. Weiterhin wird in diesem Schritt geprüft, ob sich an der aktuellen Programmstelle p der vom Algorithmus bestimmte 46 KAPITEL 3. KONZEPTION Schachteltyp f t von dem berechneten unterscheidet. Gibt es einen Unterschied, dann wird ein Zertifikatseintrag aus der Differenz dieser beiden Schachteltypen erzeugt. Dieser Eintrag hat dann genau die Form wie sie in Abschnitt 3.2.2 beschrieben wurde. Ähnlich verhält es sich mit den Markierungen. Während der Kompressionphase enthält die Menge gemerkt alle Programmstellen der Methode. Das führt dazu, dass in Schritt 7 und 8 niemals eine zum Vergleich nötige Programmstelle fehlt. Muss ein Schachteltypvergleich in Schritt 7 oder 8 durchgeführt werden, dann wird die entsprechende Programmstelle in dem Zertifikat zu der Liste der Markierungen hinzugefügt. Für das Beispiel ergibt sich nun folgendes: Der Algorithmus arbeitet die Blöcke in der Reihenfolge 1, 3, 4, 2 ab. Die goto-Instruktion in Block 1 lässt den Algorithmus in Stelle 17 fortführen. Nach dem Erreichen der return-Instruktion setzt der Algorithmus wieder an Stelle 7 fort, die vorher in die Menge zurueckgestellt geschrieben wurde. Die vom Algorithmus bestimmten Schachteltypen sind mit denen aus der Datenflussanalyse identisch. Daher sind die Schachteltypeinträge des Zertifikats leer. Bei der zuletzt geprüften Programmstelle 16 ist der direkte Nachfolger schon geprüft worden. Das heißt, dass der Schritt 7 des Algorithmus einen gemerkten Eintrag benötigt und diesen mit dem Schachteltyp vergleicht, der durch die Ausführung der Instruktion an Programmstelle 16 entsteht. Aus diesem Grund gibt es in dem Zertifikat einen Eintrag in den Markierungen. Das Zertifikat hat somit folgenden Inhalt: Markierungen = Typenliste = Eintraege = {17} {} {} Überprüfung Die Überprüfung funktioniert nun ähnlich wie die Kompression. Es wird hier jedoch ausschließlich das Zertifikat zur Überprüfung benutzt. Die Instruktionen werden in der gleichen Reihenfolge wie bei der Kompression geprüft (siehe Abbildung 3.14). In Schritt 2 wird der Schachteltyp f t nicht verändert, da keine Schachteltypeinträge in dem Zertifikat vorhanden sind. Die delta-Funktion entspricht also der identischen Abbildung. Bei Abarbeitung der Programmstelle 17 wird wegen der Markierung im Zertifikat der Schachteltyp dieser Programmstelle gemerkt. In Schritt 3 wird die Ausführbarkeit der Instruktionen mit dem aktuellen Schachteltyp geprüft. Die Bedingung in Schritt 4 tritt nicht ein, da der Algorithmus auf keine zurückgestellte Instruktion trifft. Jede geprüfte Instruktion wird in Schritt 5 zur Menge geprueft hinzugefügt. In Schritt 6 wird mit zwei Ausnahmen immer die primäre Fortsetzungsstelle p1 als nächste zu überprüfende Stelle gewählt. Die Ausnahmen sind die goto-Instruktion und die return-Instruktion. Bei der goto-Instruktion wird die sekundäre Fortsetzungsstelle benutzt – also der Sprung zur Programmstelle 17. 3.3. DIE ÜBERPRÜFUNGSPHASE Instruktionen Reihenfolge und Bemerkung 0 1 2 3 4 iload_0 istore_2 iload_1 istore_3 goto 17 7 8 9 10 2 12 13 14 16 iload_2 iload_3 irem istore 4 iload_3 istore_2 iload 4 istore_3 3 17 18 iload_3 ifne 7 Schachteltyp 17 gemäß Zertifikat merken (Schritt 2) Sprungziel 7 und Schachteltyp zurückstellen (Schritt 8) 4 21 22 iload_2 ireturn zurückgestellten Schachteltyp benutzen (Schritt 6) 1 47 sekundären Nachfolger benutzen (Schritt 6) Schachteltypvergleich mit gemerktem Typ von Stelle 17 (Schritt 7) Akzeptieren (Schritt 9) Abbildung 3.14: grober Ablauf des Überprüfungsalgorithmus für das Beispiel größter gemeinsamer Teiler“ ” Die return-Instruktion hat weder eine primäre noch eine sekundäre Fortsetzungsstelle und der Algorithmus bedient sich aus den zurückgestellten Schachteltypen. Der Algorithmus stellt bei der Überprüfung von Programmstelle 16 in Schritt 7 fest, dass die primäre Nachfolgeprogrammstelle 17 schon geprüft wurde und führt daher einen Schachteltypvergleich mit dem von Programmstelle 17 gemerkten Schachteltyp durch. Dieser Schachteltyp wurde in Schritt 2 gemerkt. Weil bei der Prüfung von Programmstelle 18 der sekundäre Nachfolger 7 noch nicht geprüft wurde, stellt Schritt 8 diesen Nachfolger mit dem aktuellen Schachteltyp zurück damit diese Programmstelle später geprüft wird. Der Algorithmus akzeptiert schließlich nach Abarbeitung der Programmstelle 16 die Methode. Was dieses Beispiel auch andeutet, ist die Tatsache, dass die Zertifikate und auch der Speicherbedarf während der Überprüfung von der Überprüfungsreihenfolge abhängen. Dies bedeutet, dass sich durch geschickte Änderung der Reihenfolge, in der die einzelnen Programmblöcke geprüft werden, der Speicherplatzbedarf des Algorithmus unter Umständen reduzieren lässt. Dazu ist eine Erweiterung der Zertifikate notwendig, damit dort die Prüfreihenfolge angegeben werden kann. Die Änderung der Reihenfolge kann auch dazu führen, dass mehr Schachteltypen in den Zertifikaten gespeichert werden müssen. Daher wird dieser mögliche Speicherplatzgewinn durch größere Zertifikate erkauft. 48 3.3.3 KAPITEL 3. KONZEPTION Beispiel 2 Dieses zweite nicht ganz so praxisnahe Beispiel soll dazu dienen, ein Zertifikat zu erzeugen, dessen Schachteltypeinträge nicht leer sind: void test(int if ( x == float } else { int y } } x) { 0 ) { y = 0; = 0; Die Datenflussanalyse liefert die in Abbildung 3.15 gezeigten Schachteltypen. Instruktionen lokale Variablen Keller 0 1 iload_1 ifne 9 A A int int ε int 4 5 6 fconst_0 fstore_2 goto 11 A A A int int int float ε float ε 9 10 iconst_0 istore_2 A A int int ε int 11 return A int ε Abbildung 3.15: Ergebnis der Datenflussanalyse für das zweite Beispiel und die Überprüfungsreihenfolge Die Instruktion 11 besitzt als Vorgänger die Instruktionen 10 und 6. Die lokale Variable 2 erhält durch Instruktion 10 einen int-Typ und durch Instruktion 6 einen float-Typ. Da ab Programmstelle 11 der Typ der lokalen Variable 2 nicht mehr eindeutig ist, befindet sich dort das ⊥-Symbol. Dies ist durch die u-Operation des Verbandes entstanden. Denn es gilt (A, int, float)u (A, int, int) = (A, int, ⊥). Nach der Prüfreihenfolge wird die Programmstelle 11 vor der Programmstelle 10 geprüft. Dies bedeutet, dass an dieser Stelle der Algorithmus noch gar nicht wissen kann, dass die Instruktion an Programmstelle 10 später einen int-Typ in die lokale Variable 2 schreiben wird. Da die Bestimmung der Schachteltypen für jede Instruktion genau einmal durchgeführt wird, ist hier ein Schachteltypeintrag in dem Zertifikat notwendig. Desweiteren benötigt der Algorithmus beim Prüfen der Programmstelle 10 den Schachteltyp der Nachfolgeprogrammstelle 11. Somit ergibt sich als Zertifikat: Markierungen = Typenliste = Eintraege = {11} {0 → ⊥} {11 → {(2, 0)}} 3.4. ERWEITERUNGEN 3.3.4 49 Zusammenfassung Dieser Ansatz muss im Gegensatz zur herkömmlichen Datenflussanalyse keine Fixpunktsuche durchführen und somit auch nicht den Schachteltyp jeder Programmstelle im Speicher halten. Für alle Programmstellen, deren Schachteltyp nicht bei einem sequentiellen Durchlauf durch den Bytecode bestimmt werden kann, enthält das Zertifikat eine Differenz zwischen dem tatsächlichen Schachteltyp und dem Schachteltyp, der bei dem sequentiellen Durchlauf bestimmt wurde. Bei der Betrachtung des benötigten Speicherplatzes ist nicht nur die Größe des Zertifikats entscheidend, sondern auch der Speicherbedarf während der Überprüfung. Insbesondere Schachteltypen, die während der Überprüfung gespeichert werden, spielen hier eine große Rolle. Aufschluss über den tatsächlichen Speicherplatzbedarf kann nur eine ausführliche Evaluierung liefern, die möglichst viele Methoden testet und deren Speicherbedarf bei der Überprüfung misst. Weitere Überlegungen und die Ergebnisse der Evaluierung werde ich in Kapitel 6 vorstellen. 3.4 Erweiterungen Der Ansatz von Eva Rose [Ro98] unterstützt nur eine Untermenge der Instruktionen der JVM [JVM96] bzw. der JCVM [JCVM]. Insgesamt werden nur die folgenden 24 Instruktionen abgedeckt: aconst null iload pop ifle areturn invokevirtual iconst 0 aload dup ifnull return invokespecial iconst 1 istore iadd goto getfield new ldc w astore isub ireturn putfield checkcast In diesem Abschnitt werde ich daher einige Erweiterungen diskutieren mit dem Ziel, den vollständigen Befehlssatz der JVM zu unterstützen. Dabei soll nicht jede fehlende Instruktion einzeln dargestellt werden, sondern ich werde mich auf die Beschreibung der dahinterstehenden Konzepte beschränken und deren Integration in den bestehenden Ansatz erläutern. 3.4.1 triviale Erweiterungen Viele der nicht unterstützten Instruktionen lassen sich problemlos integrieren. Diese werde ich daher zuerst abhandeln. Die Instruktionen iadd und isub verhalten sich auf Typebene gleich oder ähnlich wie alle anderen arithmetischen Instruktionen auch. Unterschiede gibt es bei der benötigten Anzahl und Art der Typen auf dem Keller. Beispielsweise benötigt die Instruktion ineg nur einen Operanden vom Typ int auf dem Keller. An weiteren Typen gibt es bei den arithmetischen Instruktionen neben int noch float, long und double. Etwas komplizierter sind die mehrwortigen 50 KAPITEL 3. KONZEPTION Datentypen long und double, die ich in dem Abschnitt 3.4.5 über mehrwortige Datentypen behandeln werde. Ähnlich verhält es sich mit den bedingten Sprüngen. Alle Instruktionen der Form ifhcondi sind auf Typebene mit der Instruktion ifle identisch. In jedem Fall wird ein int-Typ vom Keller genommen. Die Instruktion ifnotnull entspricht der ifnull-Instruktion, die eine Referenz vom Keller nimmt. Abgesehen von tableswitch und lookupswitch sind die übrigen bedingten Sprünge sehr ähnlich zu den bereits unterstützen Instruktionen. Diese Instruktionen unterscheiden sich nur darin, wieviele und welche Typen sie auf dem Keller erwarten. Der entscheidene Unterschied bei tableswitch und lookupswitch ist die Tatsache, dass diese Instruktionen Sprünge zu mehr als einer Programmstelle erlauben. Es gibt also mehrere sekundäre Fortsetzungsstellen. Der Überprüfungsalgorithmus unterstützt jedoch nur eine einzige sekundäre Fortsetzungsstelle. Wie der Algorithmus anzupassen ist, um mehrere sekundäre Fortsetzungsstellen zu unterstützen, werde ich in Abschnitt 3.4.4 erläutern. Die Typumwandlungsbefehle wie z.b. i2l oder f2i erwarten einen bestimmten Typ auf dem Keller und ersetzen diesen durch einen anderen Typ. Abgesehen von den mehrwortigen Datentypen stellen diese Instruktionen auch keine große Herausforderung dar. Die übrigen Instruktionen der JVM sind entweder ähnlich einfach einzubinden oder lassen sich mit den konzeptionellen Erweiterungen der folgenden Abschnitte realisieren. 3.4.2 Reihungen (arrays) Um Reihungen zu unterstützen, ist eine Erweiterung des Grundkomponentenverbands notwendig (vgl. 3.2.1). Jede Reihung besitzt einen Basistyp und eine Dimension größer als Null. Jeder Grunddatentyp und jede Klasse kann Basistyp einer Reihung sein. Es folgen einige Beispiele: Reihung int[] int[][] Object[] String[][] String[][][] Basistyp int int Object String String Dimension 1 2 1 2 3 Der Grundkomponentenverband, den E. Rose bei ihrem Ansatz benutzt, besteht aus den Typen int, ⊥, > und Klassen. Dieser Verband ist nun um Reihungen zu erweitern. Dazu muss definiert werden, welche Beziehungen zwischen den neuen Elementen in dem Verband bestehen. Ich werde diese Beziehungen über die durch den Verband induzierte partielle Ordnung beschreiben. Zur Erinnerung: Es gilt x v y ⇔ x u y = x. 3.4. ERWEITERUNGEN 51 1. Es sei r eine Reihung. Dann gilt Object v r 2. Es sei r eine Reihung. Dann gilt r v Nullreferenz 3. Zwischen Reihungen mit gleicher Dimension gelten die gleichen Beziehungen wie zwischen den Basistypen 4. Sei od eine Reihung vom Basistyp Object mit Dimension d. Sei rd+1 eine beliebige Reihung mit Dimension d + 1. Es gilt dann od v rd+1 Ein Beispiel für einen derartig erweiterten Verband zeigt die Abbildung 3.16. null int[] java.lang.String[][][] java.lang.Object[][][] int[][] java.lang.String[] java.lang.String[][] int java.lang.Object[][] java.lang.Object[] java.lang.String java.lang.Object Abbildung 3.16: Beispiel für einen um Reihungen erweiterten Grundkomponentenverband 3.4.3 Ausnahmebehandlungen Ausnahmen werden in Java durch try-catch-Blöcke abgefangen. Tritt innerhalb des try-Blocks eine Ausnahme auf, wird die Ausführung der Methode beim nächsten zu der Ausnahme passenden catch-Block fortgesetzt. Gibt es in der 52 KAPITEL 3. KONZEPTION aktuellen Methode keinen passenden catch-Block, so wird die Ausnahme an die aufrufende Methode weitergereicht und dort die Suche nach einem passenden Ausnahmebehandlungsblock fortgesetzt. Der Java-Übersetzer übersetzt diese Blöcke in sogenannte Ausnahmetabellen (exception tables) und Ausnahmebehandlungen (exception handler). Bei den Ausnahmebehandlungen handelt es sich um normalen Bytecode, der Teil der zugehörigen Methode ist. Die Ausnahmetabellen sind Methodenattribute und bestehen aus ggf. mehreren Einträgen mit drei Programmstellen und einem Ausnahmetyp: Die ersten beiden Programmstellen definieren den Anfang und das Ende des geschützten Bereichs. Die dritte Programmstelle bezeichnet den Anfang der Ausnahmebehandlung und der Typ gibt schließlich an, welcher Ausnahmetyp durch diese Ausnahmebehandlung abgefangen wird. Wenn in einem Bereich mehrere verschiedene Arten von Ausnahmen abgefangen werden sollen, existieren in der Ausnahmentabelle mehrere Einträge mit den gleichen Anfangs- und Endprogrammstellen. Der Ansatz von E. Rose würde die Ausnahmebehandlungsblöcke als nicht erreichbaren Bytecode ansehen und damit die Methode zurückweisen. Damit diese Methoden akzeptiert werden, müssten alle Instruktionen, die durch einen solchen Block geschützt sind und eine Ausnahme auslösen könnten, die erste Instruktion jeder passenden Ausnahmebehandlung als sekundäre Nachfolgeinstruktion besitzen. Dies verhält sich aus Sicht der Datenflussanalyse wie ein bedingter Sprung. Abbildung 3.17 zeigt dazu ein Beispiel. static int test (int x) { try { return 10 / x; } catch ( Exception ex ) { return 0; } } 0: bipush 10 2: iload_0 3: idiv 4: ireturn 5: 6: 7: Ausnahmentabelle Anfang Ende Ziel 0 5 5 Typ java.lang.Exception astore_1 iconst_0 ireturn Abbildung 3.17: Beispiel für eine Ausnahmebehandlung, die durch idiv ausgelöst werden kann Die einzige Instruktion zwischen den Programmstellen 0 und 5 (dabei zählt die Instruktion 5 nicht mehr zu dem geschützten Bereich), die eine Ausnahme auslösen kann, ist die Instruktion idiv. Ist der Divisor gleich Null so wird eine ArithmeticException ausgelöst. Abgefangen wird diese Ausnahme durch die Ausnahmebehandlung ab Programmstelle 5. Einige Instruktionen können mehrere verschiedene Ausnahmen auslösen und 3.4. ERWEITERUNGEN 53 von verschiedenen Ausnahmebehandlungen abgefangen werden. Dies kann zum Beispiel ein Methodenaufruf mit invokevirtual sein. Dann gibt es von dieser Instruktion mehr als zwei Nachfolger. Der primäre Nachfolger ist die nächste Instruktion, die ausgeführt wird, wenn keine Ausnahme auftritt. Die anderen Nachfolgeinstruktionen sind die ersten Instruktionen aller möglichen Ausnahmebehandlungen in der aktuellen Methode. Das Problem hier ist, dass der Überprüfungsalgorithmus nur eine sekundäre Fortsetzungsstelle unterstützt. Es bestehen hier also die gleichen Schwierigkeiten wie bei den bedingten Sprunginstruktionen tableswitch und lookupswitch. Typsemantisch geschieht beim Auslösen einer Ausnahme folgendes: Der Keller wird geleert und mit einer Referenz auf den Typ der aufgetretenen Ausnahme gefüllt. Die lokalen Variablen bleiben erhalten. D.h. die lokalen Variablen der ersten Instruktion der Ausnahmebehandlung sind eine Zusammenfassung aller lokalen Variablen der möglichen auslösenden Programmstellen. Bei der Datenflussanalyse ist dies kein größeres Problem. Jedoch müsste sich der Überprüfungsalgorithmus für jede dieser Vorgängerinstruktionen einen Schachteltyp merken, falls die Ausnahmebehandlung noch nicht überprüft wurde (in der Menge zurueckgestellt in Schritt 8 des Algorithmus). Dies könnte sehr schnell den verfügbaren Speicher der Javacard übersteigen, vor allem wenn eine Ausnahmebehandlung von vielen Stellen aus aufgerufen werden kann. Eine Lösung für dieses Problem wäre, den Algorithmus so zu modifizieren, dass die Ausnahmebehandlungen grundsätzlich zuerst geprüft werden. Dies würde zwar einen vollständigen Schachteltypeintrag im Zertifikat für die erste Instruktion des Ausnahmebehandlung erfordern, würde aber im Gegenzug den Speicherbedarf während der Überprüfungsphase reduzieren. Gerade bei längeren Bereichen, die durch eine oder mehrere Ausnahmebehandlungen geschützt sind, würde dies zu einer größeren Reduzierung des Speicherplatzbedarfs führen. 3.4.4 Instruktionen mit mehr als einem Sprungziel Es gibt drei Situationen, in denen eine Instruktion mehr als zwei Nachfolgeinstruktionen haben kann: 1. Durch die Instruktion tableswitch. In Abbildung 3.18 ist ein Beispiel zu sehen, in dem die Programmstelle 1 vier Nachfolgeinstruktionen besitzt. 2. Durch die Instruktion lookupswitch. Dies ist sehr ähnlich zur Instruktion tableswitch. Der einzige Unterschied ist die Art und Weise wie die Fortsetzungsstellen in der Instruktion gespeichert werden. 3. Durch Instruktionen, die mehr als eine Ausnahme auslösen können und durch mehrere Ausnahmebehandlungen geschützt sind. Dies sind beispielsweise Methodenaufrufe, die new-Instruktion oder Instruktionen für den Zugriff auf Reihungen. Für diese drei Fälle ist eine Erweiterung des Überprüfungsalgorithmus notwendig, da dieser nur maximal zwei Fortsetzungsstellen pro Instruktion unterstützt. Dabei muss die Berechnung der sekundären Fortsetzungsstelle an- 54 KAPITEL 3. KONZEPTION 0 iload_1 1 tableswitch 0 to 2: default=43 0: 38 1: 33 2: 28 28 iconst_3 29 istore_2 30 goto 45 33 iconst_2 34 istore_2 35 goto 45 38 iconst_1 39 istore_2 40 goto 45 43 iconst_0 44 istore_2 45 return Abbildung 3.18: die Instruktion tableswitch besitzt mehr als zwei Fortsetzungsstellen gepasst werden. Der Algorithmus muss hier statt einer einzigen Fortsetzungsstelle eine Menge von Fortsetzungsstellen berechnen. Diese Berechnung ist für tableswitch und lookupswitch einfach, da die möglichen Fortsetzungsstellen als Operanden in der Instruktion enthalten sind. Für die Ausnahmen hingegen muss zuerst geprüft werden, ob die aktuelle Instruktion eine Ausnahme auslösen kann und ob sie durch eine Ausnahmebehandlung geschützt ist. Die erste Instruktion der Ausnahmebehandlung ist dann eine sekundäre Fortsetzungsstelle. Bei Instruktionen, die zwar Ausnahmen auslösen können, aber keine Ausnahmebehandlung in der aktuellen Methode besitzen, brauchen die Ausnahmen nicht berücksichtigt zu werden. Denn beim Auslösen einer Ausnahme wird die Methode sofort beendet und die lokalen Variablen und der Keller der Methode sind nicht mehr zugreifbar. Gibt es zu einer Instruktion genau eine Ausnahmebehandlung, dann lässt sich diese Instruktion wie eine bedingte Sprunginstruktion ansehen. Die primäre Fortsetzungsstelle ist die direkte Nachfolgeinstruktion, die ausgeführt wird, wenn keine Ausnahme auftritt. Die sekundäre Fortsetzungsstelle entspricht der ersten Instruktion der Ausnahmebehandlung. Mehr als zwei Fortsetzungsstellen gibt es somit nur, wenn es entweder in einer Methode mindestens zwei Ausnahmebehandlungen gibt oder die Instruktionen tableswitch bzw. lookupswitch verwendet werden. In Abbildung 3.19 ist ein Beispiel zu sehen, in dem ein Programmstück durch zwei Ausnahmebehandlungen geschützt ist. Die einzige Instruktion, die beide Ausnahmen auslösen kann, ist die invoke-Instruktion. Neben der Berechnung der sekundären Fortsetzungsstellen muss der Überprüfungsalgorithmus noch an den Stellen erweitert werden, an denen die sekundäre Fortsetzungsstelle verwendet wird. Auch hier muss statt einer einzigen Fortsetzungsstelle eine Menge von Fortsetzungsstellen benutzt werden. Dies ist einmal an der Stelle, wo die als nächstes zu prüfende Instruktion bestimmt wird (Schritt 6) und zum anderen dort, wo die sekundären Sprungziele mit gespeicherten Schachteltypen verglichen und ggf. zurückgestellt werden (Schritt 8). 3.4. ERWEITERUNGEN 55 static int test1 ( int x ) { try { test2(x); return 10 / x; } catch ( ArithmeticException ex ) { return 0; } catch ( Exception ex ) { return 1; } } 0 iload_0 1 invokestatic #3 4 5 7 8 9 ireturn pop bipush 10 iload_0 idiv 10 astore_1 11 iconst_0 12 ireturn Ausnahmentabelle von bis Ziel 0 0 10 10 10 13 Ausnahmetyp java.lang.ArithmeticException java.lang.Exception 13 astore_2 14 iconst_1 15 ireturn Abbildung 3.19: Beispiel für mehrere Ausnahmebehandlungen in einer Methode Jede Verwendung von p2 muss ersetzt werden durch die Verwendung von jedem Element aus der Menge der sekundären Sprungziele. Bei Ausnahmen ist noch eine weitere Besonderheit zu berücksichtigen: Wenn eine Ausnahme ausgelöst wird, wird der Operandenkeller geleert und eine Referenz der ausgelösten Ausnahme auf den Keller abgelegt. Dies bedeutet, dass im Gegensatz zu den sekundären Nachfolgern von tableswitch und lookupswitch bei Ausnahmen nicht nur mehrere sekundäre Fortsetzungsstellen zu merken sind, sondern auch verschiedene Schachteltypen möglich sind. Diese Schachteltypen müssen glücklicherweise nicht zwischengespeichert werden, sondern können bei Bedarf über die Ausnahmentabelle ermittelt werden. Sie unterscheiden sich von dem direkten Nachfolgeschachteltypen nur in dem Operandenkeller. Es wird die Nachfolgeprogrammstelle, die ja bei einer Ausnahmebehandlung der ersten Instruktion dieser Ausnahmebehandlung entspricht, in der Ausnahmentabelle nachgeschlagen und deren Ausnahmetyp bildet dann das einzige Element des Operandenkellers. Die Schachteltypen der Ausnahmebehandlungen aus dem Beispiel aus Abbildung 3.19 werden daher folgendermaßen bestimmt: Die lokalen Variablen der beiden sekundären Nachfolgeinstruktionen von Programmstelle 1 besitzen die gleichen Typen in den lokalen Variablen wie der primäre Nachfolger. Der Operandenkeller für den Sprung von Programmstelle 1 nach 10 enthält den Typ ArithmeticException und der Sprung von 1 nach 13 enthält den Typ Exception. Ähnliches gilt für den einzigen sekundären Nachfolger der idivInstruktion. Die lokalen Variablen des Schachteltyps für Programmstelle 10 stimmen mit dem primären Nachfolger von Programmstelle 9 überein und der Operandenkeller enthält wieder nur den Typ aus der Ausnahmentabelle. Abbildung 3.20 zeigt diese Bestimmung des Schachteltyps der ersten Instruktion 56 KAPITEL 3. KONZEPTION einer Ausnahmebehandlung. Eigentlich dürften die lokalen Variablen nicht vom primären Nachfolger genommen werden, sondern von der auslösenden Instruktion selbst. Da aber Ausnahmen auslösende Instruktionen die lokalen Variablen nicht modifizieren, ist dies nicht weiter tragisch. Bei der Betrachtung des modifizierten Überprüfungsalgorithmus fällt auf, dass der Schachteltyp der auslösenden Instruktion gar nicht mehr verfügbar ist, wenn die Schachteltypen der Ausnahmen benötigt werden. Aus diesem Grund werden die lokalen Variablen des Schachteltyps des primären Nachfolgers benutzt. 0 iload_0 1 invokestatic #3 lokale Variablen 4 5 7 8 pop bipush 10 iload_0 idiv int lokale Variablen 9 ireturn int Keller int lokale Variablen 13 astore_2 14 iconst_1 15 ireturn Keller int lokale Variablen 10 astore_1 11 iconst_0 12 ireturn int int Keller Exception Keller ArithmeticException Abbildung 3.20: Beispiel für die Bestimmung der Schachteltypen bei Sprüngen in Ausnahmebehandlungen Es folgen nun die modifizierten Schritte 6 und 8 des Überprüfungsalgorithmus. Dabei wird eine neue Funktion SchachteltypAusnahmebehandlung benutzt. Diese Funktion modifiziert den Keller eines Schachteltyp mit Hilfe der Ausnahmentabelle, falls die übergebene Programmstelle die erste Instruktion einer Ausnahmebehandlung ist. Andernfalls wird der übergebene Schachteltyp unverändert zurückgegeben. 6. (p1 , ft1 ) := PrimaererNachfolger(C, p, ft); (P2 , ft2 ) := SekundaererNachfolger(C, p, ft); if p1 6= ⊥ and p1 6∈ geprueft then p := p1 ; ft := ft1 ; p1 := ⊥ elseif P2 6= ∅ and ∃ p’ ∈ P2 mit p’ 6∈ geprueft then p := p’; ft := SchachteltypAusnahmebehandlung(p’, ft2 ); P2 := P2 \ p’ elseif (p’, ft’) ∈ zurueckgestellt then p := p’; ft := ft’; zurueckgestellt := zurueckgestellt \ (p’, ft’) else p := ⊥ fi; (∗ ) Die wesentlichste Änderung besteht hier in der Tatsache, dass die Funktion SekundaererNachfolger statt einer Programmstelle eine Menge von Programmstellen zurückgibt. Bei der Bestimmung der als nächstes zu prüfenden 3.4. ERWEITERUNGEN 57 Programmstelle muss daher auch in der durch (∗ ) markierten Zeile eine noch nicht geprüfte Programmstelle aus dieser Menge benutzt werden. Der zweite zu ändernde Schritt ist der Schritt 8. Hier müssen alle Anweisungen, die vorher mit der einen sekundären Fortsetzungsstelle durchgeführt wurden, mit allen in der Menge P2 enthaltenen Programmstellen durchgeführt werden. Dabei wird jedes Element der Menge nach der Bearbeitung aus dieser Menge entfernt bis die Menge leer ist. 8. while ∃ p’ ∈ P2 do ft’’ := SchachteltypAusnahmebehandlung(p’, ft2 ); if p’ ∈ geprueft then if (p’, ft’) 6∈ gemerkt then ABGELEHNT fi; if (p’, ft’) ∈ gemerkt and ft’ 6v ft’’ then ABGELEHNT fi else zurueckgestellt := zurueckgestellt ∪ (p’, ft’’) fi P2 := P2 \ p’ od Es stellt sich nun die Frage nach der Laufzeit. Wie ich weiter oben bereits erwähnt habe, enthält diese Menge P2 nur dann mehr als ein Element, wenn entweder die Instruktionen tableswitch bzw. lookupswitch benutzt werden oder wenn eine Instruktion mehrere Ausnahmen auslösen kann und durch mehr als eine Ausnahmebehandlung geschützt ist. Diese Bedingungen dürften nicht sehr häufig eintreten und schon gar nicht für alle Instruktionen einer Methode. Daher wird sich in der Praxis die Laufzeit nicht wesentlich erhöhen, da die Menge P2 in den meisten Fällen weniger als zwei Elemente enthält. 3.4.5 mehrwortige Datentypen Die Datentypen long und double belegen im Gegensatz zu den anderen Datentypen zwei Worte auf dem Operandenkeller und in den lokalen Variablen. Wird ein long- oder ein double-Wert mit den Instruktionen lstore bzw. dstore in einer lokalen Variable gespeichert, so belegen diese Werte tatsächlich zwei lokale Variablen. Um die Typsicherheit zu gewährleisten, muss der Zugriff mit einwortigen Instruktionen auf diese beiden Worte verhindert werden. Ähnlich verhält es sich mit dem Operandenkeller. Kellerinstruktionen, die mit einwortigen Werten operieren, dürfen keinesfalls die Integrität von zweiwortigen Werten beschädigen. Zum Beispiel darf die dup-Instruktion nicht ausgeführt werden, wenn die obersten beiden Worte auf dem Operandenkeller einen long- oder einem double-Wert repräsentieren. Um diesen Zugriff zu verhindern, müssen long und double-Typen in zwei Typen aufgeteilt werden, die jeweils die beiden einzelnen Worte dieser Doppelworte repräsentieren. Bild 3.21 zeigt den entsprechend erweiterten Grundkomponentenverband, der um die beiden Elemente *long2* und *double2* ergänzt wurde. Diese beiden neuen Typen stehen für den zweiten Teil eines double- bzw. long-Typs. 58 KAPITEL 3. KONZEPTION *long2* double int *double2* long A java.lang.Object Abbildung 3.21: Der für mehrwortige Typen erweiterte Grundkomponentenverband Wie diese neuen Typen von den mehrwortigen Instruktionen verwendet werden, zeigt das Beispiel in Abbildung 3.22. Zu Beginn befinden sich in den ersten beiden lokalen Variablen ein long-Wert, der als Parameter an die Methode übergeben wurde. Dieser wird durch die erste Instruktion lload 0 auf den Operandenkeller abgelegt. Es befinden sich dann zwei Worte auf dem Keller, die die beiden Teilworte des long-Wertes repräsentieren. Bei der Ausführung von Kellerinstruktionen, die mit einzelnen Worten operieren, kann nun der Zugriff auf Teilworte erkannt und verhindert werden. Beispielsweise darf die pop-Instruktion an dieser Stelle nicht verwendet werden. An Programmstelle 1 wird nun eine Konstante vom Typ long auf dem Keller abgelegt. Die beiden Doppelworte auf dem Keller werden nun multipliziert. Das Ergebnis dieser Multiplikation wird schließlich als Methodenergebnis zurückgegeben. static long mal_zwei (long l) { long x = l * 2; return x; } lokale Variablen Instruktionen 0 0 1 4 5 6 7 lload_0 ldc2_w 2 lmul lstore_2 lload_2 lreturn long long long long long long 1 2 3 *long2* *long2* *long2* *long2* *long2* long *long2* *long2* long *long2* Keller *long2*, long *long2*, long, *long2*, long *long2*, long *long2*, long Abbildung 3.22: Beispiel für die Verwendung mehrwortiger Typen 3.4.6 Objektinitialisierung Die Erstellung neuer Objekte führt die Java Virtual Machine in zwei Phasen durch. Die erste Phase reserviert Speicherplatz für das Objekt durch den Aufruf der Instruktion new. Die zweite Phase ruft dann zu einem späteren 3.4. ERWEITERUNGEN 59 Zeitpunkt den Konstruktor mit invokespecial auf, um das noch nicht initialisierte Objekt zu initialisieren. Zwischen der new- und der entsprechenden invokespecial-Instruktion können beliebige Befehle stehen, die aber keinesfalls ein noch nicht initialisierte Objekt benutzen dürfen. So darf diese Referenz nicht nicht in Feldern anderer Objekte gespeichert oder als Methodenergebnis zurückgegeben werden. Auch die Methoden und Felder dieses Objektes dürfen nicht benutzt werden. Die Überprüfung dieser beiden Phasen und die Einhaltung der Zugriffsbeschränkungen auf das noch nicht initialisierte Objekt wird dadurch erschwert, dass die Referenz auf das Objekt mehrfach auf dem Keller abgelegt wird. Es ist weiterhin möglich, dass die uninitialisierte Referenz in einer lokalen Variable abgelegt wird. Nach dem Aufruf der Konstruktormethode dürfen sämtliche Referenzen nicht mehr als uninitialisiert angesehen werden. Desweiteren können mehrere verschiedene uninitialisierte Objekte des gleichen Typs auf dem Keller abgelegt werden, die zu unterschiedlichen Zeitpunkten zu initialisierten Objekten werden. Um dieses Problem zu verdeutlichen, soll die main-Methode der folgenden Klasse betrachtet werden: public class A { protected A previous; A(A prev) { previous = prev; } static public void main ( String[] args ) { A a = new A(new A(null)); } } Die Besonderheit an dieser Klasse A ist, dass der Konstruktor eine Referenz auf ein Objekt der eigenen Klasse als Parameter erhält. Damit lassen sich new-Aufrufe schachteln. Diese Schachtelung wird in der main-Methode benutzt. Der Bytecode dieser main-Methode und der entsprechende Kellerinhalt vor Ausführung der Instruktion sind in folgender Tabelle abgebildet: 0 3 4 7 8 9 12 15 16 Instruktion new Class A dup new Class A dup aconst null invokespecial Method A(A) invokespecial Method A(A) astore 1 return Kellerinhalt A A, A, A, A, A, A A A, A, A, A, A A, A A, A, null A Nach Abarbeitung der Programmstelle 9 ist das zuerst erzeugte Objekt vollständig initialisiert. Nach Programmstelle 12 ist das zweite Objekt A durch den KonstruktorAufruf initialisiert. Wie sich an diesem Beispiel leicht erkennen lässt, müssen die beiden Referenzen auf dem Keller unterschieden werden, solange sie uninitialisert sind. Dies 60 KAPITEL 3. KONZEPTION wird durch ein Zusatzinformation erreicht, die den Typ um die Programmstelle ergänzt, an der das Objekt erzeugt wurde: 0 3 4 7 8 9 12 15 16 Instruktion new Class A dup new Class A dup aconst null invokespecial Method A(A) invokespecial Method A(A) astore 1 return Kellerinhalt A0 A0 , A0 , A0 , A0 , A0 , A A0 A0 , A0 , A0 , A0 , A4 A4 , A4 A4 , A4 , null A Bemerkung A4 → A A0 → A Der Index muss nach Aufruf des Konstruktors bei allen Typen der aktuellen Schachtel mit dem entsprechenden Index entfernt werden. Nach Ausführung von Programmstelle 9 müssen also der Keller und die lokalen Variablen durchsucht und jedes A4 durch A ersetzt werden. Ähnliches geschieht an Programmstelle 12, an der jedes Auftreten von A0 im aktuellen Schachteltyp durch A ersetzt wird. Diese Indizierung mit den Programmstellen scheitert, wenn eine new-Instruktion in einer Schleife aufgerufen wird, ohne den Konstruktur aufzurufen. Dies erzeugt nämlich unterschiedliche Referenzen, die sich beim Konstruktoraufruf und den damit verbundenem Zustandswechsel von unitialisiert nach initialisiert nicht unterscheiden lassen. Aus diesem Grund dürfen nach der JVM-Spezifikation bei einem Rückwärtssprung keine nicht initialisierten Objekte existieren. Dies Einschränkung garantiert, dass in Schleifen niemals mehrere uninitialisierte Objekte erzeugt werden. Ein ähnliches Problem existiert bei Ausnahmen. Wird eine Instruktion durch eine Ausnahme geschützt, dann dürfen keine uninitialisierte Objekte in den lokalen Variablen existieren. Der Keller bleibt hier unberücksichtigt, weil dieser beim Auslösen einer Ausnahme geleert wird. Desweiteren muss bei Konstruktormethoden berücksichtigt werden, dass die thisReferenz in der lokalen Variable 0 ein noch nicht initialisiertes Objekt darstellt. Erst wenn eine Konstruktormethode derselben Klasse oder einer Oberklasse aufgerufen wird, wechselt der Zustand von unitialisiert zu initialisiert. Dies muss bei der Bestimmung des Schachteltyps der ersten Programmstelle berücksichtigt werden. Es müssen also folgende Erweiterungen durchgeführt werden, um die Objektinitialisierung zu verifizieren: 1. Die Grundtypen – im speziellen die Referenzen – müssen zusätzliche Informationen über die Programmstelle enthalten, an der das zugehörige Objekt erzeugt wurde. In dem Grundkomponentenverband müssen diese Typen unterschieden werden. Wenn also ein Objekt A in einer Methode an verschiedenen Stellen erzeugt wird, gibt es für A insgesamt drei Typen in dem Verband: Der initialisierte Typ und zwei uninitialisierte Typen, die durch die Programmstelle unterschieden werden, an denen sie erzeugt wurden. Abbildung 3.23 zeigt den entsprechend erweiterten Verband für die main-Methode aus dem letzten Beispiel. 2. Bei der Bestimmung des ersten Schachteltyps von Konstruktor-Methoden muss die this-Referenz – also die lokale Variable 0 – ein nicht initialisiertes Objekt referenzieren. 3. Instruktionen innerhalb von Bereichen, die von Ausnahmen geschützt sind, dürfen keine uninitialisierten Objekte in den lokalen Variablen enthalten. 3.5. SCHLUSSFOLGERUNG A@4 *uninit* 61 A@0 *null* java.lang.String[] java.lang.Object[] java.lang.String A java.lang.Object Abbildung 3.23: Beispiel für einen Verband, in dem nicht initialisierte und initialisierte Typen unterschieden werden 4. Die Transferfunktion für die new-Instruktion muss den auf dem Keller abgelegten Typ um die aktuelle Programmstelle ergänzen. 5. Die Transferfunktion für invokespecial entfernt die Programmstelleninformationen wieder, falls damit ein Konstruktor aufgerufen wird. Dazu wird die Programmstelleninformation der Referenz, die dieser Instruktion übergebenen wurde, gelesen und bei allen Typen, die sich in der aktuellen Schachtel befinden und die gleiche Programmstelleninformation besitzen, entfernt. Enthält die übergebene Referenz keine Programmstelleninformation, so ist das Objekt schon initialisiert worden und der Konstruktor darf keinesfalls ein zweites Mal aufgerufen werden. 6. Alle Sprunginstruktionen mit einem Rückwärtssprung müssen prüfen, ob eine nicht initialisierte Referenz im aktuellen Schachteltyp vorhanden ist. Ist dies der Fall, dann ist die Methode fehlerhaft. 7. Alle Transferfunktionen, die auf Objektreferenzen zugreifen – wie beispielsweise getfield, putfield, invokeinterface, invokevirtual oder areturn – dürfen nur ausgeführt werden, wenn das referenzierte Objekt bereits vollständig initialisiert wurde. Eine Ausnahme bilden hier die Konstruktoren. Hier dürfen die Felder des noch nicht initialisierten Objekts bereits beschrieben werden, obwohl der Oberklassenkonstruktor noch nicht aufgerufen wurde. 3.5 Schlussfolgerung Das besondere an dem Ansatz von E. Rose ist, dass der Hauptaufwand in den Sicherheitsrichtlinien – also dem Typmodell der JVM – steckt. Die Zertifikate sind nicht be- 62 KAPITEL 3. KONZEPTION sonders kompliziert aufgebaut, da es sich lediglich um Typinformationen handelt. Dafür ist die Erstellung der Zertifikate umso aufwändiger. Der Aufwand ist nämlich genauso groß wie der Aufwand der herkömmlichen Bytecode-Verifikation. Die Überprüfung der Zertifikate lässt sich dafür relativ schnell durchführen und kommt mit wenig Speicher aus. Abgesehen von Unterprogrammen lässt sich das Verfahren der leichtgewichtigen Verifikation so erweitern, dass damit eine vollständige Bytecode-Verifikation durchgeführt werden kann. Kapitel 4 Korrektheit Die Korrektheit der beiden Phasen der leichtgewichtigen Verifikation lässt sich nur durch eine Formalisierung aller beteiligten Komponenten nachweisen. Dazu ist die herkömmliche Bytecode-Verifikation, die leichtgewichtige Verifikation und insbesondere die Semantik der Bytecodeinstruktionen auf Typebene zu formalisieren. In diesem Abschnitt werde ich keinen vollständigen Beweis führen. Der Grund dafür ist, dass die Beweisführung wesentlich davon abhängt, ob die herkömmliche BytecodeVerifikation richtig formalisiert wurde. Da Sun die Verifikation jedoch nur informal beschreibt, lässt sich nicht exakt sagen, ob ein Modell der Verifikation exakt der virtuellen Maschine von Sun entspricht. E. Rose hat in [Ro98] einen Beweis für die von ihr behandelte Bytecode-Untermenge geliefert. Darüberhinaus hat sie eine vollständige Beschreibung der statischen und dynamischen Semantik der in dem Beweis benutzten Java-Bytecode-Untermenge angegeben. Da in dieser Arbeit der Schwerpunkt auf der Implementierung und Evaluierung liegt, werde ich diesen Beweis nur grob skiziieren. Dieser Beweis ist in ähnlicher Form in [RR98] zu finden. Darüberhinaus sollen einige der Erweiterungen aus Kapitel 3.4 in den Beweis integriert werden. Im folgenden werde ich zuerst ein Modell der herkömmlichen Verifikation aufstellen. Danach wird eine formale Beschreibung der Zertifizierung und der leichtgewichtigen Verifikation benötigt, um schließlich die Äquivalenz dieser beiden Verifizierer nachzuweisen. Dieses Modell wird in der Notation der Natural Semantics (siehe 2.4) formuliert. Es wird ein Regelsystem beschrieben, mit dem sich ein Beweis für die Durchführbarkeit der Verifikation erzeugen lässt. 4.1 Die herkömmliche Bytecode-Verifikation Die folgende Beschreibung drückt die herkömmliche Verifikation auf eine etwas ungewohnte Art aus. Hier wird davon ausgegangen, dass die Schachteltypen für alle Programmstellen bereits bekannt sind und nur die Korrektheit dieser Schachteltypen geprüft wird. Vom logischen Standpunkt aus macht dies keinen Unterschied. Diese ungewöhnliche Beschreibung der Verifikation macht die Beweisführung jedoch einfacher. Zunächst sollen die Grundbausteine wie der Konstantenbereich und die anderen benötigte Datenstrukturen beschrieben werden. 63 64 4.1.1 KAPITEL 4. KORREKTHEIT Der Konstantenbereich Einige Instruktionen benötigen Parameter, die nicht in den Bytecode eingebettet werden können. Diese befinden sich daher im Konstantenbereich. Neben Zahlen enthält der Konstantenbereich auch Methodenreferenzen und Feldreferenzen. Andere Einträge des Konstantenbereichs wie Zeichenketten werden hier nicht modelliert. Die Feldreferenzen werden von den Instruktionen getfield und putfield benötigt. Die Instruktionen invokevirtual oder invokespecial benutzen die Methodenreferenzen, um die aufzurufenden Methoden zu ermitteln. cp ∈ Konstantenbereich it ∈ Item int ∈ Integer = Z f ref ∈ FeldReferenz cid ∈ KlassenID id ∈ Bezeichner mt ∈ MaschinenTyp mref ∈ MethodenReferenz msig ∈ MethodenSignatur rmt ∈ Ergebnistyp ::= it∗ ::= int | f ref | mref ::= fieldref(cid, id, mt) ::= ::= ::= ::= int | cid methref(cid, msig, rmt) methsig(id, mt∗ ) void | mt Auf den Konstantenbereich kann mittels cp(i) zugegriffen werden. Das erste Element wird mit cp(0) angesprochen. Eine Feldreferenz besteht aus einer Klasse, einem Feldnamen und einem Typ. Eine Methodenreferenz besteht aus einer Klasse, einer Signatur und einem Ergebnistyp. Der Ergebnistyp kann neben Referenz und Grundtyp auch vom Typ void sein. Die Signatur gibt den Namen der Methode an und bestimmt, welche Parameter mit welchen Typen die Methode besitzt. Damit Erweiterungen wie Reihungen oder weitere Grunddatentypen berücksichtigt werden, ist hier der MaschinenTyp zu ergänzen: mt ∈ MaschinenTyp ::= gt | arrayid | cid gt ∈ Grunddatentyp ::= int | f loat | double | double2 | long | long2 f loat, double, double2 ∈ Real = Q long, long2 ∈ Integer = Z arrayid ∈ Bezeichner Die beiden zweiwortigen Datentypen long und double sind hier, wie im Konzeptionskapitel beschrieben, in zwei Komponenten aufgeteilt. Für die Reihungen sind zwei Funktionen notwendig, die die Dimension und den Basistyp einer Reihung bestimmen. 4.1.2 Schachteltypen Jeder Programmstelle der Methode wird nun genau ein Schachteltyp zugeordnet. Es ergibt sich also eine endliche Abbildung von Programmstellen auf Schachteltypen: 4.1. DIE HERKÖMMLICHE BYTECODE-VERIFIKATION md ∈ MethodentypDeskriptor f t ∈ Schachteltyp st ∈ Kellertyp lvt ∈ LokaleVariablenTyp = = = = 65 f in Programmstellen −→ Schachteltyp Kellertyp × LokaleVariablenTyp MaschinenTyp ∗ (MaschinenTyp | ⊥)∗ Jede Programmstelle der Methode besitzt nicht mehr als einen Schachteltyp, weil die JVM-Spezifikation vorschreibt, dass der Keller an jeder Programmstelle die gleiche Tiefe besitzt, unabhängig davon, durch welchen Programmpfad diese Programmstelle erreicht wurde. Die Anzahl der lokalen Variablen ist innerhalb einer Methode konstant und somit für jede Programmstelle in der Methode gleich. Etwas komplizierter wird dies innerhalb von Unterprogrammen, aber die sollen hier – wie zuvor beschrieben – nicht behandelt werden. Die Schachteltypen sind ähnlich definiert wie der Verband der Datenflussanalyse aus Abschnitt 3.2.1 und werden aus einfacheren Elementen zusammengesetzt. Der Schachteltyp ergibt sich daher aus einem Kellertyp und einem Typ für die lokalen Variablen. Man beachte, dass der Keller nicht das ⊥-Symbol enthalten kann. Dies ist nach JVM-Spezifikation nicht zulässig. Der Keller muss an jeder Programmstelle Werte von eindeutig bestimmbaren Typen enthalten. Das aus dem Verband bekannte >-Element tritt hier nicht auf, weil dieses nur als Zwischenergebnis bei der Datenflussanalyse benutzt wird. Hier wird nur das Ergebnis dieser Datenflussanalyse modelliert. Zur Erinnerung: Das >-Symbol bezeichnet in der Datenflussanalyse Elemente, deren Typ noch nicht bekannt ist, weil diese von anderen ebenfalls noch nicht bekannten Typen abhängen. Schließlich wird noch die Relation definiert, die ich schon vorher als die auf einem Verband induzierte partielle Ordnung erwähnt habe. E. Rose nennt diese Relation schwächer definiert“ und wird folgendermaßen in der Notation der Natural Semantics ” mit Hilfe der Unterklassenrelation ≤: definiert: cid2 ≤: cid1 cid1 v cid2 ⊥ v mt Das rechte Axiom definiert das ⊥-Symbol als kleinstes Element. mt kann hier – wie weiter oben definiert – eine Klasse oder ein Grundtyp sein. Durch die rechte Regel wird ein Zusammenhang zwischen der Unterklassenbeziehung ≤: und der Relation v festgelegt. Zwischen zwei Klassen cid1 und cid2 gilt die Relation cid1 v cid2 , wenn cid1 eine Oberklasse von cid2 ist oder cid1 = cid2 gilt. Diese Relation müsste nun entsprechend den Erweiterungen aus Kapitel 3.4 ergänzt werden. Die dort beschriebenen Erweiterungen des Grundkomponentenverbands müssen auf diese Relation übertragen werden. Dies soll aus Gründen der Übersichtlichkeit hier nur kurz erwähnt werden. Für Reihungen müssten beispielsweise die vier Regeln aus Abschnitt 3.4.2 in entsprechenden Regeln formuliert werden. Dabei sind die Funktionen zur Bestimmung der Dimension und des Basistyps einer Reihung hilfreich. Diese Relation wird nun auf ganze Schachteltypen erweitert. Dies entspricht genau der Konstruktion des Schachteltypverbands aus dem Grundkomponentenverband. Die Elemente zweier Schachteltypen f t1 und f t2 seien folgendermaßen benannt: f t1 = hmts1 (1) mts1 (2) . . . mts1 (n1 ), mtl1 (1) mtl1 (2) . . . mtl1 (m1 )i , f t2 = hmts2 (1) mts2 (2) . . . mts2 (n2 ), mtl2 (1) mtl2 (2) . . . mtl2 (m2 )i 66 KAPITEL 4. KORREKTHEIT Für Schachteltyp i ist mtsi (j) das j-te Element auf dem Keller und mtli (k) ist die k-te lokale Variable. Die Relation v für ganze Schachteltypen ist nun folgendermaßen definiert: mts1 (1) v mts2 (1) mts1 (2) v mts2 (2) . . . mts1 (n) v mts2 (n) mtl1 (1) v mtl2 (1) mtl1 (2) v mtl2 (2) . . . mtl1 (m) v mts2 (m) f t1 v f t 2 mit n = n1 = n2 , m = m1 = m2 Die beiden Schachteltypen f t1 und f t2 müssen also die gleiche Anzahl Elemente auf dem Keller und die gleiche Anzahl lokaler Variablen besitzen. Weiterhin gilt die Relation gilt nur, wenn die Relation v zwischen jedem entsprechenden Typ gilt. 4.1.3 Verifikation Nun soll die herkömmliche Bytecode-Verifikation formal ausgedrückt werden. Dazu müssen nur noch die Größe des Kellers und die Anzahl der lokalen Variablen der zu verifizierenden Methode definiert werden: ms ∈ MaxStack = N mlv ∈ MaxLokaleVariablen = N Als Formel in der Notation der Natural Semantics geschrieben ergibt sich für die Bytecode-Verifikation folgendes: ≤:, cid, cp ` msig, c, ms, mlv : md (BV) Vor dem ` steht der Klassenkontext in Form der Klassenhierarchie, des Klassennamens und des Konstantenbereichs. Die lokalen Informationen der zu prüfenden Methode befinden sich zwischen ` und dem Doppelpunkt. Diese Informationen sind die Methodensignatur msig, der Bytecode c, die maximale Kellergröße ms und die Anzahl der lokalen Variablen mlv. Schließlich ist hinter dem Doppelpunkt der Typdeskriptor der Methode zu finden. Wie eingangs beschrieben, ist md an dieser Stelle bereits bekannt und wird nur auf Korrektheit geprüft. Implizit steckt in dieser Prüfung eine Konstruktionsanleitung. Um diese Formel beweisen zu können, werden die folgenden Regeln benötigt. 4.1.4 Methodenprüfung Methodenprüfung bedeutet zu überprüfen, dass jede in der Methode enthaltene Instruktion ihre Bedingungen erfüllt hat. Dies bedeutet im wesentlichen, dass die Instruktion mit ihrem Schachteltyp ausführbar ist und dass der Keller und die Anzahl der lokalen Variablen groß genug sind. h≤; , cp, ms, mlv, rmti ` ∅, 0, c : md → P P ≤; , cid, cp ` msig, c, ms, mlv : md (4.1) 4.1. DIE HERKÖMMLICHE BYTECODE-VERIFIKATION 67 Wobei folgendes gilt: msig = methsig(rmt, mt1 . . . mtn ) lvt = {0 7→ cid, 1 7→ mt1 , . . . , n 7→ mtn , n + 1 7→ ⊥, . . . , mlv − 1 7→ ⊥} md(0) v h, lvti Dom(md) = P P Man beachte, dass die Konklusion der Methodenprüfung genau der Formel für die Bytcode-Verifikation (BV) entspricht. Damit diese Konklusion gilt, muss die Prämisse nachgewiesen werden. Die Anwendbarkeit der Regel 4.1 wird durch einige Bedingungen eingeschränkt. Sind diese Nebenbedingungen nicht erfüllt, lässt sich die Konklusion nicht beweisen. Dom(md) ist die Urbildmenge von md. Dom(md) = P P bedeutet, dass die Abbildung md jedes Element der Menge P P auf einen Schachteltyp abbilden muss. Dies ist eine Voraussetzung für die Anwendbarkeit dieser Regel. Es müssen also vorher alle Instruktionen geprüft werden, bevor diese Regel bewiesen werden kann. Bei dieser Instruktionenprüfung wird die Menge P P nach und nach erweitert bis die Programmstellen aller Instruktionen in P P aufgenommen wurden. Die Elemente hier dem Pfeil → kann man in dieser und in allen folgenden Regeln als Berechnungsergebnis“ der ” Regel ansehen. Desweiteren muss für den Schachteltyp der ersten Instruktion gelten, dass dieser schwächer definiert“ ist als h, lvti. Dies ist der Schachteltyp, der von der JVM ein” gerichtet wird, wenn die Methode aufgerufen wird. Die erste lokale Variable lvt(0) ist eine Referenz auf die Klasse, zu der diese Methode gehört. Statische Methoden werden also bei diesem Beweis nicht unterstützt. Die Methodenparameter werden ebenfalls als lokale Variablen verfügbar gemacht und befinden sich hinter der this-Referenz in der Menge lvt. 4.1.5 Prüfung von Instruktionsfolgen Zur Vereinfachung wird zunächst die Umgebung der Methode zu dem Symbol Ξ zusammengefasst, weil diese Umgebung innerhalb einer Methode unveränderlich ist und in den folgenden Regeln immer wieder benötigt wird. Dies erhöht die Lesbarkeit der Regeln. Die Umgebung besteht aus dem Konstantenbereich, der maximalen Kellergröße, der Anzahl der lokalen Variablen und dem Ergebnistyp der Methode: Ξ ∈ StatischeMethodenInformation ::= h≤:, cp, ms, mlv, rmti Nun lässt sich die Instruktionenprüfung folgendermaßen ausdrücken: Ξ ` pp, i : md → pp 0 Ξ ` P P 0 , pp 0 , c : md → P P 00 Ξ ` P P, pp, i · c : md → P P 00 P P 0 = P P ∪ {pp} Ξ ` P P, pp, : md → P P (4.2) (4.3) Zwischen ` und : stehen in den Konklusionen drei Elemente: Eine Menge von Programmstellen P P , die aktuelle Programmstelle pp und eine Liste von noch zu prüfenden Instruktionen. Die Regel (4.2) soll nach und nach die erste Menge mit allen Programmstellen der Methode füllen und dabei die Liste der Instruktionen leeren bis das Axiom (4.3) erfüllt ist. Der Beginn der Instruktionenprüfung wird durch Regel (4.1) initiiert. Der obere Teil der Prämisse von Regel (4.2) prüft die Instruktion i an Programmstelle pp. Diese Formel wird im folgenden bei der Prüfung der einzelnen Instruktionen 68 KAPITEL 4. KORREKTHEIT noch mehrmals auftreten. Gelesen wird die Formel folgendermaßen: Falls im Typkontext Ξ die Instruktion i an Programmstelle pp typsicher im Bezug auf den Methodentypdeskriptor md ist, dann kann an der Zielprogrammstelle pp 0 fortgesetzt werden. Der untere Teil der Prämisse sorgt für die Prüfung aller Folgeinstruktionen und setzt die aktuelle Instruktion um einen Schritt von pp auf pp 0 weiter. Dieser Vorgang wird rekursiv wiederholt, bis der zweite Teil der Prämisse das Axiom (4.3) ergibt. Damit wäre dann die Konklusion erfüllt und die Instruktionenfolge überprüft. In P P 0 werden die schon geprüften Programmstellen gesammelt, d.h. nach jeder Anwendung der Regel (4.2) enthält diese Menge ein Element mehr, und zwar pp. Genau dieser Sachverhalt wird durch die Nebenbedingung sichergestellt. 4.1.6 einzelne Instruktionen Für jede Instruktion muss nun eine Regel existieren, die angibt, wie diese Instruktion verifiziert werden kann. Dies entspricht den Transferfunktionen. Ich werde hier einen Auszug dieser Regeln vorstellen. Konkret handelt es sich bei diesen Regeln um Axiome mit Nebenbedingungen. Zuerst folgen einfache Instruktionen, die nur den Keller modifizieren. einfache Kellerinstruktionen (4.4) Ξ ` pp, i : md → pp 0 Wobei folgendes gilt: pp 0 = pp + 1 md(pp) = hst, lvti f t 0 = hst 0 , lvti md(pp 0 ) v f t 0 |st 0 | < ms pp 0 ist hier die Nachfolgestelle der Instruktion i an Programmstelle pp. md(pp) ist der Schachteltyp vor der Ausführung und f t 0 ist der Schachteltyp nach der Ausführung der Instruktion i. Welche Instruktionen für i stehen können und wie sich bei Ausführung dieser Instruktion der Keller st 0 aus dem Keller st berechnet, zeigt folgende Tabelle an ein paar Beispielen: Instruktion i iconst 0 iconst 1 aconst null dup pop iadd isub dadd st st st st st1 · mt st1 · mt st1 ·int·int st1 ·int·int st1 · double2 · double · double2 · double st 0 st·int st·int st·Object st1 · mt · mt st1 st1 ·int st1 ·int st1 · double2 · double Das oberste Element des Kellers steht immer rechts. Der Punkt · verkettet Elemente zu einer Liste. So stellt z.B. die Instruktion iconst 0 einen int-Typ oben auf den Keller. Durch die Unterstützung von mehrwortigen Datentypen muss bei der pop-Instruktion die Integrität dieser Typen durch zusätzliche Bedingungen sichergestellt werden. Es muss verhindert werden, dass durch eine pop-Instruktion eine einzelne Komponente eines mehrwortigen Typs vom Keller genommen wird: 4.1. DIE HERKÖMMLICHE BYTECODE-VERIFIKATION Instruktion i pop 69 Bedingungen mt 6= long, mt 6= long2, mt 6= double, mt 6= double2 Etwas interessanter sind Instruktionen, die neben dem Keller auch den Konstantenbereich benötigen und auf Felder und Methoden von Objekten zugreifen. Objektorientierte Instruktionen Ξ ` pp, i : md → pp 0 (4.5) Wobei folgendes gilt: pp 0 = pp + 3 md(pp) = hst, lvti f t 0 = hst 0 , lvt 0 i md(pp 0 ) v f t 0 cp(n) = it cid ≤: cid 0 |st 0 | < ms it ist ein Element des Konstantenbereichs und muss entweder eine Feldreferenz oder eine Methodenreferenz enthalten abhängig davon, welche Instruktion i ausgeführt werden soll. Folgende Tabelle zeigt wieder, wie die Instruktion den Keller von st nach st 0 verändert unter Verwendung der Informationen aus dem Konstantenbereich: Instruktion i putfield[n] getfield[n] invokevirtual[n] invokevirtual[n] invokespecial[n] invokespecial[n] st st 0 · cid · mt st1 · cid st 0 · cid · mt1 · · · mtj st1 · cid · mt1 · · · mtj st 0 · cid · mt1 · · · mtj st1 · cid · mt1 · · · mtj st 0 st 0 st1 · mt st 0 st1 · mt st 0 st1 · mt it fieldref(cid 0 , id, mt) fieldref(cid 0 , id, mt) methref(cid 0 , methsig( methref(cid 0 , methsig( methref(cid 0 , methsig( methref(cid 0 , methsig( , mt1 , . . . , mtj ),void) , mt1 , . . . , mtj ), mt) , mt1 , . . . , mtj ),void) , mt1 , . . . , mtj ), mt) Die Instruktionen invokevirtual und invokespecial sind zweimal aufgeführt, weil diese bei vorhandenem und nicht vorhandenem Ergebnistyp unterschiedlich behandelt werden müssen. Der Wert n steht hier für einen Index innerhalb des Konstantenbereichs und ist Teil der Instruktion. Die Bedingung cid ≤: cid 0 garantiert, dass die dem Keller übergebene Klasse eine Unterklasse zu der im Konstantenbereich angebenen Klasse ist, so wie es laut JVMSpezifikation gefordert ist. Die Vorgehensweise bei Methoden ist ähnlich. Hier wird anhand der Methodensignatur geprüft, ob sich alle Parameter mit den passenden Typen auf dem Keller befinden. Liefert diese Methode einen Rückgabewert, so muss auch dieser Typ mit dem Keller verglichen werden. An dieser Stelle gibt es eine weitere Vereinfachung, da nach Spezifikation bei den Methodenparametern statt mti auch eine Unterklasse von mti als aktueller Parameter verwendet werden könnte. Darauf wird in dieser Modellierung verzichtet. Nun folgen bedingte Sprunginstruktionen. 70 KAPITEL 4. KORREKTHEIT bedingte Sprunginstruktionen Ξ ` pp, i : md → pp 0 (4.6) Wobei folgendes gilt: md(pp) = hst, lvti pp 0 = pp + 3 f t 0 = hst 0 , lvti md(pp 0 ) v f t 0 pp 00 = pp + n md(pp 00 ) v f t 0 Die Instruktion i modifiziert dabei den Keller folgendermaßen: Instruktion i ifle[n] ifnull[n] st st 0 · int st 0 · cid Beide Instruktionen nehmen also genau ein Element vom Keller. Dabei nimmt ifle einen int-Wert und ifnull eine Klassenreferenz vom Keller. Abschließend folgen noch Rücksprunginstruktionen, die vor allem prüfen, ob auch tatsächlich der richtige Ergebnistyp entsprechend der Methodendeklaration geliefert wird. Rücksprunginstruktionen Ξ ` pp, i : md → pp 0 (4.7) Wobei folgendes gilt: pp 0 = pp + 1 md(pp) = hst, lvti Folgende Tabelle zeigt wieder, welche Instruktionen i hier möglich sind und wie der Keller aussehen muss. Desweiteren gibt es in der Tabelle eine weitere Bedingung, die überprüft, ob die Methode auch mit dem korrekten Ergebnistyp beendet wird. Instruktion i return ireturn areturn 4.2 st st1 st1 · int st1 · cid Bedingung rmt = void rmt = int cid ≤: rmt Die leichtgewichtige Verifikation Die leichtgewichtige Verifikation besteht aus den beiden Phasen Zertifizierung und Überprüfung. Wenn in dem Zertifikat ein Eintrag gespeichert werden muss, dann wird hier der Einfachheit halber ein vollständiger Schachteltyp zusammen mit der zugehörigen Programmstelle in das Zertifikat aufgenommen. Die Zertifikate haben damit folgenden Aufbau: 4.2. DIE LEICHTGEWICHTIGE VERIFIKATION 71 cert ∈ Zertifikat ::= hδ, li f in δ ∈ ∆ = Programmstellen −→ Schachteltyp l ∈ Markierungen = P(Programmstellen) Die Markierungen haben die gleiche Bedeutung wie in Abschnitt 3.3.1 beschrieben. Die Schachteltypen wurden bereits in Abschnitt 4.1.2 definiert. 4.2.1 Zertifizierung Die Regel für die Bytecode-Verifikation (BV) wird nun erweitert, so dass ein Zertifikat erzeugt wird (leichtgewichtige Bytecode-Zertifizierung). ≤:, cid, cp ` msig, c, ms, mlv : md → cert (LBC) Methodenprüfung Entsprechend wird die Regel (4.1) zur Methodenprüfung um das zu konstruierende Zertifikat erweitert: h≤; , cp, ms, mlv, rmti ` ∅, 0, c : md → P P, cert ≤; , cid, cp ` msig, c, ms, mlv : md → cert Die Bedingungen der Regel bleiben unverändert. (4.8) Instruktionsfolgen Bei der Prüfung von Instruktionsfolgen sind zwei Zertifikate zu vereinigen. Nämlich einmal das Zertifikat cert, was sich bei Prüfung der Instruktion i ergibt und zum anderen das Zertifikat cert 0 , das Ergebnis der Prüfung des übrigen Bytecode ist: Ξ ` pp, i : md → pp 0 , cert Ξ ` P P 0 , pp 0 , c : md → P P 00 , cert 0 Ξ ` P P, pp, i · c : md → P P 00 , cert 00 P P 0 = P P ∪ {pp} cert 00 = cert ∪ cert 0 Ξ ` P P, pp, : md → P P, ∅ (4.9) (4.10) Das Axiom (4.3) wird um die leere Menge erweitert, weil hier das Zertifikat zu Beginn leer ist. einzelne Instruktionen Schließlich müssen die Regeln für die einzelnen Instruktionen um die Erzeugung des Zertifikats ergänzt werden. Dabei wird in der Konklusion der Regeln (4.4), (4.5) und (4.6) pp 0 durch pp 0 , cert ersetzt. Es wird ein Zertifikateintrag erzeugt, wenn md(pp 0 ) echt schwächer definiert ist als f t 0 . Für die Regeln (4.4) und (4.5) ohne Sprünge werden keine Markierungen für die Rückwärtssprünge erzeugt: cert = hδ, i ( {pp 0 7→ md(pp 0 )} falls md(pp 0 ) @ f t 0 δ= ∅ andernfalls 72 KAPITEL 4. KORREKTHEIT Bei den Sprunginstruktionen sind für Rückwärtssprünge zusätzlich Markierungen zu erzeugen. Das Zertifikat für die Regel der Sprunginstruktion (4.6) wird daher folgendermaßen berechnet: cert = hδ, li ( {pp 0 7→ md(pp 0 )} falls md(pp 0 ) @ f t 0 δ= ∅ andernfalls ( {pp 00 } falls pp 00 < pp l= ∅ andernfalls Schließlich wird das Zertifikat für die Rücksprung-Regel (4.7) bestimmt. Es wird nur dann ein nicht leerer Zertifikatseintrag erzeugt, wenn eine Instruktion nach der Rücksprunginstruktion existiert: cert = hδ, i ( {pp 0 7→ md(pp 0 )} falls md(pp 0 ) ∈ Dom(md) δ= ∅ andernfalls Nun lassen sich Zertifikate erzeugen, die später zur Überprüfung des Bytecode benutzt werden können. 4.2.2 Überprüfung Die Überprüfungsphase arbeitet ähnlich wie die herkömmliche Bytecode-Verifikation (BV). Hier muss statt des Methodendeskriptors md das Zertifikat cert benutzt werden. Dieses Zertifikat hat die Eigenschaft, dass es Informationen über die Schachteltypen md bei Bedarf bereitstellen kann. Das Regelsystem beginnt nun mit der folgenden Formel: ≤:, cid, cp ` msig, c, ms, mlv : cert (LBV) Die folgende Beschreibung entspricht dem Überprüfungsalgorithmus aus Abschnitt 3.3.1 und hat daher entsprechend den dort beschriebenen Mengen gemerkt und zurueckgestellt ähnliche Definitionen: f in S ∈ GemerkteSchachteltypen = Programmstellen −→ Schachteltyp P ∈ ZurueckgestelltePruefungen = P(Programmstellen × Schachteltyp) In den folgenden Regeln bezeichnet f t den aktuellen Schachteltyp und entspricht somit md(pp) im aktuellen Kontext. In der Menge S werden Schachteltypen gemerkt, die laut Zertifikat später noch einmal benötigt werden. Die Menge P enthält zurückgestellte Schachteltypen, die bei Vorwärtssprüngen in noch nicht geprüfte Bereiche gespeichert werden. Methodenprüfung Die Prüfung einer Methode (4.1) wird folgendermaßen angepasst: Ξ ` ∅, 0, c : cert, f t0 , ∅, ∅ → f t, S, P, P P ≤; , cid, cp ` msig, c, ms, mlv : cert (4.11) 4.2. DIE LEICHTGEWICHTIGE VERIFIKATION 73 Wobei folgendes gilt: msig = methsig(rmt, mt1 . . . mtn ) lvt = {0 7→ cid, 1 7→ mt1 , . . . , n 7→ mtn , n + 1 7→ ⊥, . . . , mlv − 1 7→ ⊥} f t0 = h, lvti Die zusätzlichen Informationen befinden sich in der Prämisse und werden von der Regel zur Bearbeitung von Instruktionsfolgen benutzt. Dabei handelt es sich um den aktuellen Schachteltyp f t und die beiden Mengen S und P . Instruktionsfolgen Die Regel 4.2 wird entsprechend so geändert, dass das Zertifikat verwendet wird. Ξ ` pp, i : cert, f t1 , S, P1 → f t 0 , P 0 , pp 0 Ξ ` P P 0 , pp 0 , c : cert, f t 0 , S 0 , P 0 → f t 00 , S 00 , P 00 , P P 00 Ξ ` P P, pp, i · c : cert, f t, S, P → f t 00 , S 00 , P 00 , P P 00 (4.12) Wobei folgendes gilt: P P 0 = P P ∪ pp cert = hδ, li ( δ(pp) falls pp ∈ Dom(δ) und δ(pp) v f t f t1 = ft andernfalls ( S ∪ {pp 7→ f t1 } falls pp ∈ l S0 = S andernfalls ∀f t 000 mit hpp, f t 000 i ∈ P gilt f t 000 v f t1 P1 = {hpp 000 , f t 000 i ∈ P | pp 000 6= pp} Die dritte Bedingung benutzt den Schachteltyp δ(pp) aus dem Zertifikat, falls für die aktuelle Programmstelle pp ein Eintrag vorhanden ist. Dieser Schachteltyp f t1 wird schließlich an die Regel für die einzelnen Instruktionen weitergereicht. Dies ist der erste Teil der Prämisse. Die Ergebnisse der Instruktionenprüfung werden schließlich an den zweiten Teil der Prämisse weitergegeben, so dass damit die übrigen Instruktionen geprüft werden. Die vierte Bedingung speichert den aktuellen Schachteltyp f t1 mit der aktuellen Programmstelle pp, falls im Zertifikat die aktuelle Programmstelle markiert ist. Durch die fünfte Bedingung werden alle für die aktuelle Programmstelle zurückgestellten Schachteltypen auf Kompatiblität mit dem aktuellen Schachteltyp überprüft. Schließlich entfernt die sechste Bedingung alle Elemente aus der Menge P , die in der fünften Bedingung benutzt wurden. Das Axiom (4.3) wird folgendermaßen geändert: Ξ ` P P, pp, : cert, f t, S, ∅ → f t, ∅, S, P P (4.13) Dieses Axiom ist nur anwendbar, wenn die Menge P leer ist. Dies garantiert, dass alle zurückgestellten Schachteltypen auch tatsächlich geprüft werden. 74 KAPITEL 4. KORREKTHEIT einzelne Instruktionen Die Regeln für die einzelnen Instruktionen werden folgendermaßen angepasst: • Jedes Auftreten von md(pp) wird ersetzt durch den aktuellen Schachteltyp f t. Dies ist der Schachteltyp, der vor Ausführung der Instruktion gilt. • Jede Bedingung md(pp 0 ) v f t 0 wird entfernt. Dies ist möglich, weil diese Bedingungen durch die Regel (4.12) bereits geprüft werden. • Die Bedingung md(pp 00 ) v f t 0 prüft die sekundären Fortsetzungsstellen und wird ersetzt durch (pp 00 < pp0 ∧ S(pp 00 ) v f t 0 ∧ P 0 = P ) ∨ (pp 00 ≥ pp 0 ∧ P 0 = P ∪ {pp 00 7→ f t 0 }) (4.14) Die Bedingung (4.14) unterscheidet Vorwärts- und Rückwärtssprünge. Bei einem Rückwärtssprung wird der Ergebnisschachteltyp der Instruktion mit dem gemerkten Schachteltyp verglichen. Falls dieser nicht existiert, ist die Regel nicht anwendbar und die Überprüfung scheitert. Der zweite Teil dieser Bedingung stellt noch nicht geprüfte Sprungziele zur späteren Prüfung durch Einfügen in die Menge P 00 zurück. Dadurch dass bei dieser Formalisierung die goto-Instruktion nicht unterstützt wird, kann die Art des sekundären Sprungs pp 0 durch einen Vergleich der Programmstellen pp 0 und pp 00 geschehen. Gäbe es eine goto-Instruktion, so wäre ein Rückwärtssprung ein Sprung auf eine schon geprüfte Instruktion. Ein Vorwärtssprung wäre ein Sprung auf eine noch nicht geprüfte Instruktion. Für eine Unterstützung von mehreren sekundären Programmstellen wäre statt pp 00 eine Menge zu benutzen. Dann gäbe es mehrere Bedingungen md(pp 00 ) v f t0 , die entsprechend durch mehrere Bedingungen der Form 4.14 zu ersetzen wären. 4.3 Korrektheit und Fälschungssicherheit Nachdem die gewöhnliche Bytecode-Verifikation (BV), die Zertifizierung (LBC) und die Überprüfung (LBV) formalisiert wurden, kann nun die Äquivalenz der leichtgewichtigen Verifikation zur herkömmlichen Verifikation gezeigt werden. Die drei Formeln seien hier noch einmal aufgelistet: ≤:, cid, cp ` msig, c, ms, mlv : md ≤:, cid, cp ` msig, c, ms, mlv : md → cert ≤:, cid, cp ` msig, c, ms, mlv : cert (BV) (LBC) (LBV) Es wird nun zuerst gezeigt, dass die Zertifizierung (LBC) dann und nur dann gelingt, wenn auch die gewöhnliche Bytecode-Verifikation (BV) erfolgreich durchgeführt werden kann. Danach wird bewiesen, dass die Überprüfung nur dann gelingt, wenn ein Zertifikat erzeugt werden kann. Damit wird die Fälschungssicherheit sichergestellt. Schließlich lässt sich aus den ersten beiden Beweisen die Äquivalenz der herkömmlichen Verifikation und der leichtgewichtigen Verifikation folgern. Wesentliche Idee des Beweises ist es zu zeigen, dass in der leichtgewichtigen Verifikation alle Schachteltypvergleiche durchgeführt werden, die auch von der herkömmlichen Verifikation durchgeführt werden. 4.3. KORREKTHEIT UND FÄLSCHUNGSSICHERHEIT 75 Satz 4.1. Die gewöhnliche Bytecode-Verifikation (BV) ist gültig genau dann, wenn folgendes gilt: Es existiert ein Zertifikat, so dass die leichtgewichtige Zertifizierung (LBC) gültig ist. Beweis. ⇒“: Die Zertifizierung wurde aus der herkömmlichen Verifikation durch Er” weiterung der vorhandenen Regeln abgeleitet. Diese Erweiterungen schränken die Anwendbarkeit der Regeln nicht ein, so dass die Erstellung des Zertifikats niemals scheitern kann, wenn die herkömmliche Verifikation durchgeführt werden kann. Daher kann jeder Beweis für die herkömmliche Verifikation (BV) zu einem Beweis für die Zertifizierung (LBC) erweitert werden. ⇐“: Die Rückrichtung ist trivial, da durch Entfernen aller cert-Einträge aus den ” Formeln eines (LBC)-Beweises sich der (BV)-Beweis ergibt. Satz 4.2. Die leichtgewichtige Verifikation (LBV) ist genau dann gültig, wenn folgendes gilt: Es existiert ein Methodendeskriptor md, so dass die leichtgewichtige Zertifizierung (LBC) gültig ist. Beweis. ⇒“: Aus dem Beweis für die leichtgewichtige Verifikation (LBV) muss nun ” ein Beweis für die Zertifizierung konstruiert werden (LBC). Der dazu notwendige Methodendeskriptor md wird dadurch ermittelt, dass jede Benutzung der Regel (4.12) in dem Beweis betrachtet wird. Die erste Formel der Prämisse dieser Regel hat die Form Ξ ` pp, i : cert, f t1 , S, P1 → f t 0 , P 0 , pp 0 . Der aktuelle Schachteltyp befindet sich in f t1 und wird für die Rekonstruktion von md verwendet. Es sollte klar sein, dass dieser Schachteltyp f t1 für jede Programmstelle vohanden ist. Schließlich ist noch zu zeigen, dass alle notwendigen Bedingungen für den (LBC)-Beweis auch in dem (LBV)-Beweis vorhanden sind. Dazu werden S, P und die Markierungen l des Zertifikats cert betrachtet. Es muss gezeigt werden, dass alle nötigen Schachteltyp-Vergleiche für den zu konstruierenden (LBC)-Beweis bereits im (LBV)-Beweis durchgeführt wurden. Dabei werden zwei Vergleiche unterschieden: • Die Vergleiche md(pp 0 ) v f t 0 werden in der Regel (4.12) bei der Bestimmung von f t1 erledigt. • Die Vergleiche md(pp 00 ) v f t 0 bei Sprunginstruktionen werden je nach Art des Sprungs unterschieden: – Bei Rückwärtssprüngen wird ein Vergleich mit den gemerkten Schachteltypen S durchgeführt Dieser Vergleich wird in den einzelnen Instruktionen durchgeführt (4.14). Dieser Vergleich kann nur durchgeführt werden, wenn vorher ein Eintrag in S gemacht wurde – also ein Eintrag in l im Zertifikat existiert. Da nach Voraussetzung davon ausgegangen wird, dass der (LBV)-Beweis korrekt ist, existiert auch dieser Eintrag. – Bei Vorwärtssprüngen in noch nicht geprüfte Bereiche wird in Regel (4.14) ein Eintrag in die Menge P vorgenommen. Der Vergleich wird somit zurückgestellt und später geprüft, wenn diese noch nicht geprüfte Programmstelle durch Regel (4.12) bearbeitet wird. ⇐“: Ähnlich wird aus einem (LBC)-Beweis ein (LBV) konstruiert. Der Methodende” skriptor md ist vollständig vorhanden, und es muss gezeigt werden, dass die für den (LBV)-Beweis benötigten Schachteltypvergleiche schon bei dem (LBC)-Beweis durchgeführt wurden. Dabei handelt es sich um folgende Vergleiche: • Der erste Vergleich in den Bedingungen der Regel (4.12) bei der Ermittlung von f t1 : Dieser Vergleich wird in jeder Regel für einzelne Instruktionen im (LBC)Beweis durchgeführt durch die Bedingung md(pp 0 ) v f t 0 . 76 KAPITEL 4. KORREKTHEIT • Die Vergleiche f t 000 v f t1 mit den in der Menge P zurückgestellten Schachteltypen aus den Bedingungen der Regel (4.12): Dieser Vergleich wird bei bedingten Sprüngen (4.6) durchgeführt und entspricht md(pp 00 ) v f t 0 . • Der Vergleich S(pp 00 ) v f t 0 bei Rückwärtssprüngen in den Regeln (4.14) für bedingte Sprünge wird ebenfalls bei Regel (4.6) durchgeführt. Satz 4.3. Die herkömmliche Bytecode-Verifikation (BV) ist genau dann gültig, wenn folgendes gilt: Es existiert ein Zertifikat cert, so dass sowohl die leichtgewichtige Zertifizierung (LBC) als auch die leichtgewichtige Verifikation (LBV) gültig ist. Beweis. Dies folgt direkt aus den Sätzen 4.1 und 4.2. Kapitel 5 Realisierung Ein Ziel dieser Arbeit ist die Evaluierung des in Kapitel 3 vorgestellten und erweiterten Verfahrens. Als Grundlage der Evaluierung dient ein Prototyp, der im Laufe dieser Diplomarbeit erstellt wurde. Einige interessante Aspekte dieser Implementierung möchte ich in diesem Kapitel ansprechen. Zunächst sollen die benutzten Hilfsmittel – insbesondere die verwendeten Bibliotheken und die Begründung für deren Einsatz – erwähnt werden. 5.1 benutzte Bibliotheken Die erste Phase der leichtgewichtigen Verifikation besteht im wesentlichen aus einer Datenflussanalyse. Ein Programmgerüst für die Analyse von Java-Bytecode-Programmen mittels einer Datenflussanalyse wurde von M. Mohnen unter dem Namen The DataFlow Analysis Framework for Java (jDFA) entwickelt [Mo02] [jDFA]. 5.1.1 jDFA Die Verwendung dieses Programmgerüsts hat den Vorteil, dass sich dadurch der Schwerpunkt der Implementierung der Zertifizierungsphase auf den Verband und die Transferfunktionen verlagert. de.rwth.dfa.jvm de.rwth.dfa de.rwth.domains de.rwth.domains.templates de.rwth.graph Abbildung 5.1: Struktur der jDFA Die jDFA besteht aus fünf Paketen wie in Abbildung 5.1 gezeigt. Ein Paket zur Erstellung von gerichteten Graphen bildet unter anderem die Grundlage für die Re- 77 78 KAPITEL 5. REALISIERUNG präsentation von Programmstrukturen. Dabei können die erzeugten Graphen durch Graphviz [GViz] angezeigt und in verschiedene Grafikformate umgewandelt werden. Das Paket de.rwth.domains dient zur Modellierung mathematischer Strukturen wie Mengen, partielle Ordnungen, Verbände und Funktionen. Durch die Implementierung entsprechender Schnittstellen können Klassen erzeugt werden, die die entsprechenden mathematischen Strukturen darstellen. Ein Nachteil ist hier, dass bestimmte mathematische Bedingungen nicht durch Java-Schnittstellen formuliert werden können. So muss der Programmierer beispielsweise selbst dafür sorgen, dass für einen Verband die Operationen meet und join kommutativ sind. Eine große Hilfe bei der Entwicklung der Verbände ist zum einen eine Methode, die alle benötigten mathematischen Bedingungen für eine konkrete Implementierung prüft. Eine andere Methode erzeugt für eine partielle Ordnung ein Hasse-Diagramm, das mit dem Graphen-Paket der jDFA und Graphviz visualisiert werden kann. Ein weiteres Paket de.rwth.domains.templates hilft bei der Konstruktion von neuen Verbänden aus vorhandenen Verbänden auf die gleiche Art und Weise, wie dies bei der Konzeption durchgeführt wurde. Damit lässt sich aus dem Grundkomponentenverband sehr elegant der Schachteltypverband aufbauen. Der eigentliche Algorithmus zur Datenflussanalyse befindet sich in dem vierten Paket de.rwth.dfa. Dieses Paket baut auf der Graphen-Repräsentation aus dem ersten Paket auf. Ein letztes Paket fügt hierzu eine weitere Abstraktionsebene hinzu. In de.rwth.dfa.jvm existiert dazu eine Schnittstelle Abstraction, durch deren Implementierung der Verband, die Transferfunktionen und die Startwerte der Datenflussanalyse festgelegt werden. Falls Änderungen des Operandenkellers modelliert werden sollen, ist es oft notwendig, dass die Kellergröße für jede Programmstelle der zu untersuchenden Methode bekannt ist. Dafür bietet dieses Paket die Klasse AbstractSSDependingAbstraction an. Wird die Modellierung der abstrakten JVM von dieser Klasse abgeleitet, so erhält man zusätzlich eine Methode, die für jede Programmstelle der zu untersuchenden Methode die Kellergröße liefert. Für den Prototypen ist diese Methode wichtig für die Bestimmung der initialen Schachteltypen. Der erste Schachteltyp hängt von den Parametern der zu untersuchenden Methode ab. Die übrigen Schachteltypen müssen mit >-Elementen initialisiert werden. Dazu werden die Kellergrößen benötigt. Das jDFA-Programmgerüst benutzt für den Zugriff auf Klassendateien die Byte Code Engineering Library (BCEL) [BCEL]. 5.1.2 BCEL Die BCEL liest eine Klassendatei ein und stellt Datenstrukturen zum Zugriff und zur Manipulation aller Aspekte dieser Klassendatei zur Verfügung. Die jDFA benutzt die BCEL zum Einlesen der zu untersuchenden Klassendateien und zum Zugriff auf die einzelnen Instruktionen jeder Methode. Ebenso macht die hier vorgestellte Implementierung Gebrauch von der BCEL. So greifen die Transferfunktionen über entsprechende Aufrufe der BCEL auf den Code-Bereich und den Konstantenbereich der zu untersuchenden Methode zu, um die benötigten Bedingungen für die einzelnen Instruktionen zu prüfen. Beispielsweise wird für die Prüfung der invokevirtual-Instruktion der Konstantenbereich benutzt, um die Typen der formalen Parameter der aufzurufenden Methode zu ermitteln. Diese können dann mit den Typen der aktuellen Parameter auf dem Keller verglichen werden, um somit die Ausführbarkeit der Instruktion zu überprüfen. 5.2. ÜBERBLICK ÜBER DIE IMPLEMENTIERUNG 5.2 79 Überblick über die Implementierung Der Prototyp ist in der Lage, alle Methoden zu zertifizieren, die keine Unterprogrammaufrufe und keinen nicht erreichbaren Bytecode enthalten. Zertifizierung Kompression Überprüfung PCCCertifier PCCCompress PCCChecker Transferfunktionen und Verband PCCAbstraction Abbildung 5.2: Grobübersicht über die Implementierung Die Implementierung besteht im wesentlichen aus drei Teilen (siehe Abbildung 5.2): • Der Zertifizierer wird realisiert durch die Klasse PCCCertifier. Die Kompression des Zertifikats ist eine leicht modifizierte Version des Überprüfers. • Der Überprüfer, der mit Hilfe des Zertifikats Bytcode auf Typsicherheit überprüfen kann (implementiert durch die Klasse PCCChecker ). • Ein zentraler Bestandteil sind die Transferfunktionen und der Verband, der von beiden Programmen benutzt wird (implementiert durch die Klasse PCCAbstraction). Zertifizierung Java−Klasse PCCCertifier PCCCompress Zertifikate Überprüfung Java−Klasse und Zertifikat PCCChecker Methode verifizierbar Ja / Nein Abbildung 5.3: Ablauf der Zertifizierung und der Überprüfung Abbildung 5.3 zeigt den Ablauf der beiden Phasen. Die Zertifizierung wird von der Klasse PCCCertifier durchgeführt und erhält als Eingabe eine Java-Klassendatei. Als Ergebnis wird für jede Methode dieser Klassendatei ein Zertifikat geschrieben, nachdem dieses durch die Klasse PCCCompress komprimiert wurde. Die Überprüfung erhält als Eingabe eines dieser Zertifikate und die Klassendatei. Die Klasse PCCChecker führt dann die Typprüfung für die in dem Zertifikat angegebene Methode durch und entscheidet, ob die Methode erfolgreich überprüft werden kann oder nicht. Es folgt nun ein näherer Blick auf die beiden Programme und deren benutzte Klassen und Schnittstellen. 80 KAPITEL 5. REALISIERUNG 5.2.1 Zertifizierung PCCCertifier PCCCompress Transfer− funktionen AbstractAnalyser Certificate AbstractSSDependingAbstraction PCCAbstraction Abstraction Verband PCCLattice PCCLocalsLattice PCCStackLattice TupleCompleteLattice LiftedCompleteLattice PCCComponentLattice Implementierung Benutzung jDFA Datenflussanalyse Die Datenflussanalyse wird durch die Klasse PCCCertifier durchgeführt. Die Struktur und die Abhängigkeiten zur jDFA-Bibliothek und anderen Klassen sind in Abbildung 5.4 zu sehen. Die Verbindung zur BCEL ist hier nicht visualisiert. CompleteLattice Erweiterung Abbildung 5.4: Struktur der Datenflussanalyse in der Zertifizierungsphase Der Schachteltypverband wird durch die Klasse PCCLattice implementiert und wird entsprechend der Konzeption aus einfacheren Verbänden zusammengesetzt. Dafür bietet die jDFA passende Klassen an, die dafür sorgen, dass die Verküpfungen des Schachteltypverbands korrekt aus den Verknüpfungen der darunterliegenden Verbände berechnet werden. Die Transferfunktionen werden durch die Klasse PCCAbstraction realisiert. Weil diese Klasse von der jDFA-Hilfsklasse AbstractSSDependingAbstraction abgeleitet ist, ist für jede Instruktion die Größe des Operandenkellers bekannt. Somit lassen sich die initialen Schachteltypen – wie in Abschnitt 5.1.1 beschrieben – einfach bestimmen. Zusammen mit dem Verband bildet diese Klasse PCCAbstraction eine abstrakte Interpretation einer Java-Bytecode-Methode. Die eigentliche Datenflussanalyse wird schließlich von der Klasse PCCCertifier durchgeführt. Diese Klasse ist wieder von einer jDFA-Klasse abgeleitet und dient in erster Linie dazu, die benutzten Elemente zu initialisieren und das Ergebnis der Datenflussanalyse zu verarbeiten. Die Datenflussanalyse selbst wird dann von entsprechenden Klassen der jDFA durchgeführt. Die Klasse Certificate erfasst und schreibt schließlich das Zertifikat in eine Datei, das später auch wieder eingelesen werden kann. Die Kompression des Zertifikats wird durch die Klasse PCCCompress durchgeführt und ist sehr ähnlich zu der Überprüfung. Daher werde ich zunächst die Implementierung der Überprüfungsphase beschreiben, die durch die Klasse PCCChecker realisiert ist. 5.2. ÜBERBLICK ÜBER DIE IMPLEMENTIERUNG 5.2.2 81 Die Überprüfungsphase Überprüfung Kern der Überprüfung ist die Klasse PCCChecker. Wie bei der Zertifizierung werden hier ebenfalls die Transferfunktionen aus PCCAbstraction benutzt. PCCChecker Transfer− funktionen PCCAbstraction Verband Certificate PCCLattice Benutzung Abbildung 5.5: Struktur der Überprüfungphase Das Schaubild 5.5 ist sehr ähnlich zu dem der Datenflussanalyse. Statt der Datenflussanalyse wird hier jedoch der Überprüfungsalgorithmus aus Kapitel 3.3.1 angewendet. Die Abhängigkeiten zur jDFA wurden in dem Schaubild weggelassen. Dabei benötigt die Klasse PCCChecker die jDFA nicht direkt, sondern nur indirekt über die Klasse PCCAbstraction. Es werden die gleichen Transferfunktionen benutzt, wie bei der Datenflussanalyse. Der Verband spielt hier auch eine Rolle; hier wird aber nur die partielle Ordnung des Verbands benötigt, um die einzelnen Elemente des Verbands vergleichen zu können. Die meet-Operation wird ausschließlich von der Datenflussanalyse benutzt. Diese Tatsache könnte später dazu benutzt werden, die Implementierung der Überprüfungsphase zu optimieren und die Abhängigkeit zur jDFA zu entfernen. Dieser Überprüfungsalgorithmus wurde nun so erweitert, dass er Zertifikate minimieren kann. Dies ist notwendig für den zweiten Teil der ersten Phase, um das komprimierte Zertifikat zu erstellen. 5.2.3 Die Kompression des Zertifikats Die Datenflussanalyse berechnet für jede Programmstelle einen Schachteltyp. Diese Informationen werden in einem vollständigen Zertifikat gespeichert und können statt des normalen komprimierten Zertifikats vom erweiterten Überprüfer PCCCompress benutzt werden. Das vollständige Zertifikat enthält also alle Schachteltypen aus der Datenflussanalyse und Markierungen für jede Programmstelle. Die Erweiterungen in PCCCompress bestehen nun darin, dass nur die Informationen aus dem vollständigen Zertifikat in das komprimierte Zertifikat kopiert werden, die während der Überprüfung benötigt und benutzt werden. Für die Schachteltypen bedeutet dies, dass nur die die Schachteltypen übertragen werden, die sich auch tatsächlich von den Schachteltypen unterscheiden, die bei dem sequentiellen Durchlauf durch den 82 KAPITEL 5. REALISIERUNG Überprüfung Kompression Bytecode bestimmt werden. Wie in Kapitel 3.2.2 beschrieben, wird hier nur die Differenz dieser beiden unterschiedlichen Schachteltypen in dem komprimierten Zertifikat gespeichert. Die Markierungen, die ja Programmstellen bezeichnen, deren Schachteltyp während der Überprüfung zwischengespeichert werden muss, werden ähnlich behandelt. Dadurch, dass in dem vollständigen Zertifikat jede Programmstelle markiert ist, werden auch alle Programmstellen mit ihren Schachteltypen zwischengespeichert. Nur wenn die Schachteltypen dieser Programmstellen im weiteren Verlauf des Algorithmus wieder benötigt werden, wird diese Markierung in das komprimierte Zertifikat übertragen. Somit erweitert PCCCompress den Überprüfungsalgorithmus an zwei Stellen: Zum einen bei Benutzung des Zertifikats und zum anderen bei der Verwendung der gespeicherten Schachteltypen. Abbildung 5.6 zeigt ein Schema des erweiterten Überprüfungsalgorithmus PCCCompress. PCCCompress PCCChecker FullCertificate Benutzung Certificate Erweiterung Abbildung 5.6: Struktur der Kompression des Zertifikats An dieser Stelle ist anzumerken, dass der Überprüfungsalgorithmus PCCChecker unabhängig von der Klasse PCCCompress die vollständigen Zertifikate aus der Klasse FullCertificate benutzen kann, da diese von Certificate abgeleitet sind. Was konkret als Eingabe für PCCChecker benutzt wird, wird bei der Erzeugung einer Instanz dieser Klasse bestimmt. 5.3 Einige interessante Aspekte In diesem Abschnitt möchte ich einige erwähnenswerte Aspekte der Implementierung herausstellen. 5.3.1 Verband Das Zertifizierungsprogramm kann dank einer entsprechenden jDFA-Methode neben dem komprimierten Zertifikat auch ein Hasse-Diagramm des benutzten Grundkomponentenverbands im Graphviz-Format erzeugen. Diese Diagramme waren zum einen für die Fehlersuche sehr hilfreich, aber auch fast alle in dieser Arbeit abgebildete Verbände wurden mit dieser Methode erzeugt. 5.3. EINIGE INTERESSANTE ASPEKTE 83 Die Elemente des Grundkomponentenverbands werden während der Datenflussanalyse bzw. während der Überprüfungsphase dynamisch hinzugefügt. Somit enthält der Verband immer nur die Elemente, die auch tatsächlich verwendet werden. Werden beispielsweise keine float-Typen in einem Java-Bytecode-Programm verwendet, so treten diese auch nicht in dem zugehörigen Verband auf. Dieses dynamische Hinzufügen ist deshalb möglich, weil der Schachteltypverband nur als Rechenvorschrift unter Anwendung dieses Grundkomponentenverbands implementiert ist. Diese Rechenvorschrift ist immer gleich und gilt für jeden möglichen Verband. Variabel sind nur die Grundkomponenten. Die Grundkomponenten werden in den Konstruktoren der Transferfunktionen bei Bedarf dem Verband hinzugefügt. Die Instanziierung dieser Funktionen geschieht bei der Datenflussanalyse vor Beginn einmalig für jede Instruktion der Methode. Dies bedeutet, dass während der Zertifizierungsphase für jede Programmstelle ein solches Objekt existiert. Bei der Überprüfung hingegen existiert immer nur ein Objekt einer Transferfunktion. Und zwar genau für die Instruktion, die gerade überprüft wird. 5.3.2 Zertifikate Die Klasse Certificate erlaubt die Erstellung und den Zugriff auf die Zertifikate. Diese Klasse enthält eine Methode, die ein textuelle Repräsentation des Zertifikats erzeugt. Diese textuelle Repräsentation wird in eine Datei geschrieben und bildet so das Zertifikat, das später von dem Überprüfer wieder eingelesen werden kann. Dazu existiert ebenfalls in dieser Klasse eine entsprechende Methode, die aus der Datei wieder ein Objekt der Klasse Certificate erzeugen kann. Beispielsweise sieht das Zertifikat für das zweite Beispiel aus Kapitel 3.3.3 folgendermaßen aus: class = A method = void test(int arg1) maxstack = 1 maxlocals = 3 labels = {11} types = {*bot*} 11; (2, 0) Der Kopf des Zertifikats enthält den Namen der Klasse, den Methodennamen mit den entsprechenden Parametern, die Kellergröße und die Anzahl der lokalen Variablen. Unter labels sind die markierten Programmstellen aufgelistet. Zu diesen Programmstellen muss der Überprüfungsalgorithmus während der Überprüfung Schachteltypen speichern. Es folgen unter types die in diesem Zertifikat verwendeten Typen. Damit die künstlichen Typen von den realen Typen der JVM unterschieden werden können, werden die künstlichen Typen um Zeichen erweitert, die in Bezeichnern nicht auftreten dürfen. Daher ist der Typ *bot*, der für das ⊥-Element steht, durch Sterne eingeschlossen. Diese Typen aus der types-Liste werden von den eigentlichen Einträgen indiziert. Diese Einträge bestehen aus einer Programmstelle gefolgt von einem oder mehreren Zahlenpaaren. Diese Zahlenpaare bezeichnen die Differenz zu dem Schachteltyp, der bei dem sequentiellen Durchlauf durch den Bytecode ermittelt wurde. In dem Beispiel bedeutet der Eintrag 11; (2, 0), dass an Programmstelle 11 die zweite lokale Variable das ⊥-Element enthält. 84 5.3.3 KAPITEL 5. REALISIERUNG Transferfunktionen Zur einfacheren Implementierung der Transferfunktionen wurden noch weitere künstliche Typen in den Verband eingefügt. Beispielsweise gibt es einen Typ, der kleiner als jede Reihung ist. Damit lässt sich sehr elegant prüfen, ob ein bestimmter Typ eine Reihung ist. Abbildung 5.7 zeigt ein einfaches Beispiel dazu. Um zu prüfen, ob ein Typ eine Reihung ist, wird einfach die auf dem Verband induzierte partielle Ordnung verwendet. Gilt für einen Typ t die Relation *array* v t, so ist t als Reihung anzusehen. Dieser Vergleich wird für die Instruktionen ARRAYLENGTH, AALOAD und AASTORE benötigt. *top* boolean[] int[] char[] byte[] *array* short[] int java.lang.Object *ref* *bot* Abbildung 5.7: Beispiel für weitere künstliche Typen in dem Grundkomponentenverband 5.4 Schlussfolgerung Dadurch dass in den beiden Phasen die gleichen Klassen für den Verband und die Transferfunktionen benutzt werden, lässt sich die Überprüfungsphase noch hinsichtlich der Programmgröße optimieren, da in dieser Phase nicht alle Methoden benutzt werden, die auch von der Datenflussanalyse benötigt werden. So lassen sich die Abhängigkeiten zur jDFA auflösen. Desweiteren wurde nicht an jeder Stelle auf möglichst kompakte und geschwindigkeitsoptimierte Umsetzung des Überprüfungsalgorithmus geachtet. Für den praktischen Einsatz auf einer Java Card sollte auch die Abhängigkeit zur BCEL aufgelöst werden. Dazu sind die Transferfunktionen entsprechend zu ändern, so dass diese nicht mehr den Konstantenbereich und die Operanden der einzelnen Instruktionen über die BCEL ermitteln. Trotzdem zeigt der Prototyp, dass das Konzept der leichtgewichtigen Verifikation erweiterbar und praktisch umsetzbar ist. Auch zur Evaluierung des Speicherplatzbedarfs eignet sich dieser Prototyp sehr gut. Kapitel 6 Evaluierung Im folgenden werde ich die praktischen Ergebnisse der Evaluierung vorstellen. Als Grundlage diente der Prototyp, der während dieser Diplomarbeit entwickelt wurde. 6.1 Ziele der Evaluierung Die beiden zentralen Aspekte bei dieser Evaluierung sind zum einen die Größe der Zertifikate und zum anderen der benötigte Speicherbedarf während der Überprüfung. Der Aufwand bei der Erstellung der Zertifikate ist von geringerer Bedeutung, weil dieser Teil nicht auf einer Java Card ablaufen soll und daher unkritisch ist. Folgende Größen sollen gemessen werden: • Die Größe der Zertifikate • Der Speicherplatzbedarf für die Speicherung der Schachteltypen während der Überprüfungsphase • Die Anzahl der Methoden, die vom Prototypen nicht zertifiziert werden können aufgrund von fehlenden Erweiterungen Bevor ich auf die zu untersuchenden Klassen eingehe, soll zunächst ein Maß für die Größe der Zertifikate und für den Speicherplatzbedarf während der Überprüfung festgelegt werden. 6.1.1 Die Größe der Zertifikate Die Zertifikate bestehen im wesentlichen aus den Markierungen für die zu speichernden Schachteltypen und den Einträgen für die Typanpassungen. Diese Einträge enthalten eine Programmstelle und eine bestimmte Anzahl von Indizes. Ein Maß für die Größe des Zertifikats wäre somit die Anzahl dieser Zahlen, die als Worte anzusehen sind. Um die Größe der Zertifikate mit der Bytecode-Größe vergleichen zu können, sollte das Zertifikat in Anzahl Bytes angegeben werden. Beispielsweise hat das Zertifikat aus Kapitel 5.3.2 nach dieser Rechnung die Größe 8 Byte (eine Markierung und ein Schachteltypeintrag bestehend aus drei Worten). 6.1.2 Speicherplatzbedarf während der Überprüfung Der Überprüfungsalgorithmus muss Schachteltypen zwischenspeichern. Diese Schachteltypen machen den wesentlichen Speicherplatzbedarf während der Überprüfung aus. Die Gesamtzahl der zu speichernden Schachteltypen ergibt sich aus der Summe der 85 86 KAPITEL 6. EVALUIERUNG gemerkten, der zurückgestellten und dem aktuellen Schachteltyp. Da einmal gemerkte Schachteltypen während der gesamten Überprüfungsphase gespeichert bleiben, entspricht die Anzahl der Markierungen im Zertifikat genau der Maximalzahl gemerkter Schachteltypen. Die Menge der zurückgestellten Schachteltypen ist dynamischer und hängt ausschließlich von der Komplexität des Bytecode ab. Da die Größe eines Schachteltyps von Methode zu Methode unterschiedlich sein kann, ist es sinnvoll, die Anzahl der Grundkomponenten zu zählen. Dies geschieht einfach durch Multiplikation der Schachteltypanzahl mit der Schachteltypgröße, die sich aus der Summe von maximaler Kellergröße und Anzahl lokaler Variablen ergibt. Dies ist eine pessimistische Abschätzung, da der Keller nicht an jeder Programmstelle vollständig gefüllt ist. 6.1.3 untersuchte Klassen Um sich ein genaues Bild von dem praktischen Nutzen der leichtgewichtigen Verfikation zu machen, habe ich mehrere Bibliotheken unterschiedlicher Größe untersucht. Die größte Bibliothek ist die java.*-Hierarchie mit über 15000 Methoden aus der JavaLaufzeitbibliothek in der Version 1.4.0 01 [JDK]. Die anderen untersuchten Bibliotheken sind wesentlich kleiner. Die jDFA und BCEL wurden bereits bei der Implementierung des Prototyps verwendet und bieten sich hier als kleine und mittelgroße Bibliotheken an. Schließlich wurden noch die Beispiele aus dem Java Card Development Kit Version 2.2.01 [JCDK] betrachtet, um das Speicherplatzverhalten der leichtgewichtigen Verifikation an konkreten Beispielen für Java Cards zu untersuchen. Es folgt eine Übersicht über die Anzahl der Klassen und Methoden der untersuchten Bibliotheken: Bibliothek java.* Hierarchie BCEL jDFA Java Card Beispiele Summe 6.2 Anzahl Klassen 1742 299 142 24 2207 Anzahl Methoden 15221 2009 684 137 18051 Resultate Im Gegensatz zu den anderen Bibliotheken konnten bei der Java-Laufzeitbibliothek 61 Methoden nicht erfolgreich zertifiziert werden. Dazu gibt es zwei Gründe: 1. 44 Methoden enthalten Unterprogrammaufrufe (jsr-Instruktionen). Diese werden von dem Prototypen nicht unterstützt. 2. 17 Methoden enthalten nicht erreichbaren Code. Solche Methoden lassen sich mit dem Überprüfungsalgorithmus nicht verifizieren. Dies ließe sich zwar relativ einfach nachrüsten, aber man muss sich dann auch die Frage stellen, ob es sinnvoll ist, nicht erreichbaren Code zuzulassen, wenn das Ziel Speicherplatzeffizienz ist. Es ist Aufgabe des Java-Übersetzers, nicht erreichbaren Code zu vermeiden. Somit gibt es hier offenbar Schwächen im Java-Übersetzer von Sun. Es fällt auf, dass Unterprogrammaufrufe tatsächlich nur sehr selten auftreten. Bezogen auf die Gesamtzahl der hier untersuchten Methoden enthalten nur 0,2 % der Methoden jsr-Instruktionen. Diese Unterprogramme werden von dem Sun-Java-Übersetzer ausschließlich für finally-Blöcke als Teil von Ausnahmebehandlungen erzeugt. 6.2. RESULTATE 87 Werden alle jsr-Instruktionen durch die zugehörigen Unterprogramme ersetzt, lassen sich derartige Methoden ebenfalls zertifizieren mit dem Nachteil, dass der Bytecode etwas größer wird. Zunächst werde ich einen Überblick über die Verteilung der Methodengrößen der einzelnen Bibliotheken geben. 6.2.1 Methodengröße Um sich eine genauere Übersicht über die betrachteten Bibliotheken zu machen, ist ein Blick auf die Größe der Methoden hilfreich. Es fällt auf, dass es beispielsweise in der BCEL viele Methoden gibt, die ein Byte groß sind und somit nur aus einer Rücksprunginstruktion bestehen. Diese leeren Methoden gehören größtenteils zu abstrakten Klassen und haben einen großen Einfluss auf die durchschnittliche Methodengröße der BCEL. In der folgenden Tabelle sind die Größen der längsten Methode und die durchschnittliche Methodengröße für die einzelnen Bibliotheken angegeben: Bibliothek java.* Hierarchie BCEL jDFA Java Card Beispiele größte Methode in Bytes 5901 8936 974 487 durchschnittliche Methodengröße in Bytes 49,6 40,3 52,2 54,2 Im Durchschnitt liegt die Methodengröße bei ungefähr 50 Bytes. Dies bedeutet, dass die sehr großen Methoden eher selten sind. Dies wird unterstrichen durch Abbildung 6.1, in der die Verteilung der Methodengrößen für die Bibliotheken abgebildet ist. Man beachte, dass beide Achsen logarithmisch skaliert sind und daher der falsche Eindruck erweckt wird, dass große Methoden doch nicht so selten sind. Es folgt nun die Auswertung der Zertifikatgrößen. 6.2.2 Größe der Zertifikate Ein wichtiger Aspekt des Speicherplatzbedarfs der leichtgewichtigen Verifikation ist die Zertifikatgröße. Diese Zertifikate werden zusammen mit dem Bytecode auf die Zielmaschine übertragen, wo dann die Typüberprüfung unter Zuhilfenahme der Zertifikate mit geringen Aufwand durchgeführt werden kann. Somit bilden die Zertifikate zusammen mit dem Bytecode eine Einheit und vergrößern quasi den Bytecode. In diesem Abschnitt soll die Größe der Zertifikate betrachtet werden. Wie zuvor beschrieben, werden die Zertifikate in Bytes angegeben. Abbildung 6.2 zeigt die Größenverteilung der einzelnen Zertifikate. In den Diagrammen besitzt die Ordinate eine logarithmische Skalierung. Auffallend ist hier, dass die meisten Methoden ein leeres Zertifikat besitzen. Dies wird auch durch die Durchschnittswerte bestätigt, die neben den Maximalgrößen in der folgenden Tabelle aufgelistet sind: Bibliothek java.* Hierarchie BCEL jDFA Java Card Beispiele größtes Zertifikat in Bytes 310 74 54 10 durchschnittliche Zertifikatgröße in Bytes 2,1 1,0 1,8 0,8 88 KAPITEL 6. EVALUIERUNG 10000 1000 Anzahl Methoden Anzahl Methoden 1000 100 100 10 10 1 1 1 10 100 Methodengröße in Bytes 1000 10000 1 10 (a) java.* Hierarchie 1000 10000 1000 10000 (b) BCEL 10 Anzahl Methoden 100 Anzahl Methoden 100 Methodengröße in Bytes 10 1 1 1 10 100 Methodengröße in Bytes (c) jDFA 1000 10000 1 10 100 Methodengröße in Bytes (d) Java Card Beispiele Abbildung 6.1: Übersicht über die Methodengröße der Bibliotheken 6.2. RESULTATE 89 100000 10000 10000 Anzahl Methoden Anzahl Methoden 1000 1000 100 100 10 10 1 1 0 50 100 150 200 Zertifikatgröße in Bytes 250 300 350 0 10 20 60 70 80 (b) BCEL 1000 1000 100 100 Anzahl Methoden Anzahl Methoden (a) java.* Hierarchie 30 40 50 Zertifikatgröße in Bytes 10 1 10 1 0 10 20 30 40 Zertifikatgröße in Bytes (c) jDFA 50 60 0 2 4 6 Zertifikatgröße in Bytes (d) Java Card Beispiele Abbildung 6.2: Übersicht über die Größe der Zertifikate 8 10 90 KAPITEL 6. EVALUIERUNG Das durchschnittliche Zertifikat hat somit eine Größe von etwa zwei Byte. Das größte Zertifikat von 310 Byte wird durch eine Methode erzeugt, die selbst 2450 Byte groß ist. Dieses Zertifikat ist in Abbildung 6.3 zu sehen. class = java.security.cert.X509CertSelector method = public boolean match(java.security.cert.Certificate arg1) maxstack = 4 maxlocals = 14 labels = {314, 331, 418, 556, 636, 912, 1041, 1143, 1183, 1321, 1414, 1424, 1427, 1445, 1501, 1592, 1687, 1724, 1727, 1843, 2042, 2052, 2222, 2298, 2311} types = {*bot*, java.lang.Object} 314; (3, 0) 418; (3, 0) 435; (3, 0, 4, 0, 5, 0) 554; (6, 0) 556; (6, 1) 599; (5, 0, 6, 0) 634; (7, 0) 636; (7, 1) 679; (6, 0, 7, 0) 767; (3, 0) 912; (3, 0, 4, 0) 930; (3, 0, 4, 0, 5, 0) 975; (3, 0) 1051; (3, 0, 4, 0) 1180; (4, 0, 5, 0, 6, 0) 1183; (3, 0, 4, 0, 5, 0, 6, 0) 1201; (3, 0) 1427; (3, 0) 1724; (7, 0) 1727; (3, 0) 1745; (3, 0, 4, 0, 5, 0, 6, 0) 1853; (4, 0) 2062; (6, 0) 2308; (4, 0, 5, 0, 6, 0) 2311; (3, 0, 4, 0, 5, 0, 6, 0, 7, 0, 8, 0, 9, 0, 10, 0, 11, 0) 2433; (3, 0) Abbildung 6.3: Das größte Zertifikat für die Methode match aus der Klasse java.security.cert.X509CertSelector Ein Grund für dieses große Zertifikat ist zum einen die Größe der Methode an sich und zum anderen die umfangreiche Ausnahmentabelle, die mit 16 Einträgen recht groß ist. Weiterhin gibt es sehr viele Sprünge und switch-Instruktionen. Dies führt dazu, dass es viele Programmstellen gibt, die mehr als eine Vorgängerinstruktion besitzen. Dadurch steigt die Wahrscheinlichkeit, dass sich bei der Verknüpfung der Schachteltypen der Vorgängerinstruktionen ein anderer Schachteltyp ergibt als beim sequentiellen Durchlauf durch den Bytecode. Dies führt dann zu einem Zertifikateintrag. Vergrößerung des Bytecode Wie eingangs erwähnt, kann man die Zertifikate als Vergrößerung des Bytecode ansehen. Daher habe ich die Zertifikatgrößen in Relation zu den Methodengrößen gesetzt. Aus dieser Sicht betrachtet vergrößert das Zertifikat aus Abbildung 6.3 den Bytecode um 12 %. In Abbildung 6.4 ist die Verteilung der prozentualen Bytecode-Vergrößerung der untersuchten Methoden zu sehen. Eine Wert von 0 % bedeutet, dass das Zertifikat leer ist. Ein Wert von 100 % würde bedeuten, dass das Zertifikat genauso groß ist, wie der Bytecode selbst. Auch hier besitzt die Ordinate wieder eine logarithmische Einteilung. An diesen Diagrammen ist recht gut zu sehen, dass die meisten Zertifikate leer sind. Die Durchschnittswerte der folgenden Tabelle zeigen, dass die Zertifikate insgesamt sehr klein sind: 6.2. RESULTATE 91 100000 10000 10000 Anzahl Methoden Anzahl Methoden 1000 1000 100 100 10 10 1 1 0 20 40 60 80 100 Speicherplatzmehrbedarf für die Zertifikate relativ zur Bytecode−Größe in Prozent 0 (b) BCEL 1000 1000 100 100 Anzahl Methoden Anzahl Methoden (a) java.* Hierarchie 10 1 20 40 60 80 100 Speicherplatzmehrbedarf für die Zertifikate relativ zur Bytecode−Größe in Prozent 10 1 0 20 40 60 80 100 Speicherplatzmehrbedarf für die Zertifikate relativ zur Bytecode−Größe in Prozent (c) jDFA 0 20 40 60 80 100 Speicherplatzmehrbedarf für die Zertifikate relativ zur Bytecode−Größe in Prozent (d) Java Card Beispiele Abbildung 6.4: Übersicht über die relative Vergrößerung des Bytecode durch die Zertifikate 92 KAPITEL 6. EVALUIERUNG Bibliothek java.* Hierarchie BCEL jDFA Java Card Beispiele maximale Vergrößerung 77 % 44 % 50 % 14 % durchschnittliche Vergrößerung 2,3 % 1,0 % 1,9 % 0,8 % Eine Vergrößerung um 77 % scheint auf den ersten Blick recht problematisch zu sein. Das entsprechende Zertifikat der Methode write aus der Klasse java.io.PrintWriter hat folgendes Aussehen: class = java.io.PrintWriter method = public void write(int arg1) maxstack = 2 maxlocals = 4 labels = {24, 42, 43, 48} types = {*bot*} 32; (2, 0, 3, 0) 42; (2, 0, 3, 0) 48; (2, 0) Dieses Zertifikat besteht aus 17 Worten und zählt somit 34 Bytes. Die Methode selbst besteht aus nur 44 Bytes. Allgemein lässt sich sagen, dass die Methoden, die ein relativ großes Zertifikat benötigen, selbst sehr klein sind. Betrachtet man nur die Java Card Beispiele, so gehört das relativ zum Bytecode größte Zertifikat zu einer 14 Byte großen Methode, dessen Zertifikat nur aus einer markierten Programmstelle besteht und somit lediglich 2 Bytes umfasst (14 % von 14 Bytes). Im Durchschnitt liegt die Vergrößerung der Zertifikate bei etwa 2 %. Dies ist überraschend, weil andere auf Proof-Carrying Code basierende Ansätze wesentlich größere Zertifikate benötigen. Einer dieser Ansätze ist in der K Virtual Machine (KVM) der Connected Limited Device Configuration [CLDC] implementiert. Nach X. Leroy [Le02] vergrößern die Zertifikate des KVM-Verifizierers den Bytecode um durchschnittlich 50 %. Desweiteren vermutet Leroy, dass die Zertifikate sich nicht auf unter 20 % der Bytecode-Größe komprimieren lassen. Als Maß für die Zertifikatgröße dient für die KVM-Zertifikate ein Byte für Basistypen und drei Bytes für Klassentypen. Selbst wenn dieses Maß auf die hier vorgestellten Zertifikate angewendet wird, bleibt die durchschnittliche Vergrößerung auf jeden Fall unter 4 %. Vergleich mit KVM-Zertifikaten Um einen Vergleich mit den Zertifikatgrößen des KVM-Verifzierers durchführen zu können, wurde für jede untersuchte Methode die zugehörige KVM-Zertifkatgröße bestimmt. Nach CLDC-Spezifikation enthalten die KVM-Zertifikate vollständige Schachteltypeinträge für alle Programmstellen, deren Instruktion mindestens eine der folgenden Eigenschaften besitzt: • Der Vorgänger ist eine goto-, return-, athrow- oder switch-Instruktionen. Dabei bezieht sich Vorgänger“ auf die Reihenfolge im Bytecode und nicht auf den ” Kontrollfluss. • Die Instruktion bildet den Anfang einer Ausnahmebehandlung. • Die Instruktion ist Sprungziel einer goto-, switch- oder if-Instruktion. 6.2. RESULTATE 93 Die folgende Tabelle zeigt die Vergrößerung des Bytecodes durch die KVM-Zertifikate. Zur Vereinfachung werden hier als Platzbedarf für jeden Grundkomponententyp 2 Bytes angesetzt. Die Schachteltypgröße in Bytes ergibt sich daher aus der Anzahl der Grundkomponenten multipliziert mit 2. Die Anzahl der Grundkomponenten wiederum ist die Summe aus maximaler Kellertiefe und Anzahl lokaler Variablen. Bibliothek java.* Hierarchie BCEL jDFA Java Card Beispiele maximale Vergrößerung 616 % 233 % 245 % 128 % durchschnittliche Vergrößerung 34,5 % 16,9 % 30,7 % 32,2 % Die durchschnittliche Zertifikatvergrößerung der BCEL profitiert wieder von der relativ großen Anzahl leerer Methoden. Dies täuscht aber nicht darüber hinweg, dass viele Zertifikate den Bytecode spürbar vergrößern. Einige Zertifikate sind sogar so groß, dass sie den Bytecode um mehr als 5 Kilobyte vergrößern. Dieser Größenunterschied zwischen den KVM-Zertifikaten und den Zertifikaten der leichtgewichtigen Verifikation hängt damit zusammen, dass die leichtgewichtige Verifikation die Zertifikate intelligenter erzeugt. Informationen, die während der Überprüfung relativ leicht ermittelt werden können, werden nicht in den Zertifikaten gespeichert. Dafür ist der Aufwand bei der Überprüfung größer. Der Vorteil bei dem KVM-Verifizierer ist der, dass während der Überprüfung nur ein einziger Schachteltyp gespeichert werden muss unabhängig von der Struktur der Methode. Der Speicherplatzbedarf der leichtgewichtigen Verifikation hingegen hängt von der Bytecode-Struktur ab und ist in der Regel größer. Bewertung Da die Zertifikate auf einer Java Card genau wie der Bytecode nur gelesen werden, brauchen die Zertifikate nicht in dem sehr kleinen RAM-Bereich gespeichert zu werden. Die Untersuchung der Zertifikatgrößen der leichtgewichtigen Verifikation hat gezeigt, dass selbst die großen Zertifikate kein Problem darstellen. Wenn man nur die Java Card Beispiele betrachtet, ist die Größe der Zertifikate sogar vernachlässigbar gering. Alles in allem sind somit die Zertifikate sehr klein und bedeuten kein Problem im Hinblick auf den Speicherplatzmehrbedarf. 6.2.3 Speicherplatzbedarf während der Überprüfung Während der Überprüfung müssen Schachteltypen zwischengespeichert werden. Dies sind zum einen die Schachteltypen der durch das Zertifikat markierten Programmstellen und zum anderen die Schachteltypen, die aufgrund von Sprüngen in noch nicht geprüfte Bereiche zurückgestellt werden müssen. Diese Schachteltypen machen den wesentlichen Teil des Speichers aus, der durch die Überprüfungsphase benötigten wird. Im folgenden wird unter der Anzahl Schachteltypen die Maximalzahl der gleichzeitig im Speicher gehaltenen Schachteltypen verstanden. Dies ist die Summe aus den gespeicherter Schachteltypen, der maximal gleichzeitig zurückgestellten Schachteltypen und dem aktuellen Schachteltyp. Da immer ein aktueller Schachteltyp gespeichert werden muss, ist die Gesamtzahl der zu speichernden Schachteltypen immer größer als Null. Für die zu speichernden Schachteltypen ergeben sich für die betrachteten Bibliotheken folgende Maximal- und Durchschnittswerte: 94 KAPITEL 6. EVALUIERUNG Bibliothek java.* Hierarchie BCEL jDFA Java Card Beispiele maximal zu speichernde Anzahl Schachteltypen 131 39 19 15 durchschnittlich zu speichernde Anzahl Schachteltypen 2,3 1,7 2,3 2,3 Dass eine Methode 131 Schachteltypen speichern muss, liegt an einer lookupswitchInstruktion mit 130 Einträgen. Hier müssen somit 130 sekundäre Sprungstellen mit identischen Schachteltypen zurückgestellt werden. Diese identischen Schachteltypen werden nicht nur bei switch-Instruktionen gespeichert, sondern auch bei Instruktionen, die durch mehrere Ausnahmen geschützt sind. Somit lassen sich diese identischen Schachteltypen zumindest bei diesen Instruktionen leicht identifizieren. Werden nur die paarweise verschiedenen Schachteltypen betrachtet, so sind für diese Methode nur 3 Schachteltypen zu speichern. Um sich ein Bild von den Möglichkeiten dieser simplen Kompression zu machen, habe ich die Bibliotheken noch einmal im Hinblick auf die paarweise verschiedenen Schachteltypen untersucht. Es ergibt sich folgende Tabelle: Bibliothek java.* Hierarchie BCEL jDFA Java Card Beispiele maximal zu speichernde Anzahl Schachteltypen (paarweise verschieden) 38 13 13 6 durchschnittlich zu speichernde Anzahl Schachteltypen (paarweise verschieden) 1,8 1,4 1,7 1,6 Im Vergleich zu der letzten Tabelle ist zu erkennen, dass nicht wenige der zu speichernden Schachteltypen identisch sind. Auch die nicht identischen Schachteltypen haben eine große Ähnlichkeit. In Abbildung 6.5 sind die 38 zu speichernden Schachteltypen für die Methode coerceData aus der Klasse java.awt.image.ComponentColorModel angegeben. Die ersten 6 lokalen Variablen sind in allen Schachteltypen identisch und wurden durch einen Platzhalter ersetzt. Weiterhin fällt auf, dass der Keller für jeden dieser Schachteltypen leer ist. Somit liegt die Vermutung nahe, dass sich diese Schachteltypen komprimieren lassen und so der Speicherplatzbedarf noch weiter gesenkt werden kann. Beispielsweise könnte im Zertifikat angegeben werden, wie groß das gemeinsame Präfix ist und wieviele Kellerelemente die zu speichernden Schachteltypen maximal besitzen. Dies würde die sehr kleinen Zertifikate um zwei Worte vergrößern, den Speicherplatzbedarf aber merkbar reduzieren. Die Methode coerceData besitzt 18 lokale Variablen und eine Kellergröße von 8. Mit der Präfixgröße von 6 und dem leeren Operandenkeller ergibt sich ein Speicherplatzreduzierung um 54 % (von 26 Worten auf 12 Worte). Dass die meisten Methoden wesentlich weniger Schachteltypen speichern müssen, zeigt die Abbildung 6.6. Hier ist die Verteilung zu sehen, wieviele Methoden welche Anzahl paarweise verschiedener Schachteltypen speichern müssen. Anzahl Grundkomponenten Da die Größe der Schachteltypen von der Anzahl der lokalen Variablen und der maximalen Tiefe des Operandenkellers abhängt und diese Größen von Methode zu Methode 6.2. RESULTATE ((..., ((..., ((..., ((..., ((..., ((..., ((..., ((..., ((..., ((..., ((..., ((..., ((..., ((..., ((..., ((..., ((..., ((..., ((..., ((..., ((..., ((..., ((..., ((..., ((..., ((..., ((..., ((..., ((..., ((..., ((..., ((..., ((..., ((..., ((..., ((..., ((..., *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, float, float, float, float, float, float, float, float, float, float, float, float, float, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, 95 *array*, byte[], byte[], double[], double[], float[], float[], int[], int[], short[], short[], byte[], byte[], double[], double[], double[], double[], double[], float[], float[], int[], int[], short[], short[], byte[], byte[], byte[], float[], float[], float[], float[], int[], int[], int[], short[], short[], short[], *bot*, byte[], float, double[], int, float[], int, float, int[], float, short[], byte[], float, double[], double[], double[], int, int, float[], int, float, int[], float, short[], byte[], byte[], float, float[], float[], int, int, float, int[], int[], float, short[], short[], *bot*, float, int, int, *bot*, int, *bot*, int, float, int, float, float, int, int, int, int, int, int, int, int, int, float, int, float, float, float, int, int, int, int, int, int, float, float, int, float, float, *bot*, int, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, int, *bot*, int, int, int, int, int, int, *bot*, double, int, *bot*, int, int, int, int, int, int, int, int, int, *bot*, float, int, int, int, int, int, int, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, int, *bot*, *bot*, double, double, *bot*, *double2*, *bot*, *bot*, *bot*, int, *bot*, int, int, int, float, *bot*, int, *bot*, int, float, int, int, float, int, int, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *double2*, *double2*, *bot*, double, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, int, int, *bot*, *bot*, *bot*, *bot*, int, *bot*, int, int, *bot*, int, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, int, *bot*, *double2*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*, *bot*), *bot*), *bot*), *bot*), *bot*), *bot*), *bot*), *bot*), *bot*), *bot*), *bot*), *bot*), *bot*), *bot*), *bot*), *bot*), *bot*), int), *bot*), *bot*), *bot*), *bot*), *bot*), *bot*), *bot*), *bot*), *bot*), *bot*), *bot*), *bot*), *bot*), *bot*), *bot*), *bot*), *bot*), *bot*), *bot*), []) []) []) []) []) []) []) []) []) []) []) []) []) []) []) []) []) []) []) []) []) []) []) []) []) []) []) []) []) []) []) []) []) []) []) []) []) ...“ steht für die folgenden 6 Typen: ” java.awt.image.ComponentColorModel, java.awt.image.WritableRaster, int, int, int, int Der Keller ist zwischen den eckigen Klammern angegeben. Abbildung 6.5: Liste der paarweise verschiedenen Schachteltypen, die während der Überprüfung der Methode coerceData aus der Klasse java.awt.image.ComponentColorModel gespeichert werden müssen KAPITEL 6. EVALUIERUNG 10000 10000 1000 1000 Anzahl Methoden Anzahl Methoden 96 100 10 100 10 1 1 0 5 10 15 20 25 30 Anzahl zu speichernde Schachteltypen 35 40 0 5 10 (a) java.* Hierarchie 35 40 35 40 (b) BCEL 1000 100 100 Anzahl Methoden Anzahl Methoden 15 20 25 30 Anzahl zu speichernde Schachteltypen 10 10 1 1 0 5 10 15 20 25 30 Anzahl zu speichernde Schachteltypen (c) jDFA 35 40 0 5 10 15 20 25 30 Anzahl zu speichernde Schachteltypen (d) Java Card Beispiele Abbildung 6.6: Übersicht über die Anzahl der während der Überprüfung zu speichernden (paarweise verschiedenen) Schachteltypen 6.2. RESULTATE 97 unterschiedlich sind, sollen nun die Anzahl der Grundkomponenten dieser paarweise verschiedenen Schachteltypen gezählt werden. Dies dürfte eine gute Näherung für den tatsächlichen Speicherplatzbedarf während der Überprüfungsphase sein. Das Ergebnis dieser Untersuchung zeigt folgende Tabelle: Bibliothek java.* Hierarchie BCEL jDFA Java Card Beispiele maximal zu speichernde Anzahl Grundkomponenten 1176 420 280 102 durchschnittlich zu speichernde Anzahl Grundkomponenten 13,9 8,3 12,9 10,2 Bei der Interpretation dieser Werte sollte man berücksichtigen, dass zu jedem Schachteltyp, der gespeichert werden muss, noch die zugehörige Programmstelle mit abgelegt wird. Weiterhin müssen für die Kompression der identischen Schachteltypen noch weitere Verwaltungsinformationen gespeichert werden. Dieser zusätzliche Speicherbedarf ist jedoch vergleichsweise gering und wird hier nicht berücksichtigt. Den größten Speicherplatzbedarf an Schachteltypgrundkomponenten hat die Methode nonICCBIFilter aus der Klasse java.awt.image.ColorConvertOp, die 1176 Grundkomponenten während der Überprüfungsphase speichert. Ein Grund für diese große Zahl ist die Schachteltypgröße. Diese Methode besitzt 43 lokale Variablen und eine Kellertiefe von 6. In jedem der 24 zu speichernden Schachteltypen sind die ersten 8 lokalen Variablen identisch. Zwei Schachteltypen besitzen ein Element auf dem Keller; bei den übrigen ist der Keller leer. Somit gibt es auch hier wieder ein Kompressionspotential (von ca. 27 %). In Abbildung 6.7 ist die Verteilung der Anzahl der zu speichernden Grundkomponenten über die untersuchten Methode zu sehen. Dabei werden – wie oben beschrieben – nur die paarweise verschiedenen Schachteltypen berücksichtigt. Die mögliche Kompression über die gemeinsamen Präfixe und der maximal von den Schachteltypen benutze Operandenkeller ist im Prototyp nicht implementiert und kann daher hier auch nicht evaluiert werden. Bewertung Bei den Java Card Beispielen ist der Speicherplatzbedarf während der Überprüfung derartig gering, dass die Schachteltypen in der Regel im RAM einer Java Card gehalten werden können. Sollte dieser Platz nicht ausreichen, gibt es eine Möglichkeit, die Schachteltypen im FlashRAM oder im EEPROM mit Caching-Strategien zu speichern, wie es von D. Deville und G. Grimaud vorgeschlagen wird [DG02]. Durch Änderung der Reihenfolge, in der die Grundblöcke überprüft werden, sollte sich der Speicherplatzbedarf noch verbessern lassen. Gerade bei Methoden, die sehr viele Ausnahmebehandlungen bzw. sehr lange durch Ausnahmebehandlungen geschützte Bereiche enthalten, könnte sich der Speicherplatzbedarf verbessern, wenn die Ausnahmebehandlungen zuerst überprüft werden. Dieser Ansatz wurde hier aus Zeitgründen nicht weiter verfolgt. 98 KAPITEL 6. EVALUIERUNG 10000 1000 Anzahl Methoden Anzahl Methoden 1000 100 100 10 10 1 1 0 200 400 600 800 1000 Anzahl zu speichernde Komponenten von Schachteltypen 1200 0 50 (a) java.* Hierarchie 400 450 (b) BCEL 1000 100 100 Anzahl Methoden Anzahl Methoden 100 150 200 250 300 350 Anzahl zu speichernde Komponenten von Schachteltypen 10 10 1 1 0 50 100 150 200 250 Anzahl zu speichernde Komponenten von Schachteltypen (c) jDFA 300 0 20 40 60 80 100 Anzahl zu speichernde Komponenten von Schachteltypen 120 (d) Java Card Beispiele Abbildung 6.7: Übersicht über die Anzahl der während der Überprüfung zu speichernden Schachteltypgrundkomponenten 6.3. WEITERE TESTS 6.3 99 weitere Tests In diesem Abschnitt möchte ich einige Beispiele für typunsicheren Bytecode beschreiben, aber auch Fälschungsversuche“ an Zertifikaten durchführen. ” 6.3.1 typunsichere Methoden Dass der Prototyp auch Bytecode abweisen kann, sollen die folgenden vier sehr einfachen, aber fehlerhaften Methoden zeigen: Method Test() 0 invokespecial #1 hMethod java.lang.Object()i 3 return Method void stackOverflow() 0 bipush 9 2 istore 1 3 return Method void stackUnderrun() 0 1 pop return Method void integerCast() 0 bipush 0 2 4 astore 1 return Die Methode Test ist eine Konstruktormethode, in der vor dem Aufruf des Oberklassenkonstruktors nicht wie üblicherweise das zu initialisierende Objekt auf den Keller abgelegt wird. Somit wird in dieser Methode ebenso wie in stackUnderrun versucht, ein nicht vorhandenes Element vom Keller zu nehmen. Dies wird von den Transferfunktionen erkannt und daher werden diese beiden Methoden vom Zertifizierer abgelehnt. Die Methode stackOverflow besitzt laut Klassendatei eine Kellergröße von 0. Damit wird diese Methode bereits beim Erstellen der initialen Schachteltypen zu Beginn der Datenflussanalyse abgewiesen. Dort wird erkannt, dass es Programmstellen gibt, die eine Kellertiefe größer als 0 benötigen. Einen echten Typfehler produziert die Methode IntegerCast. Hier wird ein intTyp auf dem Keller abgelegt und dann mit einer Instruktion wieder gelesen, die jedoch eine Referenz erwartet. Die Transferfunktion von astore erkennt dies und bricht die Datenflussanalyse ab. Die Tatsache, dass diese fehlerhaften Methoden bereits von dem Zertifizierer abgelehnt wurden, ist nicht verwunderlich, da ja der Zertifizierer einem erweiterten herkömmlichen Verifizierer entspricht (vgl. Satz 4.1). Um das Verhalten des Überprüfungsprogramms zu testen, müssen somit entweder Zertifikate von korrekten Methoden geändert werden oder die Methoden nach der Zertifizierung modifiziert werden. 6.3.2 Änderungen an Zertifikaten Im folgenden möchte ich an Beispielen das Verhalten des Überprüfungsprogramms untersuchen, wenn Zertifikate modifiziert werden. Dazu soll die folgende eher sinnlose Methode betrachtet werden: 100 KAPITEL 6. EVALUIERUNG static void test ( int x ) { while ( x > 10 ) { if ( x == 0 ) { boolean ret = false; if ( ! ret ) return; } x--; } } Das Zertifizierungsprogramm berechnet für die übersetzte Methode folgendes Zertifikat: class = A method = static void test(int arg0) maxstack = 2 maxlocals = 2 labels = {17} types = {*bot*} 14; (1, 0) Wird nun aus diesem Zertifikat die Markierung für die Programmstelle 17 gelöscht, wird die Methode vom Überprüfungsprogramm abgelehnt, weil im Laufe der Überprüfung diese gemerkte Programmstelle fehlt. Wenn der Eintrag für die Programmstelle 14 entfernt wird, würde man annehmen, dass der Überprüfer ebenfalls die Methode ablehnt. Stattdessen wird jedoch die Methode überraschenderweise akzeptiert. Der Grund dafür ist folgender: Die Zertifikate sind minimal im Bezug auf die vollständige Rekonstruktion der Informationen aus der Datenflussanalyse. Es kommt vor, dass die Datenflussanalyse mehr Informationen ermittelt als für die Überprüfung notwendig sind. Dann erscheint ein für die Überprüfung überflüssiger Eintrag im Zertifikat. Dies kann beispielsweise auftreten, wenn eine Instruktion mehrere Vorgänger besitzt, die in der gleichen lokalen Variable verschiedene Typen enthalten. Diese Informationen werden kombiniert und es erscheint ein Eintrag im Zertifikat. Wenn in den nachfolgenden Instruktionen diese lokale Variable nicht mehr benutzt wird, ist es für die Verifikation unerheblich, welchen Typ diese lokale Variable besitzt. 6.3.3 Änderungen am Bytecode Gerade im Hinblick auf die Fälschungssicherheit ist es interessant zu überprüfen, wie sich der Prototyp bei Bytecode-Änderungen nach der Zertifizierung verhält. Es soll hier das Beispiel aus Kapitel 3.3.3 betrachtet werden. Der Bytecode und das Zertifikat der Methode haben folgendes Aussehen: 0 1 4 5 6 9 10 11 iload 1 ifne 9 fconst 0 fstore 2 goto 11 iconst 0 istore 2 return class = A method = void test(int arg1) maxstack = 1 maxlocals = 3 labels = {11} types = {*bot*} 11; (2, 0) 6.4. BEWERTUNG DER ERGEBNISSE 101 Wird nun die erste Instruktion iload 1 durch aload 1 ersetzt, lässt sich die Methode nicht mehr überprüfen, weil die erste Instruktion nach der Änderung laut Überprüfer eine Referenz erwartet. Nun könnte man versuchen, das Zertifikat entsprechend zu fälschen, so dass die erste Instruktion eine Referenz in der ersten lokalen Variable besitzt: 0 1 4 5 6 9 10 11 aload 1 ifne 9 fconst 0 fstore 2 goto 11 iconst 0 istore 2 return class = A method = void test(int arg1) maxstack = 1 maxlocals = 3 labels = {11} types = {*bot*, *null*} 0; (1, 1) 11; (2, 0) Aber auch dies führt nicht zum Erfolg, weil dieses geänderte Zertifikat bereits bekannte Informationen zerstört und daher vom Überprüfer abgewiesen wird. Durch die Methodensignatur ist nämlich bereits bekannt, dass die erste lokale Variable einen int-Typ enthält. Der Überprüfer lehnt Zertifikateinträge ab, die nicht durch eine u-Operation entstanden sind. Dazu wird ein Vergleich auf der partiellen Ordnung durchgeführt. Ein Eintrag ist zulässig, wenn für den vom Überprüfer bestimmten Typ talt und den Typ aus dem Zertifikat tneu gilt: tneu v talt . Auf das Hasse-Diagramm bezogen darf ein Zertifikateintrag einen Typ somit nur in Richtung ⊥-Symbol verändern. 6.4 Bewertung der Ergebnisse Die Evaluierung hat gezeigt, dass die Größe der Zertifikate sehr gering ist. Im Durchschnitt vergrößern die Zertifikate den Bytecode um ca. 2 %. Der Speicherplatzbedarf während der Überprüfung ist ebenfalls unproblematisch. Selbst die aufwändigsten Methoden aus den hier untersuchten Bibliotheken sollten sich in der Regel mit dem auf Java Cards verfügbaren Speicher überprüfen lassen. Falls der Speicherplatz nicht ausreichen sollte, lassen sich die zu speichernden Schachteltypen noch mit einfachen Methoden komprimieren, da sie zum Teil identisch sind. 102 KAPITEL 6. EVALUIERUNG Kapitel 7 Zusammenfassung Die leichtgewichtige Verifikation von E. Rose ließ sich weitgehend problemlos erweitern um • Reihungen, • Ausnahmebehandlungen, • Sprunginstruktionen mit mehr als einem Sprungziel, • mehrwortige Datentypen und • Prüfung der Objektinitialisierung. So konnte ein auf Proof-Carrying Code Technik basierender Bytecode-Verifizierer implementiert werden. Dieser Verifizierer besteht aus zwei Teilen: Ein Zertifizierer, der Platz sparende Zertifikate erzeugt, und ein Überprüfer, der wenig Speicherplatz während der Ausführung benötigt. Unterprogramme werden von dem Verifizierer nicht unterstützt, da diese im Vorfeld schon als sehr schwierig identifiziert wurden. Es ist am einfachsten, die Unterprogramme in den wenigen Methoden, wo sie auftreten, zu expandieren. Der Speicherplatzbedarf der erzeugten Zertifikate ist sehr gering und liegt im Schnitt bei etwa 2 % der Bytecode-Größe. Auch der Bedarf an Speicherplatz während der Überprüfungsphase ist vertretbar und liegt bei durchschnittlich 11 Worten für die zu speichernden Schachteltypen. Durch geschicktere Speicherung der Schachteltypen lässt sich der Speicherplatzbedarf während der Überprüfung weiter senken, da viele Schachteltypen identisch sind, ein gemeinsames Präfix besitzen und selten Einträge für den Operandenkeller enthalten. Eine Frage, die in dieser Arbeit aus Zeitgründen nicht weiter verfolgt wurde, ist der Einfluss der Überprüfungsreihenfolge der Grundblöcke der zu zertifizierenden Methoden auf die Zertifikatgröße und den Speicherplatzbedarf während der Überprüfungsphase. Gerade durch die Erweiterungen um Ausnahmen und die switch-Instruktionen dürfte sich dieser Einfluss vergrößert haben. Die Implementierung hängt noch stark von den beiden Bibliotheken jDFA und BCEL ab. Diese Abhängigkeiten müssen für einen praktischen Einsatz – zumindestens für den Überprüfer – aufgelöst werden. 103 104 KAPITEL 7. ZUSAMMENFASSUNG Die Ergebnisse der Evaluierung haben gezeigt, dass die leichtgewichtige Verifikation ein sehr vielversprechender Ansatz zur Bytecode-Verifikation ist, vor allem wenn diese auf Systemen mit knappen Speicherplatz zum Einsatz kommen soll und damit eine speichereffiziente Verifikation notwendig ist. Literaturverzeichnis [Ro98] Eva Rose, Towards secure bytecode verification on a java card, Master’s thesis, DIKU, University of Copenhagen, 1998. [RR98] Eva Rose and Kristoffer H. Rose, Lightweight Bytecode Verification, Extended Abstract, Workshop Fundamental Underspinnings of Java ´98, 1998. [Ka87] G. Kahn, Natural Semantics, Rapport de Recherche 601, INRA, SophiaAntipolis, France, 1987. [Kas90] Uwe Kastens, Übersetzerbau, R. Oldenburg Verlag München Wien, 1990. [Muc97] S. Muchnick, Advanced Compiler Design and Implementation, Morgan Kaufmann Publishers, 1997. [Nec97] G. C. Necula, Proof Carrying Code, POPL ’97 – 24th Annual ACM symposium on Principles of Programming Languages, SIGPLAN Notices, 1997. [NL96] G. C. Necula and P. Lee, safe kernel extensions without run-time checking, Second Symposium on Operating Systems Design and Implementations, Usenix, 1996. [Le01] X. Leroy, Java bytecode verification: an overview. In G. Berry, H. Comon, and A. Finkel, editors, Computer Aided Verification, CAV 2001, volume 2102 of Lecture Notes in Computer Science, pages 265-285. Springer-Verlag, 2001. [Le02] X. Leroy, Bytecode verification on Java smart cards, Software Practice & Experience, volume 32, 2002. [Mo02] M. Mohnen, An Open Framework for Data-Flow Analysis, Aachener Informatik Berichte AIB-2002-08, RWTH Aachen, 2002. [DG02] D. Deville and G. Grimaud, Building an “impossible” verifier on a Java Card, In Second USENIX Workshop on Industrial Experiences with Systems Software (WIESS’02), Boston USA, 2002. [JVM96] T. Lindholm and F. Yellin, The Java Virtual Machine Specification, The Java Series, Addison-Wesley, 1996. [JCVM] Sun, The Java Card 2.1.1 Virtual Machine Specification, revision 1.0, http://java.sun.com/products/javacard/javacard21.html, 2000. [JDK] Sun, Java 2 Software Development Kit, Standard Edition 1.4.1 01, http://java.sun.com/j2se/1.4.1/download.html, 2002. [JCDK] Sun, Java Card 2.2 Development Kit, http://java.sun.com/products/javacard/dev kit.html, 2002. [CLDC] Sun, Connected, Limited Device http://java.sun.com/j2me/docs/, 2000. 105 Configuration Specification 1.0a, 106 LITERATURVERZEICHNIS [jDFA] M. Mohnen, jDFA – The Data-Flow Analysis Framework for Java, http://jdfa.sourceforge.net/, 2002. [BCEL] M. Dahm, BCEL – The Byte Code Engineering Library, http://jakarta.apache.org/bcel/, 2001. [GViz] AT&T, Graphviz – open source graph drawing software, http://www.research.att.com/sw/tools/graphviz/.