Schützen Sie Ihren Java-Code — durch Obfuscatoren und weitergehende Maßnahmen

Von Dmitry LESKOV LinkedIn

Letzte Aktualisierung: 09. Okt 2015

Durch Reverse Engineering Ihrer Anwendungen können unlautere Mitbewerber oder böswillige Hacker Ihre Algorithmen und Ideen, Ihre Datenformate, Lizenzierungs- und Sicherheitsmechanismen und ganz besonders Ihre Kundendaten offenlegen. Im Folgenden erfahren Sie, warum Java im Vergleich zu C++ hier besondere Schwächen aufweist:

Zielbefehlssatz

C++: Wird zu einem Low-Level-Befehlssatz kompiliert, der mit unformatierten binären Daten arbeitet und der von der Zielhardware, z. B. x86, ARM oder PowerPC, abhängt.

Java: Wird in abstrakten, portablen Bytecode kompiliert, der mit typisierten Werten arbeitet: Objekte, primitive Typen und deren Arrays.

Compiler-Optimierungen

C++: Während der Kompilierung werden zahlreiche Code-Optimierungen vorgenommen. Durch Inline-Substitution werden Kopien von (Member-) Funktion in der Binärdatei verteilt; durch die Verwendung des Preprocessors in Kombination mit der Auswertung von Ausdrücken zur Kompilierzeit ist es möglich, sämtliche Spuren der im Quellcode definierten Konstanten zu verwischen, usw.

Java: Beruht aus Gründen der Leistungssteigerung auf einer dynamischen Kompilierung (Just-In-Time-Kompilierung). Der Standardcompiler javac ist einfach aufgebaut. Im Gegensatz zu den gängigen C++-Compilern führt er keine Optimierungen währen der Kompilierung durch. Die Idee ist, unter Berücksichtigung des Ausführungsprofils alle Optimierungen zur Laufzeit vom JIT-Compiler vornehmen zu lassen.

Linking

C++: Programme werden statisch gelinkt und die Kernsprache besitzt keine Reflection-Möglichkeit. Folglich müssen mit Ausnahme der aus dynamischen Bibliotheken (DLLs/Shared Objects) exportierten Bezeichnungen die Namen von Klassen, Membern und Variablen nicht mehr im kompilierten Programm vorhanden zu sein.

Java: Abhängigkeiten werden zur Laufzeit beim Laden der Klassen aufgelöst. Folglich müssen der Name der Klasse und die Namen ihrer Methoden und Felder in der Klassendatei vorhanden sein. Gleiches gilt für die Namen aller importierten Klassen, aufgerufenen Methoden und Felder.

Deployment

C++: Eine Anwendung wird als monolithische ausführbare Datei (eventuell mit ein paar dynamischen Bibliotheken) bereitgestellt. Daher ist es schwierig, die Memberfunktionen einer bestimmten Klasse zu identifizieren oder die Klassenhierarchie zu rekonstruieren.

Java: Eine Anwendung wird normalerweise als Satz von JAR-Dateien bereitgestellt. Dabei handelt es sich um unverschlüsselte Archive, die die einzelnen Klassendateien enthalten.

Die Dekompilierung von Java-Programmen ist also wesentlich einfacher als die Dekompilierung von C++-Programmen und lässt sich daher vollständig automatisieren: Klassenhierarchie, High-Level-Anweisungen, Namen von Klassen, Methoden und Feldern – all dies kann aus den vom Standardcompiler javac ausgegebenen Klassendateien wiedergewonnen werden. Jede Person mit durchschnittlichen Programmierkenntnissen kann einen Java-Decompiler herunterladen, Ihre Anwendung durch den Decompiler laufen lassen und Ihren Code fast genauso lesen als ob es Open-Source-Code wäre.

Schauen wir uns einmal an, was man dagegen tun kann.

Die naheliegendste Lösung besteht darin, die Klassendateien zu verschlüsseln. Leider hat dieser Ansatz einen entscheidenden Haken: Die JVM kann keine verschlüsselten Klassen laden und ausführen. Punkt. Eine Klasse muss entschlüsselt sein, damit sie von der JVM geladen werden kann. Und an dieser Stelle ist es relativ einfach, den ursprünglichen, unverschlüsselten Bytecode abzugreifen. Das Verfahren wird detailliert in  [1] und in [2] beschrieben.

Die API java.lang.instrument bietet Hackern noch eine weitere Möglichkeit, um die Verschlüsselungsmechanismen von Klassendateien zu umgehen.

Zudem kann jedes Schutzschema, das auf Bytecode-Verschlüsselung basiert, ohne Reverse Engineering der Entschlüsselungsroutinen zunichte gemacht werden. Solange jemand im Besitz der verschlüsselten Anwendung und des Entschlüsselungs-Keys ist, kann er die ursprünglichen Klassen relativ einfach erhalten, und zwar unabhängig davon, wie sie verschlüsselt wurden.

Natürlich kennen die Autoren moderner Bytecode-Verschlüsselungssysteme diese Standardmechanismen und versuchen, sie abzustellen. Da Java jetzt jedoch Open Source ist, kann man den Quellcode des OpenJDK einfach herunterladen, so zu patchen, dass geladene Klassen auf der Festplatte abgelegt werden, und die Option -XX:+CompileTheWorld erzwingen.

Um dem Ganzen die Krone aufzusetzen, hat vor nicht allzu langer Zeit ein Sicherheitsingenieur – frustriert durch falsche Behauptungen von Anbietern, deren Tools Bytecode-Verschlüsselung implementieren – einen Artikel [3] verfasst, in dem er aufzeigt, wie einfach OpenJDK modifiziert werden kann, um jedes Bytecode-Verschlüsselungsschema auszuhebeln.

