Java - Mit Deep Reflection Integers und Strings hacken - Feature Bild

Deep Reflection: Wie man Integer und String hackt

von Sven Woltmann – 11. März 2020

Ich lese gerade das Buch „The Pragmatic Programmer“ von Andrew Hunt und David Thomas. Darin stellen die Autoren folgende Aufgabe:

Which of these „impossible“ things can happen?

[…]
3. In C++: a = 2; b = 3; if (a + b != 5) exit(1);
[…]

Eine der richtigen Antworten ist 3. In C++ gibt es mehrere Gründe, warum die Bedingung „a + b != 5“ erfüllt sein könnte:

  • Operator Overloading: z. B. kann man den ‚+‘-Operator eine Multiplikation ausführen lassen.
  • Variable Aliasing: b ist ein Alias für a, damit setzt die Zuweisung b = 3 auch a auf 3, und die Summe ist 6.

Da es beides in Java nicht gibt, habe ich mich gefragt: Kann ich denselben Code auch in Java so schreiben, dass die Bedingung erfüllt ist? Die Antwort lautet: ja. Wie das möglich ist, erfährst du in diesem Artikel.

Die Code-Beispiele des Artikels findest du in meinem GitLab-Repository.

2 + 3 = 5

Fangen wir ganz einfach an: mit einer main-Funktion, in der zwei primitive ints, a und b, deklariert werden, gefolgt vom zu testenden Code:

import static java.lang.System.exit;

public class ImpossibleThings1 {
  public static void main(String[] args) {
    int a, b;
    a = 2; b = 3; if (a + b != 5) exit(1);
  }
}

Selbstverständlich gilt hier a + b = 5, so dass das Programm regulär, also mit Exit-Code 0, endet.

2 + 3 = 6: Deep Reflection mit Integer

Mit einer gar nicht allzu großen Änderung können wir dafür sorgen, dass die Bedingung wahr wird (also, dass 2 + 3 nicht 5 ist) und das Programm mit Fehlercode 1 endet:

public class ImpossibleThings2 {
  static {
    try {
      Field VALUE = Integer.class.getDeclaredField("value");
      VALUE.setAccessible(true);
      VALUE.set(2, 3);
    } catch (ReflectiveOperationException e) {
      throw new Error(e);
    }
  }

  public static void main(String[] args) {
    Integer a, b;
    a = 2; b = 3; if (a + b != 5) exit(1);
  }
}

Hier ist der Beweis:

Screenshot mit der Ausgabe "exit code 1"
Screenshot mit der Ausgabe „exit code 1“

Was haben wir getan? Wir verwenden hier Integer statt int und machen von reichlich Auto-Boxing und -Unboxing Gebrauch. Was hier genau passiert, beschreibe ich im nächsten Abschnitt.

Auto-(un)boxing aufgedeckt

Im Folgenden habe ich Auto-Boxing und -Unboxing durch explizites Boxing und Unboxing ersetzt. So wird deutlicher, was passiert. Die Änderungen sind gelb markiert (um dies zu ermöglichen, musste ich hier ein einfache Textbox verwenden).

public class ImpossibleThings3 {
  static {
    try {
      Field VALUE = Integer.class.getDeclaredField("value");
      VALUE.setAccessible(true);
      Integer two = Integer.valueOf(2);
      VALUE.set(two, 3);
    } catch (ReflectiveOperationException e) {
      throw new Error(e);
    }
  }

  public static void main(String[] args) {
    Integer a, b;
    a = Integer.valueOf(2);
    b = Integer.valueOf(3);
    if (a.intValue() + b.intValue() != 5) exit(1);
  }
}

Integer.valueOf() liefert für die Werte -128 to 127 gecachte Integer-Instanzen zurück.*

Ein Integer-Objekt speichert den eigentlichen Wert in einem privaten value-Feld und gibt eben diesen über intValue() zurück.

Im statischen Initializer holen wir uns das gecachten Integer-Objekt für den Wert 2. Mittels Deep Reflection setzen wir dessen value auf die Zahl 3. Da value ein privates Feld ist, müssen wir den Zugriff darauf mit Field.setAccessible(true) zunächst gestatten.

