Einleitung
Wie schon in einem vorangegangenen Blog-Artikel erwähnt, stellen wir bei der Durchführung von Rich-Client Penetrationstests immer wieder fest, dass sicherheitsrelevante Funktionen im Client verankert sind. Ein sehr häufiges Beispiel ist, dass die Berechtigungsprüfung dahingehend implementiert wird, dass gewisse Menüeinträge bzw. Buttons nur unter bestimmten Voraussetzungen angezeigt werden. Was aus Sicht der Benutzerfreundlichkeit durchaus Sinn macht, ist in Bezug auf die Sicherheit leider nicht empfehlenswert.
Da wir neben .NET-Applikationen auch häufiger mit Java-Applikationen zu tun haben, möchte ich im heutigen Artikel verdeutlichen, dass es auch in dieser Sprache äußerst einfach ist, Schutzmaßnahmen zu umgehen.
Vieles aus dem .NET-Artikel lässt sich auch auf dieses Thema übertragen, weshalb in dem heutigen Artikel der Fokus nicht auf der Frage liegt, warum Byte-Code-Analysen wichtig sind. Hierzu gibt Ihnen der Artikel zur .NET-Applikation nähere Informationen.
Zugriff auf den Byte-Code
Wie .NET auch bietet Java einige Möglichkeiten, den Source Code zu dekompilieren. Schauen wir uns das Ganze an einem Beispiel an. Vergleicht man den originalen Code mit der dekompilierten Version, ist deutlich zu erkennen, dass diese nahezu identisch sind:
public class SuperSafe {
public static boolean checkUsername() {
String currentDomain = System.getProperty("user.name");
if(currentDomain.toLowerCase() == "securai") {
return true;
} else {
return false;
}
}
public static void main(String[] args) {
if (SuperSafe.checkUsername()) {
System.out.println("You are trustworthy! Here is our secret: ...");
} else {
System.out.println("You shall not pass!");
}
}
}
Wird dieser Code kompiliert und mit “Luyten” angezeigt, erhält man folgendes Ergebnis:
Es gibt Fälle, bei denen der Source nicht korrekt wiedergegeben wird, da er zu komplex ist. Doch auch hier bekommt man dadurch bereits einen ersten Überblick, was man mit der Applikation erreichen kann.
Analyse des Programms
Um die “Vertrauenswürdigkeit” eines Benutzers zu gewährleisten, greift der Code auf den Benutzernamen des Anwenders zurück und vergleicht diesen mit einem festgelegten Wert “securai”.
Nun gibt es mehrere Wege zu unserem Ziel, dem Geheimnis.
Am einfachsten wäre wohl, das Ganze auf einem Computer mit einem Benutzer mit dem Namen “securai” auszuführen. Dies funktioniert und sollte der Vollständigkeit halber erwähnt werden, da man sich nie auf Umgebungsvarianten verlassen sollte. Dennoch wollen wir hier mehr die technischen Varianten vorstellen.
Die ganze Ausführung hängt an der Überprüfung der checkUserName-Methode in der Main-Methode. Hier ist es am einfachsten, das Konstrukt zu negieren, sodass man immer vertrauenswürdig ist, wenn man nicht den Benutzernamen “securai” hat.
Disassemblierung
Um die eigentlichen Schritte eines Tools zu verstehen, sollte man erst einmal alle Schritte gesehen und von Hand vollzogen haben. Dies ist zwar ein umständlicher Weg, erleichtert aber später einiges.
Um ein näheres Verständnis von den Methoden zu erhalten, kann man sich mithilfe von “javap” den Byte-Code anzeigen lassen:
public class SuperSafe {
public SuperSafe();
Code:
0: aload_0
1: invokespecial #10 // Method java/lang/Object."<init>":()V
4: return
public static boolean checkUsername();
Code:
0: ldc #1 // String user.name
2: invokestatic #14 // Method java/lang/System.getProperty:(Ljava/lang/S
5: astore_0
6: aload_0
7: invokevirtual #15 // Method java/lang/String.toLowerCase:()Ljava/lang/
10: ldc #2 // String securai
12: if_acmpne 17
15: iconst_1
16: ireturn
17: iconst_0
18: ireturn
public static void main(java.lang.String[]);
Code:
0: invokestatic #20 // Method checkUsername:()Z
3: ifeq 9
6: getstatic #21 // Field java/lang/System.out:Ljava/io/PrintStream;
9: ldc #3 // String You are trustworthy! Here is our secret: .
11: invokevirtual #22 // Method java/io/PrintStream.println:(Ljava/lang/St
14: goto 25
17: getstatic #21 // Field java/lang/System.out:Ljava/io/PrintStream;
20: ldc #4 // String You shall not pass!
22: invokevirtual #22 // Method java/io/PrintStream.println:(Ljava/lang/St
25: return
}
In der Ansicht kann man mehrere Dinge erkennen; neben der initialen Klasse auch die Public-Methoden und die Funktionsblöcke. In den Funktionsblöcken ist der Funktionsablauf definiert, welcher in einzelnen Spalten Offset, Mnemonic, Typ und ASCII-Repräsentation darstellt.
Die Masochisten unter uns können sich jetzt die korrekten Offsets berechnen, die richtigen Hex-Werte heraussuchen und mithilfe eines Hex-Editors “iconst_1” (0x04), so wie “iconst_0” (0x03) in der Methode checkUserName austauschen, um die Überprüfung zu negieren.
Disassemblierung eines Programms
Wir können aber auch einfach einen Schritt weiter gehen und ein Programm namens “Krakatau” nutzen. Dieses erlaubt uns eine Disassemblierung in eine Zwischensprache und eine spätere Assemblierung, um die Veränderungen in der CLASS-Datei wirksam zu machen.
Krakatau liefert uns für die Methode checkUserName folgenden Code:
.method public static checkUsername : ()Z
.code stack 2 locals 1
L0: ldc 'user.name'
L2: invokestatic Method java/lang/System getProperty (Ljava/lang/String;)Ljava/lang/String;
L5: astore_0
L6: aload_0
L7: invokevirtual Method java/lang/String toLowerCase ()Ljava/lang/String;
L10: ldc 'securai'
L12: if_acmpne L17
L15: iconst_1
L16: ireturn
.stack append Object java/lang/String
L17: iconst_0
L18: ireturn
L19:
.linenumbertable
L0 3
L6 5
L15 6
L17 8
.end linenumbertable
.end code
.end method
Auf den ersten Blick unterscheiden sich javap und Krakatau Source nicht sehr. Man sollte auch hier seine Mnemonics kennen, um keinen Fehler zu machen. Doch kann mithilfe von Krakatau der Code wieder in funktionierenden Java Byte-Code übersetzt werden. Dies erlaubt einfache und schnelle Veränderungen am Byte-Code während eines Rich-Client-Penetrationtests ohne gründliches Wissen über Offsets.
Nach einem Austauschen von L15 und L17 kann die CLASS-Datei wieder assembliert und ausgeführt werden und man bekommt das Geheimnis ausgegeben, da die Überprüfung auf Benutzernamen geht, die nicht “securai” sind.
Natürlich können neben einfachen Negierungen von solchen Konstrukten auch weitaus komplexere Änderungen vorgenommen werden. Doch ist dies meist nicht einmal nötig, um eine Anwendung anzugreifen.
Bei einem von mir durchgeführten Audit kam es tatsächlich vor, dass durch eine einfache Negierung eines IF-Konstrukts einem unprivilegierten Benutzer ein geheimes Menü mit Zusatzfunktionen freigeschalten werden konnte. Hierüber hatte der Benutzer sogar Shell-Zugriff zum Server, trotz einer Drei-Schicht-Architektur.
Und die Moral von der Geschicht’?
Ganz klar: Vertrau dem Client nicht!
Hier kann man nicht oft genug erwähnen, dass alle Daten, die vom Client kommen, manipuliert sein können. Deshalb ist der einzige Ort, an dem sicherheitsrelevante Prüfungen durchgeführt werden dürfen, der Applikationsserver.
Dies kann in nahezu allen Fällen nur durch eine Drei-Schicht-Architektur Ihrer Software umgesetzt werden, also der Schicht eines Clients, den Einsatz eines Applikationsservers und dahinterliegend der Datenbank. Wichtig ist noch zu erwähnen, dass der Applikationsserver allein auf die Datenbank zugreift. Greift der Client direkt auf die Datenbank zu, ist es fast unmöglich, ein vernünftiges Berechtigungskonzept zu implementieren.