Zwei Korrekturen:

  1. Natürlich erfüllt eine starke Verschlüsselung ihren Zweck, wenn ein böswilliger Konkurrent oder Hacker lediglich die verschlüsselten Klassendateien in die Finger bekommt. Insofern kann die Verschlüsselung in gewissem Maße dazu beitragen, der Offenlegung des serverseitigen Anwendungscodes, der in einer kontrollierten Umgebung ausgeführt wird, entgegenzuwirken (zumindest bis der Account des Systemadministrators gehackt wird :) ). Aber jeder Code, der auf Drittsystemen ausgeführt werden soll, muss mit dem entsprechenden Entschlüsselungsschlüssel ausgestattet werden und kann daher nicht durch Verschlüsselung geschützt werden.
  2. Die an sich schon sehr lange Überschrift dieses Abschnitts hätte eigentlich heißen müssen: "Bytecode-Verschlüsselung auf reiner Softwarebasis...", denn mithilfe eines kleinen Stücks Silizium lässt sich Java-Bytecode sehr sicher verschlüsseln.

    Validy SoftNaOS

    Der Nachteil? Die Leistungseinbussen betragen mehrere Größenordnungen…

Okay, eine rein softwarebasierte Bytecode-Verschlüsselung macht wenig Sinn und die Hardwareverwendung ist mit eigenen Problemen behaftet und zudem nicht immer möglich. Wie wäre es dann also, wenn man den Bytecode einfach unverständlicher machen würde? Und genau darum geht es bei der Quelltext-Obfuscation (Code Obfuscation) — der Binärcode wird so geändert, dass er bei der Ausführung zwar die gleichen Ergebnisse produziert, aber seine Funktion nach dem Dekompilieren wesentlich schwerer nachzuvollziehen ist.

Bei der Namens-Obfuscation werden die Bezeichner, die Sie sorgfältig nach den Codierungsstandards Ihres Unternehmens ausgewählt haben, wie zum Beispiel de.meineFirma.TradeSystem.Security.checkFingerprint(), durch bedeutungslose Zeichenfolgen wie zum Beispiel a.a0() ersetzt. (Natürlich muss ein Obfuscator die gesamte Anwendung verarbeiten, um eine einheitliche Namensänderung über alle Klassen und JAR-Dateien hinweg sicherzustellen.)

Die Ausgereifteren dieser Tools gehen einen Schritt weiter. Wie Sie wissen, kann eine Java-Klasse mehrere Methoden gleichen Namens aufweisen, sofern die Signaturen unterschiedlich sind. Diese Tatsache macht sich der Obfuscator zunutze, indem er beispielsweise setPos(int x, int y) und setColor(int color) in sagen wir a(int a, int b) und a(int a) umbenennt.

Ein netter Nebeneffekt der Namens-Obfuscation ist die deutliche Größenreduzierung der Klassendatei, was etwas kleinere Downloads und schnellere Cold Starts von Desktop-Java-Anwendungen zur Folge hat und es Ihnen ermöglicht, mehr Spiele Apps auf Ihrem Android-Smartphone zu installieren. Aber wie jede andere Technik ist auch die Namens-Obfuscation mit Einschränkungen und Nachteilen verbunden:

  • Die Namen von Java API-Standardklassen, die Teil der JRE sind, können nicht verschleiert werden, d. h. sämtliche Verwendungen dieser Klassen bleiben im dekompilierten Code eindeutig sichtbar.
  • Entitäten, auf die zur Laufzeit per Reflection oder JNI zugegriffen wird, können nicht umbenannt werden. Das Problem ist, dass Sie nicht mit Sicherheit sagen können, auf welche Klasse oder Methode dynamisch zugegriffen wird. Dies gilt insbesondere, wenn die Klasse bzw. Methode zu einer Bibliothek, einer Komponente oder einem Framework eines Drittanbieters gehört oder sich auf einen Teil Ihrer Anwendung bezieht, der von jemand anderem geschrieben wurde.

    Viele Frameworks und Tools sind in hohem Maße auf Reflection angewiesen. Ein bemerkenswertes Beispiel ist die JavaBeans-Komponentenarchitektur mit den entsprechenden Tools für die visuelle Programmierung. Die EJB-Spezifikation erfordert (und der Container erzwingt) bestimmte Signaturen für Callback-Methoden wie zum Beispiel ejbCreate().

  • Die Namen von serialisierbaren Klassen können nicht verschleiert werden. Die meisten Obfuscatoren würden Klassen, die die Schnittstelle java.io.Serializable implementieren, automatisch außen vor lassen. Ähnliches gilt für die RMI-Suffixe _Stub und _Skel sowie für Klassen, die java.rmi.Remote erweitern. Sie müssen ebenfalls den Ausschlussmechanismus des Obfuscators auslösen.

Die String-Verschlüsselung ist eine weitere Funktion, die häufig in Java-Obfuscatoren anzutreffen ist. Das Ersetzen von Strings durch das Aufrufen einer Methode, die den verschlüsselten Parameter entschlüsselt, macht das Hackerleben etwas interessanter, wenn auch nicht viel.

Das Problem ist, dass die Strings zur Laufzeit entschlüsselt werden müssen, also muss der entsprechende Code in der Anwendung enthalten sein. Außerdem ist die String-Verschlüsselung bei den meisten Tools so simpel, dass der Hacker den Code noch nicht einmal Reverse Engineeren muss! Alles, was er tun muss, ist ein Programm zu schreiben, dass die Entschlüsselungsmethode(n) für alle Strings aufruft.

Einfach ausgedrückt, geht es bei der Obfuscation der Ablaufsteuerung darum, das Programm so zu modifizieren, dass es bei der Ausführung das gleiche Ergebnis liefert, es aber unmöglich ist, das Programm in eine wohlstrukturierte Java-Source zu dekompilieren und/oder der Code schwerer nachzuvollziehen ist.

Die meisten Code-Obfuscatoren ersetzen die von einem Java-Compiler produzierten Anweisungen durch goto- und andere Anweisungen, die in Java nicht direkt repräsentiert werden können. Ein Decompiler, der einen herkömmlichen javac-Output erwartet, würde entweder fehlschlagen oder Pseudocode mit etlichen Labels und goto-Anweisungen produzieren. Allerdings gibt es auch intelligentere Decompiler.