Würden wir nun mit System.out.println(two) dieses Objekt ausgeben, würden wir diese „3“ sehen.

In der main-Methode wird a = 2 zu a = Integer.valueOf(2) geboxt, was wiederum dieselbe gecachte Integer-Instanz liefert wie two, dessen Wert mittlerweile 3 ist. b ist ebenfalls 3 und somit ergibt a + b an dieser Stelle 6. Und das ist bekanntermaßen ungleich 5 (sofern diese nicht auch „gehackt“ wurde … was aber meines Wissens nach bei einem int-Primitiv nicht möglich ist) .

(*Garantiert ist dieses Verhalten nicht, aber praktisch ist es so. Mit -XX:AutoBoxCacheMax kann der gecachte Integer-Bereich vergrößert werden.)

Deep Reflection mit Strings

Dasselbe lässt sich auch mit Strings machen. Die folgenden Beispiele funktionieren so ab Java 9, eine Anpassung für ältere Versionen folgt weiter unten.

public class ImpossibleThings4 {
  static {
    try {
      Field VALUE = String.class.getDeclaredField("value");
      VALUE.setAccessible(true);
      VALUE.set("Hello world", "You have been hacked".getBytes());
    } catch (ReflectiveOperationException e) {
      throw new Error(e);
    }
  }

  public static void main(String[] args) {
    System.out.println("Hello world");
  }
}

Die Ausgabe dieses Programms lautet „You have been hacked“. Hier der Beweis:

Screenshot mit der Ausgabe von "You have been hackend"
Screenshot mit der Ausgabe von „You have been hackend“

So einfach, wie es in diesem Beispiel scheint, funktioniert es allerdings nicht immer. In­wie­weit wir Strings mit Deep Reflection manipulieren können, hängt von drei Faktoren ab:

  • ob Strings als Konstanten vorliegen oder zur Laufzeit erstellt werden,
  • ob Strings Sonderzeichen enthalten, die nicht als Latin-1 kodiert werden können,
  • welche Java-Version wir verwenden.

Strings müssen als Konstanten vorliegen

Zunächst einmal müssen Strings als Kontanten im Code enthalten sein. Nur String-Konstanten werden, wenn sie gleich sind, durch dieselbe Objektreferenz ersetzt.

Folgendes funktioniert noch:

public class ImpossibleThings5 {
  static { ... }

  public static void main(String[] args) {
    System.out.println("Hello" + " " + "world");
  }
}

Hier fügt bereits der Compiler die drei Teile zu einem einzigen String zusammen – zur Laufzeit ist das dann derselbe String wie der, dessen value-Inhalt wir ändern.

Folgendes hingegen funktioniert nicht:

public class ImpossibleThings6 {
  static { ... }

  public static void main(String[] args) {
    System.out.println("Hello " + getName());
  }

  private static String getName() {
    return "world";
  }
}

Hier werden erst zur Laufzeit „Hello “ und „world“ verkettet. Dabei entsteht ein neues String-Objekt mit dem value-Inhalt „Hello world“.

Vergleich der Objekt-Identitäten

Etwas klarer wird es, wenn wir uns die Identitäten der String-Objekte anschauen, hier noch einmal am ersten String-Beispiel:

public class ImpossibleThings4WithIdentity {
  static {
    try {
      Field VALUE = String.class.getDeclaredField("value");
      VALUE.setAccessible(true);
      String s1 = "Hello world";
      System.out.println("identityHashCode(s1) = " +
          System.identityHashCode(s1));
      VALUE.set(s1, "You have been hacked".getBytes());
    } catch (ReflectiveOperationException e) {
      throw new Error(e);
    }
  }

  public static void main(String[] args) {
    String s2 = "Hello world";
    System.out.println("identityHashCode(s2) = " +
        System.identityHashCode(s2));
    System.out.println(s2);
  }
}

Die Ausgabe lautet:

Screenshot mit Anzeige der Objekt-Identitäten
Screenshot mit Anzeige der Objekt-Identitäten

