Nachdem ich einige Zeit mit Java-Bytecode gearbeitet und einige zusätzliche Untersuchungen zu diesem Thema durchgeführt habe, finden Sie hier eine Zusammenfassung meiner Ergebnisse:
Führen Sie Code in einem Konstruktor aus, bevor Sie einen Superkonstruktor oder Hilfskonstruktor aufrufen
In der Java-Programmiersprache (JPL) muss die erste Anweisung eines Konstruktors ein Aufruf eines Superkonstruktors oder eines anderen Konstruktors derselben Klasse sein. Dies gilt nicht für Java-Bytecode (JBC). Innerhalb von Bytecode ist es absolut legitim, Code vor einem Konstruktor auszuführen, solange:
- Ein anderer kompatibler Konstruktor wird irgendwann nach diesem Codeblock aufgerufen.
- Dieser Aufruf befindet sich nicht in einer bedingten Anweisung.
- Vor diesem Konstruktoraufruf wird kein Feld der konstruierten Instanz gelesen und keine ihrer Methoden aufgerufen. Dies impliziert den nächsten Punkt.
Legen Sie Instanzfelder fest, bevor Sie einen Superkonstruktor oder Hilfskonstruktor aufrufen
Wie bereits erwähnt, ist es völlig legal, einen Feldwert einer Instanz festzulegen, bevor ein anderer Konstruktor aufgerufen wird. Es gibt sogar einen Legacy-Hack, der es ermöglicht, diese "Funktion" in Java-Versionen vor 6 auszunutzen:
class Foo {
public String s;
public Foo() {
System.out.println(s);
}
}
class Bar extends Foo {
public Bar() {
this(s = "Hello World!");
}
private Bar(String helper) {
super();
}
}
Auf diese Weise könnte ein Feld gesetzt werden, bevor der Superkonstruktor aufgerufen wird, was jedoch nicht mehr möglich ist. In JBC kann dieses Verhalten weiterhin implementiert werden.
Verzweigen Sie einen Super-Konstruktor-Aufruf
In Java ist es nicht möglich, einen Konstruktoraufruf wie zu definieren
class Foo {
Foo() { }
Foo(Void v) { }
}
class Bar() {
if(System.currentTimeMillis() % 2 == 0) {
super();
} else {
super(null);
}
}
Bis Java 7u23 hat der Verifizierer der HotSpot-VM diese Prüfung jedoch verpasst, weshalb dies möglich war. Dies wurde von mehreren Tools zur Codegenerierung als eine Art Hack verwendet, aber es ist nicht mehr legal, eine solche Klasse zu implementieren.
Letzteres war lediglich ein Fehler in dieser Compilerversion. In neueren Compilerversionen ist dies wieder möglich.
Definieren Sie eine Klasse ohne Konstruktor
Der Java-Compiler implementiert immer mindestens einen Konstruktor für eine Klasse. Im Java-Bytecode ist dies nicht erforderlich. Dies ermöglicht die Erstellung von Klassen, die auch bei Verwendung von Reflektion nicht erstellt werden können. Die Verwendung sun.misc.Unsafe
ermöglicht jedoch weiterhin die Erstellung solcher Instanzen.
Definieren Sie Methoden mit identischer Signatur, aber unterschiedlichem Rückgabetyp
In der JPL wird eine Methode durch ihren Namen und ihre Rohparametertypen als eindeutig identifiziert. In JBC wird zusätzlich der Rohergabetyp berücksichtigt.
Definieren Sie Felder, die sich nicht nach Namen, sondern nur nach Typ unterscheiden
Eine Klassendatei kann mehrere Felder mit demselben Namen enthalten, sofern sie einen anderen Feldtyp deklarieren. Die JVM bezieht sich immer auf ein Feld als Tupel aus Name und Typ.
Wirf nicht deklarierte geprüfte Ausnahmen, ohne sie zu fangen
Die Java-Laufzeit und der Java-Bytecode kennen das Konzept der geprüften Ausnahmen nicht. Nur der Java-Compiler überprüft, ob geprüfte Ausnahmen immer entweder abgefangen oder deklariert werden, wenn sie ausgelöst werden.
Verwenden Sie den dynamischen Methodenaufruf außerhalb von Lambda-Ausdrücken
Der sogenannte dynamische Methodenaufruf kann für alles verwendet werden, nicht nur für Javas Lambda-Ausdrücke. Mit dieser Funktion können Sie beispielsweise die Ausführungslogik zur Laufzeit ausschalten. Viele dynamische Programmiersprachen, die auf JBC hinauslaufen, haben ihre Leistung mithilfe dieser Anweisung verbessert . In Java-Bytecode können Sie auch Lambda-Ausdrücke in Java 7 emulieren, bei denen der Compiler die Verwendung eines dynamischen Methodenaufrufs noch nicht zugelassen hat, während die JVM die Anweisung bereits verstanden hat.
Verwenden Sie Bezeichner, die normalerweise nicht als legal angesehen werden
Haben Sie schon einmal Lust gehabt, Leerzeichen und einen Zeilenumbruch im Namen Ihrer Methode zu verwenden? Erstellen Sie Ihre eigene JBC und viel Glück bei der Codeüberprüfung. Die einzigen ungültigen Zeichen für Bezeichner sind .
, ;
, [
und /
. Darüber hinaus Methoden, die nicht benannt sind <init>
oder <clinit>
nicht <
und enthalten können >
.
Ordnen Sie die final
Parameter oder die this
Referenz neu zu
final
Parameter sind in JBC nicht vorhanden und können daher neu zugewiesen werden. Jeder Parameter, einschließlich der this
Referenz, wird nur in einem einfachen Array innerhalb der JVM gespeichert, wodurch die this
Referenz am Index 0
innerhalb eines einzelnen Methodenrahmens neu zugewiesen werden kann.
final
Felder neu zuweisen
Solange ein letztes Feld innerhalb eines Konstruktors zugewiesen ist, ist es zulässig, diesen Wert neu zuzuweisen oder gar keinen Wert zuzuweisen. Daher sind die folgenden zwei Konstruktoren zulässig:
class Foo {
final int bar;
Foo() { } // bar == 0
Foo(Void v) { // bar == 2
bar = 1;
bar = 2;
}
}
Für static final
Felder ist es sogar zulässig, die Felder außerhalb des Klasseninitialisierers neu zuzuweisen.
Behandeln Sie Konstruktoren und den Klasseninitialisierer so, als wären sie Methoden
Dies ist eher ein konzeptionelles Merkmal, aber Konstruktoren werden in JBC nicht anders behandelt als normale Methoden. Nur der Verifizierer der JVM stellt sicher, dass Konstruktoren einen anderen legalen Konstruktor aufrufen. Davon abgesehen ist es lediglich eine Java-Namenskonvention, dass Konstruktoren aufgerufen werden müssen <init>
und dass der Klasseninitialisierer aufgerufen wird <clinit>
. Abgesehen von diesem Unterschied ist die Darstellung von Methoden und Konstruktoren identisch. Wie Holger in einem Kommentar hervorhob, können Sie sogar Konstruktoren mit anderen Rückgabetypen als void
oder einen Klasseninitialisierer mit Argumenten definieren, obwohl es nicht möglich ist, diese Methoden aufzurufen.
Erstellen Sie asymmetrische Datensätze * .
Beim Erstellen eines Datensatzes
record Foo(Object bar) { }
javac generiert eine Klassendatei mit einem einzelnen Feld namens bar
, einer Accessormethode namens bar()
und einem Konstruktor, der ein einzelnes Feld verwendet Object
. Zusätzlich wird ein Datensatzattribut für bar
hinzugefügt. Durch manuelles Generieren eines Datensatzes ist es möglich, eine andere Konstruktorform zu erstellen, das Feld zu überspringen und den Accessor anders zu implementieren. Gleichzeitig ist es weiterhin möglich, die Reflection-API davon zu überzeugen, dass die Klasse einen tatsächlichen Datensatz darstellt.
Rufen Sie eine beliebige Supermethode auf (bis Java 1.1)
Dies ist jedoch nur für Java-Versionen 1 und 1.1 möglich. In JBC werden Methoden immer für einen expliziten Zieltyp gesendet. Dies bedeutet, dass für
class Foo {
void baz() { System.out.println("Foo"); }
}
class Bar extends Foo {
@Override
void baz() { System.out.println("Bar"); }
}
class Qux extends Bar {
@Override
void baz() { System.out.println("Qux"); }
}
Es war möglich zu implementieren Qux#baz
, um Foo#baz
beim Überspringen aufzurufen Bar#baz
. Während es weiterhin möglich ist, einen expliziten Aufruf zum Aufrufen einer anderen Implementierung der Supermethode als der der direkten Superklasse zu definieren, hat dies in Java-Versionen nach 1.1 keine Auswirkungen mehr. In Java 1.1 wurde dieses Verhalten durch Setzen des ACC_SUPER
Flags gesteuert, das dasselbe Verhalten ermöglicht, das nur die Implementierung der direkten Superklasse aufruft.
Definieren Sie einen nicht virtuellen Aufruf einer Methode, die in derselben Klasse deklariert ist
In Java ist es nicht möglich, eine Klasse zu definieren
class Foo {
void foo() {
bar();
}
void bar() { }
}
class Bar extends Foo {
@Override void bar() {
throw new RuntimeException();
}
}
Der obige Code führt immer zu einem RuntimeException
Wann, foo
das für eine Instanz von aufgerufen wird Bar
. Es ist nicht möglich, die Foo::foo
Methode zu definieren , um eine eigene bar
Methode aufzurufen , die in definiert ist Foo
. Wie bar
bei einer nicht privaten Instanzmethode ist der Aufruf immer virtuell. Mit Byte - Code, kann man jedoch den Aufruf definieren die verwenden INVOKESPECIAL
Opcode , die direkt über die Links bar
Methodenaufruf in Foo::foo
zu Foo
s Version‘. Dieser Opcode wird normalerweise zum Implementieren von Super-Methodenaufrufen verwendet. Sie können den Opcode jedoch wiederverwenden, um das beschriebene Verhalten zu implementieren.
Feinkörnige Anmerkungen
In Java werden Anmerkungen entsprechend ihrer @Target
Deklaration in den Anmerkungen angewendet . Mithilfe der Bytecode-Manipulation können Anmerkungen unabhängig von diesem Steuerelement definiert werden. Es ist beispielsweise auch möglich, einen Parametertyp zu kommentieren, ohne den Parameter zu kommentieren, selbst wenn die @Target
Annotation für beide Elemente gilt.
Definieren Sie ein Attribut für einen Typ oder seine Mitglieder
In der Java-Sprache können nur Anmerkungen für Felder, Methoden oder Klassen definiert werden. In JBC können Sie grundsätzlich alle Informationen in die Java-Klassen einbetten. Um diese Informationen nutzen zu können, können Sie sich jedoch nicht mehr auf den Lademechanismus der Java-Klasse verlassen, sondern müssen die Metainformationen selbst extrahieren.
Überlauf und implizit assign byte
, short
, char
und boolean
Werte
Die letzteren primitiven Typen sind in JBC normalerweise nicht bekannt, sondern nur für Array-Typen oder für Feld- und Methodendeskriptoren definiert. Innerhalb von Bytecode-Anweisungen nehmen alle benannten Typen das 32-Bit-Leerzeichen ein, mit dem sie dargestellt werden können int
. Offiziell nur die int
, float
, long
und double
Typen innerhalb Byte - Code existieren , die alle brauchen explizite Konvertierung durch die Regel des Verifizierer JVM.
Kein Monitor freigeben
Ein synchronized
Block besteht eigentlich aus zwei Anweisungen, eine zum Erfassen und eine zum Freigeben eines Monitors. In JBC können Sie eine erwerben, ohne sie freizugeben.
Hinweis : In neueren Implementierungen von HotSpot führt dies stattdessen zu einem IllegalMonitorStateException
am Ende einer Methode oder zu einer impliziten Freigabe, wenn die Methode durch eine Ausnahme selbst beendet wird.
Fügen Sie einem Typinitialisierer mehr als eine return
Anweisung hinzu
In Java kann sogar ein trivialer Typinitialisierer wie
class Foo {
static {
return;
}
}
ist illegal. Im Bytecode wird der Typinitialisierer wie jede andere Methode behandelt, dh Rückgabeanweisungen können überall definiert werden.
Erstellen Sie irreduzible Schleifen
Der Java-Compiler konvertiert Schleifen in goto-Anweisungen im Java-Bytecode. Solche Anweisungen können verwendet werden, um irreduzible Schleifen zu erstellen, was der Java-Compiler niemals tut.
Definieren Sie einen rekursiven Catch-Block
Im Java-Bytecode können Sie einen Block definieren:
try {
throw new Exception();
} catch (Exception e) {
<goto on exception>
throw Exception();
}
Eine ähnliche Anweisung wird implizit erstellt, wenn ein synchronized
Block in Java verwendet wird, bei dem eine Ausnahme beim Freigeben eines Monitors zu der Anweisung zum Freigeben dieses Monitors zurückkehrt. Normalerweise sollte bei einer solchen Anweisung keine Ausnahme auftreten, aber wenn dies ThreadDeath
der Fall wäre (z. B. die veraltete Anweisung ), wird der Monitor trotzdem freigegeben.
Rufen Sie eine Standardmethode auf
Der Java-Compiler erfordert, dass mehrere Bedingungen erfüllt sind, um den Aufruf einer Standardmethode zu ermöglichen:
- Die Methode muss die spezifischste sein (darf nicht von einer Subschnittstelle überschrieben werden, die von einem beliebigen Typ implementiert wird , einschließlich Supertypen).
- Der Schnittstellentyp der Standardmethode muss direkt von der Klasse implementiert werden, die die Standardmethode aufruft. Wenn die Schnittstelle die Schnittstelle
B
erweitert, A
aber eine Methode in nicht überschreibt A
, kann die Methode trotzdem aufgerufen werden.
Für Java-Bytecode zählt nur die zweite Bedingung. Der erste ist jedoch irrelevant.
Rufen Sie eine Supermethode für eine Instanz auf, die dies nicht ist this
Der Java-Compiler erlaubt nur das Aufrufen einer Super- (oder Schnittstellenstandard-) Methode für Instanzen von this
. Im Bytecode ist es jedoch auch möglich, die Super-Methode für eine Instanz desselben Typs aufzurufen, die der folgenden ähnelt:
class Foo {
void m(Foo f) {
f.super.toString(); // calls Object::toString
}
public String toString() {
return "foo";
}
}
Zugriff auf synthetische Elemente
Im Java-Bytecode ist es möglich, direkt auf synthetische Mitglieder zuzugreifen. Überlegen Sie beispielsweise, wie im folgenden Beispiel auf die äußere Instanz einer anderen Bar
Instanz zugegriffen wird:
class Foo {
class Bar {
void bar(Bar bar) {
Foo foo = bar.Foo.this;
}
}
}
Dies gilt im Allgemeinen für jedes synthetische Feld, jede Klasse oder Methode.
Definieren Sie nicht synchronisierte generische Typinformationen
Während die Java-Laufzeit keine generischen Typen verarbeitet (nachdem der Java-Compiler die Typlöschung angewendet hat), werden diese Informationen weiterhin als Metainformationen an eine kompilierte Klasse weitergeleitet und über die Reflection-API zugänglich gemacht.
Der Prüfer überprüft nicht die Konsistenz dieser mit Metadaten String
kodierten Werte. Es ist daher möglich, Informationen zu generischen Typen zu definieren, die nicht mit der Löschung übereinstimmen. Als Konsequenz können die folgenden Behauptungen wahr sein:
Method method = ...
assertTrue(method.getParameterTypes() != method.getGenericParameterTypes());
Field field = ...
assertTrue(field.getFieldType() == String.class);
assertTrue(field.getGenericFieldType() == Integer.class);
Die Signatur kann auch als ungültig definiert werden, sodass eine Laufzeitausnahme ausgelöst wird. Diese Ausnahme wird ausgelöst, wenn zum ersten Mal auf die Informationen zugegriffen wird, da diese träge ausgewertet werden. (Ähnlich wie Anmerkungswerte mit einem Fehler.)
Fügen Sie Parameter-Metainformationen nur für bestimmte Methoden hinzu
Der Java-Compiler ermöglicht das Einbetten von Parameternamen und Modifikatorinformationen beim Kompilieren einer Klasse mit parameter
aktiviertem Flag. Im Java-Klassendateiformat werden diese Informationen jedoch pro Methode gespeichert, sodass nur solche Methodeninformationen für bestimmte Methoden eingebettet werden können.
Verwirren Sie die Dinge und bringen Sie Ihre JVM zum Absturz
In Java-Bytecode können Sie beispielsweise festlegen, dass eine beliebige Methode für einen beliebigen Typ aufgerufen werden soll. Normalerweise beschwert sich der Prüfer, wenn einem Typ eine solche Methode nicht bekannt ist. Wenn Sie jedoch eine unbekannte Methode für ein Array aufrufen, habe ich in einer JVM-Version einen Fehler gefunden, bei dem der Prüfer dies übersieht und Ihre JVM beendet wird, sobald die Anweisung aufgerufen wird. Dies ist zwar kaum eine Funktion, aber technisch gesehen ist dies mit javac kompiliertem Java nicht möglich . Java hat eine Art doppelte Validierung. Die erste Validierung wird vom Java-Compiler angewendet, die zweite von der JVM, wenn eine Klasse geladen wird. Wenn Sie den Compiler überspringen, finden Sie möglicherweise eine Schwachstelle in der Validierung des Überprüfers. Dies ist jedoch eher eine allgemeine Aussage als ein Merkmal.
Kommentieren Sie den Empfängertyp eines Konstruktors, wenn keine äußere Klasse vorhanden ist
Seit Java 8 können nicht statische Methoden und Konstruktoren innerer Klassen einen Empfängertyp deklarieren und diese Typen mit Anmerkungen versehen. Konstruktoren von Klassen der obersten Ebene können ihren Empfängertyp nicht mit Anmerkungen versehen, da sie meistens keinen deklarieren.
class Foo {
class Bar {
Bar(@TypeAnnotation Foo Foo.this) { }
}
Foo() { } // Must not declare a receiver type
}
Da Foo.class.getDeclaredConstructor().getAnnotatedReceiverType()
jedoch eine AnnotatedType
Darstellung zurückgegeben wird Foo
, ist es möglich, Typanmerkungen für Foo
den Konstruktor direkt in die Klassendatei aufzunehmen, in der diese Anmerkungen später von der Reflection-API gelesen werden.
Verwenden Sie nicht verwendete / Legacy-Bytecode-Anweisungen
Da andere es benannt haben, werde ich es auch aufnehmen. Java verwendete früher Unterprogramme der Anweisungen JSR
und RET
. JBC kannte zu diesem Zweck sogar eine eigene Art von Absenderadresse. Die Verwendung von Unterprogrammen hat jedoch die statische Code-Analyse zu kompliziert gemacht, weshalb diese Anweisungen nicht mehr verwendet werden. Stattdessen dupliziert der Java-Compiler den von ihm kompilierten Code. Dies schafft jedoch im Grunde eine identische Logik, weshalb ich es nicht wirklich für etwas anderes halte. Ebenso können Sie beispielsweise die hinzufügenNOOP
Bytecode-Anweisung, die auch vom Java-Compiler nicht verwendet wird, aber dies würde es Ihnen auch nicht wirklich ermöglichen, etwas Neues zu erreichen. Wie im Zusammenhang ausgeführt, werden diese erwähnten "Funktionsanweisungen" jetzt aus dem Satz legaler Opcodes entfernt, wodurch sie noch weniger zu einem Merkmal werden.