Ein interessanter, aber obskurer Ableger von Soot, dem von der Sable-Gruppe an der kanadischen McGill-Universität entwickelten Framework zur Analyse und Optimierung von Bytecode, ist das Decompiler-Projekt "Dava" [4]. Ziel dieses Projekts ist es, den von einem beliebigen Tool (also nicht nur vom javac-Compiler) produzierten Java-Bytecode in lesbaren Quellcode zu dekompilieren. Somit kann man hier in der Tat von dem Versuch sprechen, einen Deobfuscator zu entwickeln.

(Das Witzige ist, dass andere Mitglieder derselben Gruppe an einem Java-Bytecode-Obfuscator namens JBCO arbeiten. Es wäre interessant zu wissen, ob sie innerhalb der Gruppe eine Art Turnier zwischen dem Verschleiern und dem Dekompilieren von Code veranstalten.)

Aber auch wenn Sie einen Obfuscator verwenden, der alle Decompiler in die Knie zwingt, leistet ein Bytecode-Disassembler noch ganze Arbeit. Wie bereits erwähnt, besteht der JVM-Befehlssatz im Vergleich zu echten CPUs wie x86 oder ARM aus Higher-Level- Anweisungen, sodass disassemblierter Java-Code einfacher zu verstehen ist als disassemblierter C++-Code. Es würde daher Sinn machen, auch die Gesamtstruktur des Programms zu "verzerren". Ausgereiftere Obfuscation-Verfahren setzen auf Änderungen der Klassenhierarchie, Inlining und Outlining von Methoden, Loop Unrolling, Folding/Flattening von Arrays usw.

Das Einbinden einer eigenen Virtual Machine in die Anwendung und das Übersetzen der empfindlichsten Methoden in den entsprechenden Befehlssatz ist vielleicht die effektivste, aber gleichzeitig auch eine der aufwändigsten Transformationen.

Gewisse Algorithmen können auch durch mathematische Transformation geschützt werden. Der transformierte Code würde unter Verwendung verschiedener Datentypen die gleichen Ergebnisse berechnen. Allerdings sind solche Tools weitaus kostspieliger und häufig nur im Rahmen einer benutzerspezifischen Risikomanagementlösung verfügbar. Ein weiterer Nachteil ist, dass solche Transformationen zu einer Verlangsamung des Codes um Größenordnungen führen können, sodass sie besser nur auf äußerst wichtige Codeteile angewendet werden sollten, sofern diese nicht leistungskritisch sind.

Einschränkungen:

  • Zu stark veränderte Klassen bestehen möglicherweise strengere Bytecode-Verifizierungen nicht, die bei künftigen JVM-Implementierungen zum Einsatz kommen können.
  • Die Codefluss-Obfuscation hat negative Auswirkungen auf die Leistung.
  • Das Field Engineering kann sich schwierig gestalten.
  • Wie bereits oben erwähnt, können die Java API-Standardklassen nicht verschleiert werden, d. h. Verweise auf diese Klassen wären im dekompilierten/disassemblierten Code vorhanden. Ein Obfuscator kann jedoch einige der grundlegenden Java-APIs durch eigene verschleierte Implementierungen ersetzen.
  • Ergänzung 20. Feb 2015: Wie in [5] gezeigt können einige Code-Transformationen automatisch rückgängig gemacht werden.

Bei der Auswahl eines Obfuscators sollten ein paar Dinge berücksichtigt werden.

Inkrementelle Obfuscation

Wenn Sie planen, inkrementelle Updates für Ihre verschleierte Anwendung anzubieten, müssen Sie sicherstellen, dass die Namen der Klassen in der neuen Version Ihrer Anwendung mit denen in der ursprünglich an die Endanwender gelieferten Version übereinstimmen. Bei der Auswahl eines Obfuscators müssen Sie dafür sorgen, dass dieser die bei einem früheren Durchgang vorgenommenen Umbenennungen wieder exakt reproduzieren kann.

Optimierung von Klassendateien

Viele Obfuscatoren können optional die Klassendateien im Hinblick auf ihre Größe optimieren, indem sie unbenutzte Methoden, Felder, Strings, Designzeit-Metadaten usw. entfernen. Allerdings ist diese Funktion mit Vorsicht zu verwenden, da auf eine solche Methode oder Feld auch per JNI oder per Reflection zugegriffen werden kann, und es selbst durch eine Analyse des laufenden Programms nicht möglich ist, alle derartigen Zugriffe zuverlässig zu erkennen.

Zu den von einigen Obfuscatoren unterstützten Bytecode-Optimierungen gehören die Auswertung konstanter Ausdrücke, die Zuordnung von static- und final-Attributen, das Inlining einfacher Methoden wie "getter" und "setter", Peephole-Optimierungen usw. Die Vorteile derartiger Optimierungen sind jedoch nur in eingeschränkten Java ME CLDC-Umgebungen von Bedeutung. Ausgereiftere JVMs wie HotSpot von Sun/Oracle, J9 von IBM und JRockit von BEA (jetzt ebenfalls Oracle) würden diese und viele andere Optimierungen bei der JIT-Kompilierung anwenden. Und Sie sollten ihnen besser nicht im Weg stehen.

Obfuscation von Debug-Informationen

Standardmäßig schreibt der javac-Compiler die Quelldateinamen und optional die Zeilennummern (mit der Option -g) in die resultierenden Klassendateien. Diese Angaben sind erforderlich, um aussagekräftige Stack-Traces zu erhalten. Ein Obfuscator kann diese ganzen Informationen entfernen oder die Dateinamen und Zeilennummern in bedeutungslose Strings umwandeln. Wenn Sie bei der Lösung von Kundenproblemen auf Stapelüberwachungen angewiesen sind, sollten Sie sicherstellen, dass Ihr Obfuscator über ein Reverse Mapping-Utility verfügt, damit Sie die Stack-Traces wieder in die ursprünglichen Klassen- und Quelldateinamen zurückübersetzen können.