Das String-Objekt s1, das wir im statischen Initialisierer modifizieren, ist also identisch* zum String-Objekt s2, das wir in der main-Methode ausgeben. Wir geben also genau den String aus, den wir per Deep Reflection verändert haben.

Dasselbe prüfen wir noch einmal für den String, der im Quellcode aus String-Konstanten verkettet wird:

public class ImpossibleThings5WithIdentity {
  static { ... }

  public static void main(String[] args) {
    String s2 = "Hello" + " " + "world";
    System.out.println("identityHashCode(s2) = " +
        System.identityHashCode(s2));
    System.out.println(s2);
  }
}

Wir erhalten folgende Ausgabe:

Screenshot mit Anzeige der Objekt-Identitäten
Screenshot mit Anzeige der Objekt-Identitäten

Auch bei diesem Beispeil sind die String-Objekte s1 und s2 identisch.*

Und zuletzt überprüfen wir die Objekt-Identitäten bei der dritten Variante, bei der ein Teil des Strings durch eine Methode zurückgeliefert wird:

public class ImpossibleThings6WithIdentity {
  static { ... }

  public static void main(String[] args) {
    String s2 = "Hello " + getName();
    System.out.println("identityHashCode(s2) = " +
        System.identityHashCode(s2));
    System.out.println(s2);
  }

  private static String getName() {
    return "world";
  }
}

Hier die Ausgabe des dritten Tests:

Screenshot mit Anzeige der Objekt-Identitäten
Screenshot mit Anzeige der Objekt-Identitäten

Wir haben also die Bestätigung, dass es sich bei s1 und s2 um zwei unterschiedliche String-Objekte handelt. Die Änderung von s1 per Reflection wirkt sich daher nicht auf s2 aus.

(* Auch zwei nicht-identische Objekte könnten denselben Identity-HashCode haben. Wir müssten eigentlich noch mit s1 == s2 die Identität überprüfen. Die Wahrscheinlichkeit dafür ist allerdings minimal, so dass mir für die Beispiele der Vergleich der HashCodes genügt.)

String-Repräsentation: Latin-1 vs. UTF-16

Wenn wir das erste String-Beispiel leicht abändern, kommt ein zunächst ziemlich unerwartetes Ergebnis heraus. Wir ändern den auszugebenen String von „Hello world“ in „Hello world ✓“ (mit einem Häkchen am Ende):

public class ImpossibleThings7 {
  static {
    try {
      Field VALUE = String.class.getDeclaredField("value");
      VALUE.setAccessible(true);
      VALUE.set("Hello world ✓", "You have been hacked".getBytes());
    } catch (ReflectiveOperationException e) {
      throw new Error(e);
    }
  }

  public static void main(String[] args) {
    System.out.println("Hello world ✓");
  }
}

Was wird hier ausgegeben? Was denkst du? (Wir sind immer noch bei Java 9 oder höher.)

  1. „Hello world ✓“
  2. „You have been hacked“
  3. „You have been hacked ✓“
  4. „潙⁵慨敶戠敥慨正摥“

Die Antwort findest du in folgendem Screenshot:

Screenshot mit der Ausgabe chinesischer Zeichen
Screenshot mit der Ausgabe chinesischer Zeichen

Wie lässt sich das erklären?

Dazu müssen wir uns die interne Repräsentation eines Strings anschauen. Seit Java 9 wird ein String intern als byte[] dargestellt. Die Art der Kodierung von Characters in Bytes hängt dabei davon ab, ob der String ausschließlich Latin-1-kodierbare Zeichen enthält oder auch andere. Enthält der String nur Zeichen, die in Latin-1 kodiert werden können, wird pro Zeichen genau ein Byte verwendet. Enthält der String jedoch auch andere Zeichen, wird er als UTF-16 kodiert.

Dieses Feature nennt sich „String Compaction“, wurde im JEP 254 definiert und ist per default aktiviert. Es kann mit -XX:-CompactStrings deaktiviert werden – dann werden Strings grundsätzlich als UTF-16 gespeichert.

