Untitled - Universität Paderborn

Werbung
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/.
Herunterladen