Auch Bibliotheken und Frameworks von bestimmten Drittanbietern benötigen Stack-Traces, um ordnungsgemäß zu funktionieren. Ein Beispiel hierfür ist Apache log4j.

Wasserzeichen

Einige Obfuscatoren können zum Schutz vor Softwarepiraterie eine verborgene Kunden- oder Distributor-ID in Ihre Klassendateien einbinden – ähnlich wie es bei digitalen Medien gemacht wird.

Source-Code Obfuscation

Nehmen wir einmal an, Ihr proprietärer Java-Quellcode löst in Ihrer Entwicklungsumgebung einen ärgerlichen Bug aus und Sie haben beschlossen, den Quellcode auf einen Testfall zu reduzieren. Bevor Sie den Code an den Anbieter der Entwicklungsumgebung senden, möchten Sie ihn vielleicht durch einen Quellcodeverschleierer laufen lassen, um Bezeichner unverständlich zu machen und Kommentare zu entfernen.

Wie Sie sehen können, sind alle drei Hauptansätze zum Schutz Ihres Java-Codes mit Einschränkungen und Nachteilen verbunden und keiner davon löst die grundlegenden Probleme, die in der Einleitung aufgeführt sind. Glücklicherweise gibt es eine Reihe von Werkzeugen, die ursprünglich mit dem Ziel entwickelt wurden, die Leistung von Java-Anwendungen zu verbessern. Bei diesen Tools handelt es sich um Ahead-Of-Time-Compiler, die Ihre JAR-Dateien und Klassen in optimierten nativen Code kompilieren und eine konventionelle ausführbare Datei produzieren.

Erinnern Sie sich noch an den Vergleich zwischen C++ und Java zu Beginn dieses Artikels? Die meisten Aussagen aus der Spalte für C++ treffen auch auf AOT-kompilierten Java-Code zu:

  • Nativer Low-Level-Befehlssatz
  • Hochoptimierter Code
  • Statisches Linking in eine monolithische Datei

Dies bringt uns natürlich auf die Idee, beim Schutz von Java-Code einen zweistufigen Ansatz zu verfolgen:

  1. Verschleiern der Namen und Verschlüsseln der Strings mithilfe der Tools, die nicht darauf angewiesen sind, dass die Anwendung als Bytecode bereitgestellt wird, wobei die Obfuscation der Ablaufsteuerung und die Datenfluss-Obfuscation deaktiviert sein müssen.
  2. Kompilieren der verschleierten Anwendung in optimierten nativen Code.

Weitere Informationen zu AOT-Compilern finden Sie in einem anderen Artikel von mir.

Eine Internet-Suche nach "Java Obfuscator" würde viel zu viele Ergebnisse liefern. Deshalb habe ich die Liste auf eine Handvoll aktiv gepflegter — sowohl kommerzieller als auch kostenfreier — Produkte gekürzt.

Produkt Lizenz Preis
Allatori Proprietär
Dash-O-Pro Proprietär €€€?
GuardIT for Java Proprietär €€€?
Proguard Open Source -
Stringer Proprietär
yGuard Freeware -
Zelix Klassmaster Proprietär

Zwei Produkte aus dieser Liste sind besonders hervorzuheben:

Stringer verschlüsselt nicht nur Strings wie der Name vermuten lässt, sondern kann auch in JAR-Dateien enthaltene Ressourcendateien – Bilder, Medienclips usw. – verschlüsseln, (standardmäßige) Bibliotheksaufrufe verbergen und Integritätsprüfungen in Ihre Anwendung einbauen. Auf diese Weise wird sichergestellt, dass nur nicht modifizierte Originalversionen Ihrer Klassendateien ordnungsgemäß ausgeführt werden können.

GuardIT for Java führt Code-/Namens-Obfuscation und String-Verschlüsselung durch, geht dann aber noch einen Schritt weiter, indem es Ihren Anwendungscode in ein aktives Schutzsystem packt, das Manipulationen in Echtzeit erkennen kann. Diese fortschrittlichen Verfahren arbeiten auf Bytecode-Ebene, sodass GuardIT for Java nicht vollständig mit AOT-Compilern kompatibel ist.

Wenn Sie Ihren Java-Quellcode verschleiern müssen, empfehlen sich die Thicket™-Quellcodeformatierer von Semantic Designs, die einen Java-Formatierer mit Obfuscator enthalten.

Schicken Sie mir eine E-Mail, wenn Sie ein Tool kennen, das in die Liste aufgenommen werden sollte. (Leider spreche ich kein Deutsch. Bitte schreiben Sie mir deshalb in Englisch oder Russisch.)

Wie bereits oben erwähnt, beschäftigt sich die Sable-Gruppe an der McGill-Universität im Rahmen ihrer Forschungsprojekte mit einem Java-Bytecode-Obfuscator namens JBCO. Er ist nicht kommerziell nutzbar und wird es vermutlich auch nie sein, aber wenn Sie vorhaben, einen besseren Obfuscator zu entwickeln, sollten Sie sich JBCO einmal etwas Genauer anschauen.

Bücher

Die Links auf Verlage und Shops sind keine Affiliate-Links.

Trotz des Titels Decompiling Java hat sich Godfrey Nolan auch in einem Kapitel mit dem Codeschutz beschäftigt und dessen Großteil wiederum dem Thema Code-Obfuscation gewidmet.

Aktualisierung 22. Mai 2013: Godfrey hat kürzlich ein neues Buch mit dem Titel Decompiling Android veröffentlicht, das ebenfalls einen Abschnitt über Codeschutz enthält.