Was bedeutet das für unser Beispiel?

  • Der String „Hello world“ wird durch folgende Bytes repräsentiert:
    48 65 6c 6c 6f 20 77 6f 72 6c 64 
    ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ 
    H  e  l  l  o     W  o  r  l  d
  • Der String „Hello world ✓“ wird wie folgt gespeichert:
    48 00 65 00 6c 00 6c 00 6f 00 20 00 77 00 6f 00 72 00 6c 00 64 00 20 00 13 27
    ^^^^^ ^^^^^ ^^^^^ ^^^^^ ^^^^^ ^^^^^ ^^^^^ ^^^^^ ^^^^^ ^^^^^ ^^^^^ ^^^^^ ^^^^^
      H     e     l     l     o           W     o     r     l     d           ✓
    (Hier im Little-Endian-Format, da ich auf einem Intel-System arbeite.)

Die Information, wie der String kodiert wird, wird im Feld coder des Strings abgelegt. Dabei steht eine 0 für Latin-1 und eine 1 für UTF-16.

Im String „Hello world ✓“ enthält das Feld coder also aufgrund der UTF-16-Kodierung den Wert 1.

Im vorangegangenen Code-Beispiel setzen wir das Feld value des Strings „Hello world ✓“ auf "You have been hacked".getBytes(). Die Methode getBytes() liefert die Bytes in der Standard-Zeichenkodierung zurück, die – sofern nicht über die System Property „file.encoding“ anders definiert – UTF-8 ist (zumindest seit Java 1.5; davor war es ISO-8859-1).

Da der String „You have been hacked“ keinerlei Sonderzeichen enthält, ist dessen UTF-8-Kodierung identisch mit seiner Latin-1-Kodierung, belegt also genau ein Byte pro Zeichen.

Der String „Hello world ✓“ enthält somit im Feld value folgende Byte-Folge:

59 6f 75 20 68 61 76 65 20 62 65 65 6e 20 68 61 63 6b 65 64
^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^ ^^
Y  o  u     h  a  v  e     b  e  e  n     h  a  c  k  e  d

Da im „Hello world ✓“-Feld coder nach wie vor eine 1 steht (wegen der ursprünglichen UTF-16-Kodierung), wird das Byte-Array als UTF-16 interpretiert – und genau das führt dann zur Ausgabe der chinesischen Zeichen.

Grob gesagt haben wir also folgendes getan:

byte[] bytes = "You have been hacked".getBytes(StandardCharsets.UTF_8);
String string = new String(bytes, StandardCharsets.UTF_16);

Wie können wir das Problem lösen?

Ziemlich einfach: wir müssen lediglich neben dem Inhalt von value auch den Inhalt von coder kopieren. Bei der Gelegenheit ändern wir auch das Kopieren von value so ab, dass wir das entsprechende Feld aus dem String „You have been hacked“ auslesen, anstatt dessen getBytes()-Methode aufzurufen. Diese hat nämlich bisher nur zufällig das zugrunde liegende Byte-Array geliefert, weil „You have been hacked“ keine Sonderzeichen enthält und die System Property „file.encoding“ (zumindest bei mir und höchstwahrscheinlich auch bei dir) nicht gesetzt ist.

public class ImpossibleThings8 {
  static {
    try {
      Field VALUE = String.class.getDeclaredField("value");
      VALUE.setAccessible(true);

      Field CODER = String.class.getDeclaredField("coder");
      CODER.setAccessible(true);

      VALUE.set("Hello world ✓", VALUE.get("You have been hacked"));
      CODER.set("Hello world ✓", CODER.get("You have been hacked"));
    } catch (ReflectiveOperationException e) {
      throw new Error(e);
    }
  }

  public static void main(String[] args) {
    System.out.println("Hello world ✓");
  }
}

Statt chinesischer Zeichen bekommen wir nun wieder „You have been hacked“ ausgegeben:

Screenshot mit der Ausgabe von "You have been hacked" anstatt chinesischer Zeichen
Screenshot mit der Ausgabe von „You have been hacked“ anstatt chinesischer Zeichen

Dass die String-Konstanten hier zweimal angegeben werden, gehört sich so natürlich nicht. Das lösen wir, in dem wir den Code in eine Methode auslagern und die zwei Strings als Parameter übergeben:

public class StringHacker_Java9 {
  public static void hackString(String victim, String replacement) {
    try {
      Field VALUE = String.class.getDeclaredField("value");
      VALUE.setAccessible(true);

      Field CODER = String.class.getDeclaredField("coder");
      CODER.setAccessible(true);

      VALUE.set(victim, VALUE.get(replacement));
      CODER.set(victim, CODER.get(replacement));
    } catch (ReflectiveOperationException e) {
      throw new Error(e);
    }
  }
}

Als nächstes müssen wir noch einen Blick auf ältere Java-Versionen werfen.

String-Repräsentation: byte[] vs. char[]

Wie in der Einleitung dieses Kapitels erwähnt, funktionieren die Beispiele nur mit Java 9. Der Grund dafür ist, dass bis zu Java 8 der Wert eines Strings nicht in einem byte[], sondern in einem char[] abgelegt wurde. Entsprechend existierte auch bis Java 8 das Feld coder nicht.

Würden wir die bisherigen Beispiele mit Java 8 starten, bekämen wir …

  • beim Aufruf von VALUE.set("...".getBytes()) eine IllegalArgumentException: Can not set final [C field java.lang.String.value to [B
  • in den letzten zwei Beispielen (in denen wir nicht explizit ein Byte-Array setzen, sondern den Inhalt von value kopieren) beim darauf folgenden Aufruf von String.class.getDeclaredField("coder") eine NoSuchFieldException: coder.

Die IllegalArgumentException haben wir, wie gesagt, in den letzten zwei Beispielen schon verhindert. Die NoSuchFieldException können wir einfach ignorieren – wenn es das Feld coder nicht gibt, brauchen wir es auch nicht zu kopieren:

public class StringHacker_Java7 {
  public static void hackString(String from, String to) {
    try {
      Field VALUE = String.class.getDeclaredField("value");
      VALUE.setAccessible(true);
      VALUE.set(from, VALUE.get(to));

      // For "Compact Strings" introduced in Java 9
      try {
        Field CODER = String.class.getDeclaredField("coder");
        CODER.setAccessible(true);
        CODER.set(from, CODER.get(to));
      } catch (NoSuchFieldException e) {
        // Ignore
      }
    } catch (ReflectiveOperationException e) {
      throw new Error(e);
    }
  }
}

Hier der Beweis, dass dieser Code auch unter Java 7 läuft:

String Deep Reflection mit Java 7
String Deep Reflection mit Java 7

Sub-Strings mit offset und count

Gehen wir weiter in der Java-Geschichte zurück, kommen wir zu einer weiteren Änderung der String-Interna von Java 6 zu Java 7. Bis Java 6 wurde das value-Character-Array wiederverwendet, wenn man mit String.substring() einen Teil-String erzeugt hat.

Dazu wurde das Character-Array des ursprünglichen Strings in den Teil-String unverändert übernommen. Und in den Feldern offset und count des Teil-Strings wurde gespeichert, welcher Abschnitt des Character-Arrays dessen Inhalt repräsentiert.

Ziel dieser Logik war es Speicher zu sparen.

Häufiger passierte jedoch das Gegenteil: Wenn der ursprüngliche String nicht mehr benötigt wurde, hielt der kürzere Teil-String nach wie vor eine Referenz auf das ursprüngliche, dann unnötig längere Character-Array. Von daher haben die Java-Entwickler in Java 7 die Funktionalität von String.substring() so geändert, dass nur der benötigte Teil des Character-Arrays in den Teil-String kopiert wurde.

Um unseren Code auf Java 6 und niedriger lauffähig zu machen, müssen wir also auch die Felder offset und count kopieren.

Vor Java 7 gab es leider auch weder die ReflectiveOperationException, noch die Möglichkeit mehrere Exception-Typen in einem catch-Block abzufangen, so dass dieser etwas umständlicher wird. Hier der auch unter 6 lauffähige Code:

public class StringHacker {
  public static void hackString(String from, String to) {
    try {
      Field VALUE = String.class.getDeclaredField("value");
      VALUE.setAccessible(true);
      VALUE.set(from, VALUE.get(to));

      // "offset" and "count" for Strings up to Java 6
      try {
        Field OFFSET = String.class.getDeclaredField("offset");
        OFFSET.setAccessible(true);
        OFFSET.setInt(from, OFFSET.getInt(to));

        Field COUNT = String.class.getDeclaredField("count");
        COUNT.setAccessible(true);
        COUNT.setInt(from, COUNT.getInt(to));
      } catch (NoSuchFieldException e) {
        // Ignore
      }

      // For "Compact Strings" introduced in Java 9
      try {
        Field CODER = String.class.getDeclaredField("coder");
        CODER.setAccessible(true);
        CODER.set(from, CODER.get(to));
      } catch (NoSuchFieldException e) {
        // Ignore
      }
    } catch (IllegalAccessException e) {
      e.printStackTrace();
    } catch (NoSuchFieldException e) {
      e.printStackTrace();
    }
  }
}

Der folgende Screenshot zeigt, wie der Code unter Java 6 läuft:

String Deep Reflection mit Java 6
String Deep Reflection mit Java 6

Experiment „Compressed Strings“ in Java 6u21

Der Artikel wäre nicht vollständig, würden wir nicht kurz auf die in Java 6 als „experimental“ eingeführten „Compressed Strings“ eingehen (nicht zu verwechseln mit den o. g. in Java 9 eingeführten „Compact Strings“!).

Wenn über die VM-Option -XX:+UseCompressedStrings aktiviert, dann wird, sofern ein String nur Latin-1-Zeichen enthält, im value-Feld anstatt eines Character-Arrays ein Byte-Array gespeichert. Dies wurde jedoch nicht im String-Quellcode gemacht, sondern intern in der JVM. Diese Optimierung hat zwar Speicher gespart, war aber sehr unperformant, da das Byte-Array für die fast alle String-Operationen in ein Character-Array konvertiert werden musste. In Java 7 wurde die Funktion wieder entfernt.

Da diese Optimierung JVM-intern durchgeführt wurde, ist unser Code ohne weitere Anpassung auch mit aktivierten „Compressed Strings“ lauffähig:

String Deep Reflection mit Java 6 und "-XX:+UseCompressedStrings"
String Deep Reflection mit Java 6 und „-XX:+UseCompressedStrings“

Fazit

In der Praxis solltet ihr davon absehen, die internen Werte von gecachten Integer-Objekten oder von Strings zu verändern. Dies könnte unvorhersehbare Konsequenzen haben. Die Änderung hat nicht nur Auswirkungen auf euren eigenen Code, sondern auch auf den restlichen Code des Projekts inklusive aller Libraries und Frameworks, die vom selben Classloader geladen werden.

Ihr solltet euch auch nicht auf die interne Repräsentation einer Klasse verlassen. Wie am String-Beispiel gezeigt, kann sich diese von einer Java-Version zur nächsten ändern.

Außerdem bekommen wir für die Code-Beispiele aus diesem Artikel seit Java 9 eine Fehlermeldung:

An illegal reflective access operation has occurred
[…]
All illegal access operations will be denied in a future release

Das bedeutet: Wir dürfen nicht davon ausgehen, dass unser Code für immer und ewig funktioniert. In Java 14 (release candidate) und 15 (early access) allerdings funktioniert der Code nach wie vor. Und da zahlreiche 3rd-Party-Frameworks von Deep Reflection Gebrauch machen, wird Oracle diese Funktion auch sicher in absehbarer nicht Zukunft entfernen.

Wenn du den Artikel interessant findest, dann teile ihn gerne über einen der Share-Buttons unten. Wenn du informiert werden möchtst, wenn neue Artikel veröffentlicht werden, trage dich auch gerne in meinen E-Mail-Verteiler ein.

Die folgenden Artikel könnten dir auch gefallen
Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Pflichtfelder sind markiert.

{"email":"Email address invalid","url":"Website address invalid","required":"Required field missing"}