Alex Kalinovsky beschäftigt sich in seinem Buch Covert Java: Techniques for Decompiling, Patching, and Reverse Engineering in erster Linie mit den Themen, die im Buchtitel angeführt werden, aber er hat auch ein Kapitel über die Obfuscation und das Knacken von verschleiertem Code geschrieben. Zufälligerweise ist genau dieses Kapitel online verfügbar, sodass ich Ihnen mal eben 20 Dollar gespart haben könnte.

Reversing: Secrets of Reverse Engineering von Eldad Eilam ist nicht Java-spezifisch, sondern bietet einen breiteten Überblick.

Beliebte Artikel

Wenn Sie mehr über Verfahren zur Code- und Datenfluss-Obfuscation erfahren möchten und sich dafür interessieren, wie sich die verschiedenen Verfahren in Sachen Wirksamkeit, Widerstandsfähigkeit und Kosten zueinander verhalten, dann empfiehlt sich die dreiteilige Reihe von Sonali Gupta, die von August bis Oktober 2005 im Palisade Magazine erschienen ist, als gute Startlektüre:

Wissenschaftliche Publikationen

Eine Gruppe von Forschern der Rowan University unter der Leitung von Prof. Ravi P. Ramachandran hat kommerzielle Java-Obfuscatoren untersucht und ihre Ergebnisse in zwei Artikeln zusammengefasst:

Wenn Sie etwas mehr Theorie und Kontroverse wünschen, sollten Sie den Artikel "On the (Im)possibility of Obfuscating Programs" von Boaz Barak et al. lesen, der im Bericht zur 21. Annual International Cryptology Conference erschienen ist und den Nachweis erbringt, dass Obfuscation unmöglich ist. Er sorgte für einige Diskussionen und Irritationen, sodass Boaz später ein Essay schrieb, in dem er erklärte, was das Ergebnis seiner Meinung nach wirklich zu bedeuten hat.

Weitere Informationen zum Softwareschutz im Allgemeinen finden Sie in der Veröffentlichung Watermarking, Tamper-Proofing, and Obfuscation - Tools for Software Protection von C. Collberg und C. Thomborson oder in der neueren Veröffentlichung Revisiting Software Protection von P.C. van Oorschot von der Carleton University in Kanada. In den Referenzen jedes dieser Werke sind wiederum Dutzende von Arbeiten aufgeführt, die Ihnen Lesestoff für viele Stunden liefern.

Herstellerveröffentlichungen

PreEmptive Solutions, der Hersteller des Java-Obfuscators Dash-O-Pro bietet diverse Whitepapers an.

  1. A. Sundararajan. Retrieving .class files from a running app, 2007
  2. V. Roubtsov. Cracking Java byte-code encryption, JavaWorld.com, 9. Mai-2003
  3. I. Kinash. A Gun is a Great Equalizer: OpenJDK Hack vs. Class Encryption, Java.DZone.com, 25. Juni 2012
  4. N. A. Naeem, L. Hendren. Programmer-friendly Decompiled Java, 2006 (PDF)
  5. D. Klein. Automating Removal of Java Obfuscation, 2015 NEU

Nehmen wir eine fiktive Anwendung, die Benutzerpasswörter als SHA-Digests speichert:

import java.io.UnsupportedEncodingException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.*;

public class Authentication {

    public static byte[] encryptPassword(String password) 
        throws UnsupportedEncodingException, NoSuchAlgorithmException
    {
        String saltedPassword = password + "Add-Some-Salt";
        byte[] digestive = saltedPassword.getBytes("ISO-8859-1");
        MessageDigest md = MessageDigest.getInstance("SHA");
        md.update(digestive);
        return md.digest();
    }

    public static boolean checkPassword(String password, byte[] digest) 
        throws UnsupportedEncodingException, NoSuchAlgorithmException
    {    
        if (Arrays.equals(encryptPassword(password),digest)) return true;
        System.out.println("Wrong password");
        return false;
    }
}

Wenn man diese Klasse mit dem javac-Standardcompiler kompiliert und dann die resultierende Klassendatei mit einem der kostenlos angebotenen Decompiler dekompiliert, erhält man folgendes Ergebnis:

import java.io.PrintStream;
import java.io.UnsupportedEncodingException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;

public class Authentication
{
    public static byte[] encryptPassword(String s)
        throws UnsupportedEncodingException, NoSuchAlgorithmException
    {
        String s1 = (new StringBuilder()).append(s).append("Add-Some-Salt").toString();
        byte abyte0[] = s1.getBytes("ISO-8859-1");
        MessageDigest messagedigest = MessageDigest.getInstance("SHA");
        messagedigest.update(abyte0);
        return messagedigest.digest();
    }

    public static boolean checkPassword(String s, byte abyte0[])
        throws UnsupportedEncodingException, NoSuchAlgorithmException
    {
        if(Arrays.equals(encryptPassword(s), abyte0))
        {
            return true;
        } else
        {
            System.out.println("Wrong password");
            return false;
        }
    }
}

Wie Sie sehen, sind die einzigen wesentlichen Unterschiede im dekompilierten Quellcode die automatisch generierten Namen der Parameter und lokalen Variablen.

Nun wollen wir den obigen Beispielcode durch einen Namensverschleierer laufen lassen und dann die resultierende Klasse dekompilieren:

import java.io.PrintStream;
import java.io.UnsupportedEncodingException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;

public class a
{
    public static byte[] a(String a)
        throws UnsupportedEncodingException, NoSuchAlgorithmException
    {
        String s = (new StringBuilder()).append(a).append("Add-Some-Salt").toString();
        byte abyte0[] = s.getBytes("ISO-8859-1");
        MessageDigest messagedigest = MessageDigest.getInstance("SHA");
        messagedigest.update(abyte0);
        return messagedigest.digest();
    }
    public static boolean a(String a, byte a[])
        throws UnsupportedEncodingException, NoSuchAlgorithmException
    {
        if(Arrays.equals(a(a), a))
        {
            return true;
        } else
        {
            System.out.println("Wrong password");
            return false;
        }
    }
}

Obwohl der Obfuscator die öffentlichen Bezeichner Authentication, encryptPassword() und checkPassword durch ein bedeutungsloses, überladenes a ersetzt hat, ist klar, dass diese Methoden mit der Sicherheits-API zu tun haben und den SHA-Algorithmus verwenden. Der Salt-String ist ebenfalls lesbar.

Die Aktivierung der String-Verschlüsselung macht den dekompilierten Code nun ein bisschen, sagen wir, kryptischer:

import java.io.PrintStream;
import java.io.UnsupportedEncodingException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;

public class b
{
    public static byte[] a(String a)
        throws UnsupportedEncodingException, NoSuchAlgorithmException
    {
        String s = (new StringBuilder()).append(a).append(a.a("X|~4Nws|<Ksu!")).toString();
        byte abyte0[] = s.getBytes(a.a("]FX9(-&-1d"));
        MessageDigest messagedigest = MessageDigest.getInstance(a.a("E_\024"));
        messagedigest.update(abyte0);
        return messagedigest.digest();
    }
    public static boolean a(String a, byte a[])
        throws UnsupportedEncodingException, NoSuchAlgorithmException
    {
        if(Arrays.equals(a(a), a))
        {
            return true;
        } else
        {
            System.out.println(a.a("Cgxzw5cuofh{j1"));
            return false;
        }
    }
}

Okay, die Strings sind nun verschlüsselt, aber die Importliste ist nach wie vor da und es ist mehr als offensichtlich, dass die Methoden die Java-Sicherheits-API verwenden. Für einen Hacker wäre es also immer noch ein Leichtes, den empfindlichen Code ausfindig zu machen. Und dazu müsste er noch nicht einmal den Verschlüsselungsalgorithmus umkehren. Er muss lediglich die Aufrufe der Entschlüsselungsmethode von der dekompilierten Quelle extrahieren:

// crack.java

public class crack {

    public static void main( String[] args ) {
        System.out.println(a.a("X|~4Nws|<Ksu!"));
        System.out.println(a.a("]FX9(-&-1d"));
        System.out.println(a.a("E_\024"));
        System.out.println(a.a("Cgxzw5cuofh{j1"));
    }
}

...und dann den resultierenden Code kompilieren und ausführen:

$ javac crack.java
$ java crack
Add-Some-Salt
ISO-8859-1
SHA
Wrong password
$

Voilà!

Die fortschrittlicheren Tools wie Stringer ergreifen jedoch ernsthafte Gegenmaßnahmen gegen den oben beschriebenen Angriff.

Testen wir nun die Codefluss-Obfuscation. Auf den ersten Blick gibt es in unserem Beispiel nicht viel Code, der zu verschleiern ist. Außerdem sind die Code- und Datenflüsse recht simpel: Nur eine Reihe von standardmäßigen API-Aufrufen ohne Schleifen oder Ausnahmebehandlung. Tatsächlich konnte der Obfuscator, den ich verwendet habe, nur eine Änderung vornehmen, nachdem ich die Code-Obfuscation aktiviert hatte:

        // Code ofbuscation disabled
        String s = (new StringBuilder())
                       .append(a)
                       .append(a.a("X|~4Nws|<Ksu!"))
                       .toString();
        byte abyte0[] = s.getBytes(a.a("]FX9(-&-1d"));
        // Code ofbuscation enabled
        byte abyte0[] = (new StringBuilder())
                            .append(a)
                            .append(a.a("X|~4Nws|<Ksu!"))
                            .toString()
                            .getBytes(a.a("]FX9(-&-1d"));

Vielleicht ist das nur ein Schwachpunkt der in einem bestimmten Produkt implementierten Obfuscator-Alogrithmen? Mit JBCO ist es tatsächlich möglich, das Ergebnis der Dekompilierung wesentlich unverständlicher zu machen. Aber bevor ich fortfahre, möchte ich zur Vorsicht mahnen:

MACHEN SIE DIES NICHT NACH!

Versuchen Sie nicht, JBCO in einer Produktionsumgebung einzusetzen. Es ist ein Forschungsprojekt und als solches zielt es darauf ab, den Forschern die Möglichkeit zu geben, ihre Ideen zu testen. Es ist weder skalierbar noch robust noch gut dokumentiert.

Aktualisierung 09. Juli 2014: Ich habe ein Upgrade auf die neueste JBCO-Version durchgeführt und finde das Tool nun wesentlich stabiler. Ich musste nur die Transformation bb.jbco_dcc (Missachtung von Konstruktor-Konventionen) deaktivieren, damit sich JBCO nicht aufhängt.

Mit der folgenden Befehlszeile ist es mir gelungen, JBCO bei der Originalversion von Authentication.class an die Grenzen seiner Leistungsfähigkeit zu bringen (mit einer main()-Methode, der ein paar Unit-Tests hinzugefügt wurden):

java -Xmx384m ^
  -cp sootclasses-2.5.0.jar;polyglotclasses-1.3.5.jar;jasminclasses-2.5.0.jar;. ^
  soot.jbco.Main ^
  -cp .;"%JAVA_HOME%\lib\rt.jar";"%JAVA_HOME%\lib\jce.jar" ^
  -t:9:wjtp.jbco_cr -t:9:wjtp.jbco_mr -t:9:wjtp.jbco_fr ^
  -t:9:wjtp.jbco_bapibm -t:9:wjtp.jbco_blbc ^
  -t:9:jtp.jbco_gia -t:9:jtp.jbco_adss -t:9:jtp.jbco_cae2bo ^
  -t:9:bb.jbco_cb2ji -t:9:bb.jbco_rds -t:9:bb.jbco_riitcb ^
  -t:9:bb.jbco_iii -t:9:bb.jbco_plvb -t:9:bb.jbco_rlaii ^
  -t:9:bb.jbco_ctbcb -t:9:bb.jbco_ecvf -t:9:bb.jbco_ptss ^
  -main-class Authentication ^
  Authentication

Folgende Meldungen wurden vom verwirrten Decompiler ausgegeben:

Couldn't fully decompile method main
Couldn't resolve all exception handlers in method main
Couldn't fully decompile method $S5
Couldn't resolve all exception handlers in method $S5
Couldn't fully decompile method I1l
Couldn't resolve all exception handlers in method I1l
Couldn't fully decompile method <clinit>
Couldn't resolve all exception handlers in method <clinit>

Und hier ist die Quelle, die er produziert hat:

import java.io.PrintStream;
import java.io.UnsupportedEncodingException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;

public class Authentication
{

    public Authentication()
    {
    }

    public static void main(String args[])
    {
_L3:
        args = args;
_L1:
        JVM INSTR pop ;
        return;
        if(!S$5)
        {
            args = $S5(S5$);
            if(!I1l(S5$, args))
                throw new AssertionError();
        }
        JVM INSTR pop ;
        if(!S$5)
        {
            args = $S5(I1l);
            if(I1l($$5S, args))
                throw new AssertionError();
        }
        JVM INSTR pop ;
          goto _L1
        args;
        if(true) goto _L3; else goto _L2
_L2:
        args;
        if((byte)0x2073a663 % 3 == 0)
            break MISSING_BLOCK_LABEL_98;
        null;
        throw ;
        throw args;
    }

    public static byte[] $S5(String s)
        throws UnsupportedEncodingException, NoSuchAlgorithmException
    {
_L2:
        StringBuilder stringbuilder;
        I1l(stringbuilder, stringbuilder = main($S5));
        stringbuilder = I1l(stringbuilder);
        return stringbuilder;
        stringbuilder = JVM INSTR new #94  <Class StringBuilder>;
        stringbuilder.StringBuilder();
        stringbuilder = I1l(s, stringbuilder);
        stringbuilder = main(I1l($$$S, stringbuilder));
        stringbuilder = main(l1I, stringbuilder);
        if(true) goto _L2; else goto _L1
_L1:
        throw ;
    }

    public static boolean I1l(String s, byte abyte0[])
        throws UnsupportedEncodingException, NoSuchAlgorithmException
    {
        if(main($S5(s), abyte0))
            return SS$5;
        JVM INSTR pop ;
        I1l(I1I, System.out);
        ll1.booleanValue();
        JVM INSTR ifge 39;
           goto _L1 _L2
_L1:
        break MISSING_BLOCK_LABEL_37;
_L2:
        break MISSING_BLOCK_LABEL_39;
        null;
        throw ;
        return ____;
    }

    public static boolean $S5(Class class1)
    {
        return class1.desiredAssertionStatus();
    }

    public static StringBuilder I1l(String s, StringBuilder stringbuilder)
    {
        return stringbuilder.append(s);
    }

    public static String main(StringBuilder stringbuilder)
    {
        return stringbuilder.toString();
    }

    public static byte[] main(String s, String s1)
    {
        return s1.getBytes(s);
    }

    public static MessageDigest main(String s)
    {
        return MessageDigest.getInstance(s);
    }

    public static void I1l(byte abyte0[], MessageDigest messagedigest)
    {
        // Placing a breakpoint here would reveal the salt string
        messagedigest.update(abyte0);
    }

    public static byte[] I1l(MessageDigest messagedigest)
    {
        return messagedigest.digest();
    }

    public static boolean main(byte abyte0[], byte abyte1[])
    {
        return Arrays.equals(abyte0, abyte1);
    }

    public static void I1l(String s, PrintStream printstream)
    {
        printstream.println(s);
    }

    static final boolean S$5;
    public static Boolean ll1;
    public static boolean ___;
    public static int ____;
    public static int SS$5;
    public static String $$$S;
    public static String l1I;
    public static String $S5;
    public static String I1I;
    public static String S5$;
    public static String I1l;
    public static String $$5S = "foo";

    static 
    {
          goto _L1
_L3:
        Boolean boolean1;
        S$5 = boolean1;
        return;
_L1:
        I1l = "bar";
        S5$ = "pazzw0rd";
        I1I = "Wrong password";
        $S5 = "SHA";
        l1I = "ISO-8859-1";
        // JBCO does not encrypt strings
        $$$S = "Add-Some-Salt";
        SS$5 = 1;
        boolean1 = JVM INSTR new #68  <Class Boolean>;
        if((byte)0x3bbe5594 % 3 == 0)
            break MISSING_BLOCK_LABEL_61;
        null;
        throw ;
        boolean1.Boolean(true);
        ll1 = boolean1;
        if(!$S5(Authentication))
        {
            boolean1 = 1;
            continue; /* Loop/switch isn't completed */
        }
        JVM INSTR pop ;
        boolean1 = 0;
        if(true) goto _L3; else goto _L2
_L2:
        throw ;
    }
}

Die Zurücksetzung des obigen Codes in Java-Quellcode, der der Originaldatei Authentication.java ähnelt, ist eine sehr zeitaufwendige Aufgabe. Aber das ist auch nicht unbedingt das, was ein Hacker vorhat. Das Ausführen der Anwendung unter einem Debugger mit einem auf MessageDigest.update() gesetzten Haltepunkt kann genug Informationen über das in dieser fiktiven Anwendung verwendete Passwortverschlüsselungsschema liefern.

Außerdem ist zu beachten, dass JBCO keine Strings verschlüsselt.

Vielleicht fragen Sie sich jetzt, inwiefern sich solch umfangreiche Transformationen auf die Leistung der Anwendung auswirken. Dasselbe habe ich mich auch gefragt und deshalb bestand mein nächster Schritt darin, eine bekannte Benchmark-Suite durch JBCO laufen zu lassen.

Dabei habe ich mich für SciMark 2.0a entschieden. Der Benchmark misst die Leistung der numerischen Berechnungen, die man normalerweise in wissenschaftlichen und technischen Anwendungen findet. Und genau diese Arten von Anwendungen möchte man schließlich vor Dekompilierung schützen.

Ein weiterer Vorteil von SciMark ist, dass es das Ergebnis jedes Tests validiert. Dies ist nützlich, um zu prüfen, ob die vom Obfuscator vorgenommenen Transformationen die Semantik des Originalcodes beibehalten. (Genau genommen musste ich auch die Obfuscation des Validierungscodes deaktivieren, um einen 100-prozentigen Nachweis zu erbringen.)

Aktualisierung 09. Juli 2014: Ich habe ein Update auf die neueste JBCO-Version durchgeführt und die Tests mit der Oracle JRE 7 erneut durchgeführt. (JBCO scheint nicht mit Java 8 kompatibel zu sein.)

Dabei habe ich die folgende JBCO-Befehlszeile verwendet:

java -Xmx384m ^
  -cp sootclasses-2.5.0.jar;polyglotclasses-1.3.5.jar;jasminclasses-2.5.0.jar;. ^
  soot.jbco.Main ^
  -cp .;scimark2lib.jar;"%JAVA_HOME%\jre\lib\rt.jar";"%JAVA_HOME%\jre\lib\jce.jar" ^
  -t:9:wjtp.jbco_cr -t:9:wjtp.jbco_mr -t:9:wjtp.jbco_fr ^
  -t:9:wjtp.jbco_bapibm -t:9:wjtp.jbco_blbc ^
  -t:9:jtp.jbco_gia -t:9:jtp.jbco_adss -t:9:jtp.jbco_cae2bo ^
  -t:9:bb.jbco_cb2ji -t:9:bb.jbco_rds -t:9:bb.jbco_riitcb ^
  -t:9:bb.jbco_iii -t:9:bb.jbco_plvb -t:9:bb.jbco_rlaii ^
  -t:9:bb.jbco_ctbcb -t:9:bb.jbco_ecvf -t:9:bb.jbco_ptss ^
  -app jnt.scimark2.commandline >log 2>err

SciMark meldet die Messergebnisse in Form von Punkten. Je höher die Punktzahl, desto besser. Die unverschleierte Originalversion hat die folgende Ausgabe auf einem 64‑Bit Oracle HotSpot Server  7 Update 55 produziert:

SciMark 2.0a

Composite Score: 800.1861429549165
FFT (1024): 489.95521101714513
SOR (100x100):   779.5125051960619
Monte Carlo : 340.00691152439737
Sparse matmult (N=1000, nz=5000): 809.8362710126169
LU (100x100): 1581.6198160243612
   .  .  .

Im Vergleich dazu ist die verschleierte Version langsam wie eine Schnecke:

SciMark 2.0a

Composite Score: 243.4813684046077
FFT (1024): 126.6880218231721
SOR (100x100):   549.6164050131032
Monte Carlo : 54.273241127590865
Sparse matmult (N=1000, nz=5000): 286.80964716134747
LU (100x100): 200.01952689782485
   .  .  .

Die Verlangsamung reicht von Faktor 1,4 beim SOR-Test bis zu Faktor 7,9 für LU. Die Gesamtpunktzahl der verschleierten Version ist 3,3-mal niedriger!

In der Tat ist dies eine gewaltige Verbesserung gegenüber J2SE 5.0, mit der ich die Tests für die Originalversion dieses Artikels durchführen musste. Damals erstreckten sich die Verlangsamungsfaktoren von 3,3 für den Monte Carlo-Test bis zu mehr als 30 für SOR und LU. Die Gesamtpunktzahl der verschleierten Version war 22,8-mal schlechter.

Auf der einen Seite bedeutet dies, dass Sie vorsichtig sein müssen, wenn Sie leistungsrelevanten Code verschleiern. Auf der anderen Seite kann es ein Zeichen für die Unzulänglichkeit der Obfuscation sein, wenn die Obfuscation der Ablaufsteuerung keine oder nur geringe Auswirkungen auf die Leistung hat. Was ein optimierender Compiler wie HotSpot herausfinden kann, kann auch ein Mensch mit durchschnittlichen Programmierkenntnissen herausfinden – insbesondere, wenn er so etwas wie Understand for Java verwendet.

Dennoch behaupte ich nach wie vor, dass die Ahead-Of-Time-Kompilierung in nativen Code weitaus besser ist als Obfuscation und ich lade Sie ein, Excelsior JET zu testen, eine Java SE 8-kompatible JVM mit AOT-Compiler, die von meinem Unternehmen hergestellt wird. Hatten Sie am Ende eines Artikels auf einer Hersteller-Website etwa etwas anderes erwartet?

Eine Open-Source-Alternative zu Excelsior JET ist GNU Compiler for Java (GCJ). GCJ unterstützt mehrere Plattformen, liegt aber in puncto Standard-Compliance weit zurück und wird seit der OpenJDK-Bekanntgabe kaum noch gewartet. Schauen Sie sich für einen direkten Vergleich der beiden Produkte bitte meinen anderen Artikel an.

Ich möchte noch einmal auf die Interoperabilität von AOT-Compilern mit Tools für den Schutz von Java-Code hinweisen, die nicht darauf angewiesen sind, dass die geschützte Anwendung im Bytecode-Format vorliegt. Wenn Sie maximalen Schutz wünschen, können Sie vor der nativen Kompilierung mit diesen Tools die Klassen-/Feld-/Methodennamen verschleiern und die Strings verschlüsseln.

Ich werde den Inhalt dieses Artikels regelmäßig aktualisieren. Wenn Sie Anregungen und Kommentare haben oder URLs von Ressourcen/Tools kennen, die ich dem Artikel noch hinzufügen sollte, freue ich mich über Ihre E-Mail. (Leider spreche ich kein Deutsch. Bitte schreiben Sie mir deshalb in Englisch oder Russisch.)

Hat Ihnen der Artikel gefallen? Wenn ja, haben wir noch mehr für Sie!

Weitere Artikel von Excelsior-Mitarbeitern: