Хакер - Грязный Джо. Взламываем Java-приложения с помощью dirtyJOE
hacker_frei
МВК
Способы обхода триала в различных программах — одна из самых интересных тем прикладного реверс‑инжиниринга, и я не уже не раз посвящал ей свои статьи. Настало время вернуться к этой тематике снова. Наш сегодняшний пациент — приложение, выполненное в виде JAR-модуля, которое мы исследуем без полного реверса и пересборки проекта.
WARNING
Статья имеет ознакомительный характер и предназначена для специалистов по безопасности, проводящих тестирование в рамках контракта. Автор и редакция не несут ответственности за любой вред, причиненный с применением изложенной информации. Распространение вредоносных программ, нарушение работы систем и нарушение тайны переписки преследуются по закону.
В заметке «В обход стражи. Отлаживаем код на PHP, упакованный SourceGuardian» мы рассматривали программу, реализованную в виде локального веб‑интерфейса. Работает она так: под Windows запускается локальный сервер Apache c набором PHP-модулей, а пользователь взаимодействует с приложением через браузер, в котором набирает адрес localhost. Программа, взломом которой мы займемся сегодня, действует похожим образом, только написана она на Java и поставляется в виде файла .JAR. Наша задача — отучить приложение от деморежима.
По счастью, нам известно, где лежат стартующие в виде сервиса исполняемые модули программы в формате .EXE и соответствующий JAR-файл. По своей сути JAR — это обычный ZIP-архив, в который упакованы части проекта. Поскольку мы собираемся править код, нас интересуют модули *.CLASS, содержащие откомпилированный JVM-байт‑код. Декомпиляторов и способов их применения множество, существуют даже инструменты вроде JD-GUI, способные полностью восстановить проект из исполняемого файла. Чаще всего взломщики используют общеизвестный JAD, который из‑за его распространенности ловкие обфускаторы давно научились обманывать, что, в свою очередь, стало причиной появления более продвинутых декомпиляторов вроде CFR. Эта война щитов и мечей, пуль и бронежилетов обещает быть долгой, нам остается только запастись попкорном. Но не будем тут останавливаться, а вместо этого предположим, что мы декомпилировали проект одним из описанных способов до Java-исходников и даже проанализировали полученный код.
Применительно к нашему подопытному приложению это выглядело примерно так. Декомпилировав все‑все‑все CLASS-файлы, мы так и не обнаружили ничего похожего на обращение к лицензии, однако в подкаталоге BOOT-INF/lib нашего JAR-архива нашлось множество упакованных JAR-библиотек, среди которых сразу бросилась в глаза библиотека license-1.2.12.jar. Распаковав и декомпилировав ее, мы наткнулись на два CLASS-модуля, содержащих две любопытные функции. Одна возвращает демонстрационный режим, вторая активирует опцию 1 по умолчанию:
public boolean isDemo() {
return this.getPublicDataHash().isEmpty();
}
public void setDefault() {
if (this.hasModule(1)) {
Iterator iter = this.modulesItems.entrySet().iterator();
while (iter.hasNext()) {
Map.Entry item = iter.next();
if ((Integer)item.getKey() == 1) continue;
((BaseModule)item.getValue()).close();
iter.remove();
this.onModuleUpdated((Integer)item.getKey());
}
} else {
this.closeModules();
if (!this.modulesConfig.containsKey(1)) {
return;
}
BaseModule mod = this.getModule(1);
if (mod != null) {
mod.setEnabled(true);
this.modulesItems.put(1, mod);
log.info("Default module loaded {}", (Object)mod.getName());
this.onModuleUpdated(1);
}
}
}
Наша задача — сделать так, чтобы функция isDemo всегда возвращала false, а в функции setDefault нужно заменить опцию 1 опцией 256. Вот здесь и начинается самое интересное, то, ради чего и написана эта статья.
Ты спросишь: раз у нас имеются в наличии все исходники и код, то почему бы просто не перекомпилировать весь проект, поменяв эти две процедуры на нужные? К сожалению, прямой метод не всегда самый простой. В нашем случае в интересующих нас модулях много зависимостей, а проект очень большой, многие модули сильно обфусцированы. Кроме того, код восстановился частично с кучей ошибок, из‑за чего проект полностью не соберется. Можно, конечно, покопать обфускацию и попробовать руками вытащить исходный текст программы, но решать эту (возможно, даже, гораздо более сложную) задачу ради двух простых патчей в коде как‑то лень. Вдобавок, пересборке проекта может помешать отсутствие установленного JDK на компьютере. Устанавливать его и разбираться в особенностях компиляции Java-проектов мне тоже неохота. Поэтому мы, как обычно, ищем самый простой путь — патч откомпилированного JVM-кода.
В этом нам поможет интересная, но малоизвестная утилита dirtyJOE. Открываем в ней наш CLASS-модуль, на вкладке Methods видим полный список методов класса. Находим в нем искомую isDemo и тыкаем в нее, открывая окно редактирования.

Это, конечно, не исходник на Java, но здесь хотя бы можно редактировать байт‑код, сверяясь с логикой исходника. Возможности программы минималистичны: редактировать можно только в виде hex-значений кодов инструкций. По счастью, мнемоника и описание текущей исправленной инструкции отображается в окошке над окном кода, а сам список инструкций с описанием каждой имеется в хелпе (причем только список, без опкодов: явно, чтобы хакерам жизнь медом не казалась и пришлось искать шестнадцатеричные опкоды инструкций самостоятельно). По сути, нам надо закоротить данную функцию, сделав возвращаемым значением 0 (false). Находим в таблице инструкцию помещения 0 на стек (iconst_0), ее опкод (3) и ставим ее в самое начало метода, а после нее — сразу возврат (ireturn).

Закрываем окно редактирования, сохраняем CLASS-модуль, затем меняем исправленный модуль в архиве license-1.2.12.jar, который, в свою очередь, копируем на место старого в основном JAR-модуле. С предвкушением перезапускаем программу и обнаруживаем, что она не работает. Мы что‑то сделали не так.
Для понимания сути проблемы надо искать логи программы. По счастью, любое Java-приложение практически всегда пишет свой системный лог, причем не один. В нашем случае в логе присутствует вот такая ошибка:
Caused by: java.lang.IllegalStateException: Unable to open nested entry 'BOOT-INF/lib/license-1.2.12.jar'. It has been compressed and nested jar files must be stored without compression. Please check the mechanism used to create your executable jar file
Теперь все ясно: для успешного чтения библиотеки Java требуется файл с нулевой компрессией. Оно и понятно — зачем сжимать уже компрессированный файл? Что ж, сохраняем данную библиотеку с нулевой компрессией, перезапускаем — снова неудача. Ошибка в журнале на этот раз вообще невразумительная, исходя из ее логики, сама библиотека license-1.2.12.jar собрана как‑то неправильно. Безрезультатно помаявшись некоторое время c разными архиваторами, делаем логичное предположение, что проблема кроется в архиваторе, которым мы собираем файл библиотеки. Скачиваем родной сборщик jar.exe из пакета JDK и пробуем собрать файл с его помощью. В итоге получаем новую ошибку:
"C:\Program Files\Java\jdk-17.0.1\bin\jar.exe" -u -f license-1.2.12.jar com\license\service\LicenseHandler.class
java.util.zip.ZipException: duplicate entry: META-INF/maven/org.slf4j/slf4j-api/pom.properties
at java.base/java.util.zip.ZipOutputStream.putNextEntry(ZipOutputStream.java:241)
at java.base/java.util.jar.JarOutputStream.putNextEntry(JarOutputStream.java:115)
at jdk.jartool/sun.tools.jar.Main.update(Main.java:961)
at jdk.jartool/sun.tools.jar.Main.run(Main.java:338)
at jdk.jartool/sun.tools.jar.Main.main(Main.java:1665)
Внезапная проблема возникла на ровном месте: казалось бы, простейшую операцию сборки файлов в один архив не может проделать корректно ни один виндовый архиватор, включая родной сборщик JAR. Разгадка проста: архиваторы работают с модулями как с обычными файлами, у которых регистронезависимые имена. А имена Java-классов вполне себе регистрозависимые, и хитрые обфускаторы давно просекли эту лазейку, переименовывая модули. В итоге проект содержит множество классов, отличающихся только регистром одной или более букв в названии. По счастью, данная проблема отсутствует у раритетных консольных архиваторов вроде ZIP или PKZIP, которые в режиме update могут обновлять JAR-модули с регистрозависимыми именами. Итак, находим PKZIP, заменяем модуль через него, запускаем — и снова неудача! На этот раз ошибка в логе выглядит примерно так:
Constructor threw exception; nested exception is java.lang.VerifyError: Expecting a stack map frame
Exception Details:
Location:
com/license/service/type/LicenseData.isDemo()Z @2: nop
Reason:
Error exists in the bytecode
Bytecode:
0x0000000: 03ac 0015 b600 16ac
В чем смысл данной ошибки? Чтобы понять это, немного углубимся в теорию. Как известно, Java, так же как и .NET, для оптимизации работы не просто интерпретирует свой байт‑код, а компилирует его в натив во время выполнения. Этот процесс называется компиляцией just in time, или JIT, в одной из своих предыдущих статей я рассказывал о нем применительно к дотнету. Начиная с 7-й версии Java ввела более строгую проверку и немного изменила формат класса — чтобы содержать карту стека, используемую для проверки правильности кода. Данная ошибка возникает при компиляции байт‑кода метода isDemo: первые две инструкции, которые мы исправили, компилируются успешно, а вот следующая за ними по смещению 2 от начала метода (nop или опкод 0) вызывает ошибку верификации, поскольку у нее нет допустимой соответствующей карты стека.
По идее, в качестве обходного пути можно было бы добавить -noverify в аргументы JVM, чтобы отключить проверку. В Java 7 также -XX:-usesplitverifier позволяла использовать менее строгий метод проверки, но эта опция была удалена в Java 8. Разумеется, это не наш метод, ведь мы хотим получить после патча работоспособный код безо всяких костылей, тем более наша задача, как я уже говорил, стартует в качестве службы. Попробуем разобраться, как происходит верификация.
Компилятор разбивает байт‑код метода на участки по операциям ветвления. Контрольные точки находятся или сразу за операторами безусловных переходов (возвратов и прочих тупиковых веток кода), или в местах, на которые есть переходы. В этих точках контролируется состояние стека. Поскольку логика метода isDemo настолько линейна, что для ее верификации компилятор даже не стал заводить карту стека, то для примера возьмем другую процедуру, которую нам требуется поправить, — setDefault. Код ее после компиляции в JVM команды выглядит вот так:
0: aload_0
1: iconst_1
2: invokevirtual #51 // Method hasModule:(I)Z
5: ifeq 101
8: aload_0
9: getfield #4 // Field modulesItems:Ljava/util/concurrent/ConcurrentMap;
12: invokeinterface #20, 1 // InterfaceMethod java/util/concurrent/ConcurrentMap.entrySet:()Ljava/util/Set;
17: invokeinterface #21, 1 // InterfaceMethod java/util/Set.iterator:()Ljava/util/Iterator;
22: astore_1
23: aload_1 // <-------------- Первая контрольная точка, начало цикла, пункт назначения безусловного перехода из #58, #95, стек пуст, локальная переменная iter класса iterator
24: invokeinterface #22, 1 // InterfaceMethod java/util/Iterator.hasNext:()Z
29: ifeq 98
32: aload_1
33: invokeinterface #23, 1 // InterfaceMethod java/util/Iterator.next:()Ljava/lang/Object;
38: checkcast #24 // class java/util/Map$Entry
41: astore_2
42: aload_2
43: invokeinterface #46, 1 // InterfaceMethod java/util/Map$Entry.getKey:()Ljava/lang/Object;
48: checkcast #47 // class java/lang/Integer
51: invokevirtual #48 // Method java/lang/Integer.intValue:()I
54: iconst_1
55: if_icmpne 61
58: goto 23
61: aload_2 // <-------------- Вторая контрольная точка, сюда есть условный переход из #55, стек пуст, дополнительно к предыдущей локальная переменная Map.Entry item
62: invokeinterface #25, 1 // InterfaceMethod java/util/Map$Entry.getValue:()Ljava/lang/Object;
67: checkcast #8 // class com/license/modules/BaseModule
70: invokevirtual #44 // Method com/license/modules/BaseModule.close:()V
73: aload_1
74: invokeinterface #45, 1 // InterfaceMethod java/util/Iterator.remove:()V
79: aload_0
80: aload_2
81: invokeinterface #46, 1 // InterfaceMethod java/util/Map$Entry.getKey:()Ljava/lang/Object;
86: checkcast #47 // class java/lang/Integer
89: invokevirtual #48 // Method java/lang/Integer.intValue:()I
92: invokespecial #49 // Method onModuleUpdated:(I)V
95: goto 23
98: goto 171 // <-------------- Третья контрольная точка, мало того что следует за глухим безусловным переходом, вдобавок есть условный переход из #29, стек пуст, локальные переменные отсутствуют
101: aload_0 // <-------------- Четвертая контрольная точка, следует за глухим безусловным переходом, конец цикла #5, стек пуст, локальные переменные те же
102: invokespecial #52 // Method closeModules:()V
105: aload_0
106: getfield #7 // Field modulesConfig:Ljava/util/Map;
109: iconst_1
110: invokestatic #10 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
113: invokeinterface #27, 2 // InterfaceMethod java/util/Map.containsKey:(Ljava/lang/Object;)Z
118: ifne 122
121: return
122: aload_0 // <-------------- Пятая контрольная точка, сюда условный переход из #118, стек пуст, локальные переменные те же
123: iconst_1
124: invokespecial #53 // Method getModule:(I)Lcom/license/modules/BaseModule;
127: astore_1
128: aload_1
129: ifnull 171
132: aload_1
133: iconst_1
134: invokevirtual #54 // Method com/license/modules/BaseModule.setEnabled:(Z)V
137: aload_0
138: getfield #4 // Field modulesItems:Ljava/util/concurrent/ConcurrentMap;
141: iconst_1
142: invokestatic #10 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
145: aload_1
146: invokeinterface #55, 3 // InterfaceMethod java/util/concurrent/ConcurrentMap.put:(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;
151: pop
152: getstatic #31 // Field log:Lorg/slf4j/Logger;
155: ldc #56 // String Default module loaded {}
157: aload_1
158: invokevirtual #57 // Method com/license/modules/BaseModule.getName:()Ljava/lang/String;
161: invokeinterface #58, 3 // InterfaceMethod org/slf4j/Logger.info:(Ljava/lang/String;Ljava/lang/Object;)V
166: aload_0
167: iconst_1
168: invokespecial #49 // Method onModuleUpdated:(I)V
171: return // <-------------- Последняя контрольная точка, сюда условный переход из #129, стек пуст, локальные переменные те же
Теперь рассмотрим карту стека, которую компилятор сгенерировал для данной процедуры. К сожалению, dirtyJOE достаточно старый и сырой инструмент, чтобы править или хотя бы отображать карту стека. Максимум, что он может показать, — это ее наличие в виде атрибута метода StackMapTable. Поэтому для просмотра карты стека воспользуемся стандартной утилитой javap из пакета JDK:
"C:\Program Files\Java\jdk-17.0.1\bin\javap.exe" -v LicenseModules.class
StackMapTable: number_of_entries = 6 <----- Шесть фреймов всего
frame_type = 252 /* append */ <----- Первая точка, тип append означает, что фрейм имеет те же локальные переменные, что и предыдущий (которого у нас нет, так как фрейм первый), за исключением того, что определены k дополнительных локальных переменных и что стек операндов пуст. Значение k определяется формулой frame_type – 251 = 252 – 251 = 1, локальная переменная
offset_delta = 23 <----- Смещение от начала модуля
locals = [ class java/util/Iterator ] <----- Тип локальной переменной
frame_type = 252 /* append */ <---- То же самое, что и предыдущий, но добавилась локальная переменная 252 – 251 = 1
offset_delta = 37 <-----Смещение от предыдущего фрейма, то есть 24 + 37 = 61
locals = [ class java/util/Map$Entry ] <----- Тип новой локальной переменной
frame_type = 249 /* chop */ <---- Такой тип фрейма имеет те же локальные переменные, что и предыдущий фрейм, за исключением того, что отсутствуют последние k локальных переменных и что стек операндов пуст. Значение k определяется формулой 251 – frame_type = 251 – 249 = 2, две локальные переменные убираются
offset_delta = 36 <----- Смещение от предыдущего фрейма, то есть 62 + 36 = 98
frame_type = 2 /* same */ <----- Этот тип фрейма указывает, что фрейм имеет точно такие же локальные переменные, что и предыдущий фрейм, и что стек операндов пуст. Смещение определяется типом, то есть 99 + 2 = 101
frame_type = 20 /* same */ <----- То же, что и предыдущий, смещение 102 + 20 = 122
frame_type = 48 /* same */ <----- То же, что и предыдущий, смещение 123 + 48 = 171
Итак, я надеюсь, мне удалось донести в этом примере логику работы верификатора через stack map. Что нам это дает на практике? Во‑первых, становится понятно, почему не работает наш первоначальный патч isDemo: исходный код был линейным и никакой верификации через фреймы стека ему не требовалось, а наша правка мало того, что добавила контрольную точку (следующий байт за глухим ireturn), так еще и сделала хвост метода безумным для компилятора. Поскольку способа быстро и просто укоротить размер кода метода через dirtyJOE нет, то самый простой метод добиться успешного прохождения нашим кодом верификации — забить все тело nop’ами и только в конце оставить return false:
00000000 : nop
00000001 : nop
00000002 : nop
00000003 : nop
00000003 : nop
00000005 : iconst_0
00000006 : ireturn
Вообще говоря, стратегия патча в данном случае — следить за контрольными точками фреймов стека и при правке кода стараться не выходить за их пределы или хотя бы следить, чтобы классы локальных переменных и значений на стеке после правки соответствовали друг другу. Можно, конечно, при желании править и сами атрибуты StackMapTable в шестнадцатеричном редакторе, но этот крайний случай мы оставим для другой статьи. Чуть не забыл напомнить, что при сложной правке стоит учитывать верификацию области видимости локальных переменных (атрибут localVariableTable) и блоков обработки исключений (окно Exceptions). По счастью, редактирование этих параметров достаточно элементарно и поддерживается в dirtyJOE.


Может показаться, что учесть все вышеописанные требования — чудовищно сложная задача, особенно когда метод использует весьма разветвленную логику, а правки увеличивают размер кода. Тем не менее это только на первый взгляд: при достаточной сноровке вполне реально найти необязательные места в коде, благодаря оптимизации которых можно расширить нужные. Я специально выбрал такой метод (setDefault), в котором за счет разницы в длинах команд (команда iconst_1 занимает один байт, а команда для замены sipush 256 — целых три) код существенно удлиняется. Тем не менее, имея представление о принципах верификации, даже в этом случае достаточно быстро можно смастерить хоть и не идеальный, но вполне рабочий патч, корректно проходящий верификацию и открывающий нужный режим в программе:
00000000 2A aload_0
00000001 04 iconst_1
00000002 B6 00 33 invokevirtual boolean com.license.modules.LicenseModules.hasModule(int)
00000005 99 00 60 ifeq pos.00000065
00000008 2A aload_0
00000009 B4 00 04 getfield java.util.concurrent.ConcurrentMap com.license.modules.LicenseModules.modulesItems
0000000C B9 00 14 01 00 invokeinterface java.util.Set java.util.concurrent.ConcurrentMap.entrySet(), 1
00000011 B9 00 15 01 00 invokeinterface java.util.Iterator java.util.Set.iterator(), 1
00000016 4C astore_1
00000017 2B aload_1
00000018 B9 00 16 01 00 invokeinterface boolean java.util.Iterator.hasNext(), 1
0000001D 99 00 45 ifeq pos.00000062
00000020 2B aload_1
00000021 B9 00 17 01 00 invokeinterface java.lang.Object java.util.Iterator.next(), 1
00000026 C0 00 18 checkcast java.util.Map$Entry
00000029 4D astore_2
0000002A 2C aload_2
0000002B B9 00 2E 01 00 invokeinterface java.lang.Object java.util.Map$Entry.getKey(), 1
00000030 C0 00 2F checkcast java.lang.Integer
00000033 B6 00 30 invokevirtual int java.lang.Integer.intValue()
00000036 04 iconst_1
00000037 A0 00 06 if_icmpne pos.0000003D
0000003A A7 FF DD goto pos.00000017
0000003D 2C aload_2
0000003E B9 00 19 01 00 invokeinterface java.lang.Object java.util.Map$Entry.getValue(), 1
00000043 C0 00 08 checkcast com.license.modules.BaseModule
00000046 B6 00 2C invokevirtual void com.license.modules.BaseModule.close()
00000049 2B aload_1
0000004A B9 00 2D 01 00 invokeinterface void java.util.Iterator.remove(), 1
0000004F 2A aload_0
00000050 2C aload_2
00000051 B9 00 2E 01 00 invokeinterface java.lang.Object java.util.Map$Entry.getKey(), 1
00000056 C0 00 2F checkcast java.lang.Integer
00000059 B6 00 30 invokevirtual int java.lang.Integer.intValue()
0000005C B7 00 31 invokespecial void com.license.modules.LicenseModules.onModuleUpdated(int)
0000005F A7 FF B8 goto pos.00000017
00000062 A7 00 49 goto pos.000000AB
00000065 2A aload_0
00000066 B7 00 34 invokespecial void com.license.modules.LicenseModules.closeModules()
00000069 2A aload_0
0000006A B4 00 07 getfield java.util.Map com.license.modules.LicenseModules.modulesConfig
0000006D 04 iconst_1
0000006E B8 00 0A invokestatic java.lang.Integer java.lang.Integer.valueOf(int)
00000071 B9 00 1B 02 00 invokeinterface boolean java.util.Map.containsKey(java.lang.Object), 2
00000076 9A 00 04 ifne pos.0000007A
00000079 B1 return
0000007A 2A aload_0
0000007B 11 01 00 sipush 256
0000007E B7 00 35 invokespecial com.license.modules.BaseModule com.license.modules.LicenseModules.getModule(int)
00000081 4C astore_1
00000082 00 nop
00000083 00 nop
00000084 2B aload_1
00000085 04 iconst_1
00000086 B6 00 36 invokevirtual void com.license.modules.BaseModule.setEnabled(boolean)
00000089 2A aload_0
0000008A B4 00 04 getfield java.util.concurrent.ConcurrentMap com.license.modules.LicenseModules.modulesItems
0000008D 11 01 00 sipush 256
00000090 B8 00 0A invokestatic java.lang.Integer java.lang.Integer.valueOf(int)
00000093 2B aload_1
00000094 B9 00 37 03 00 invokeinterface java.lang.Object java.util.concurrent.ConcurrentMap.put(java.lang.Object, java.lang.Object), 3
00000099 57 pop
0000009A 2A aload_0
0000009B 11 01 00 sipush 256
0000009E 00 nop
0000009F B7 00 31 invokespecial void com.license.modules.LicenseModules.onModuleUpdated(int)
000000A2 00 nop
000000A3 00 nop
000000A4 00 nop
000000A5 00 nop
000000A6 00 nop
000000A7 00 nop
000000A8 00 nop
000000A9 00 nop
000000AA 00 nop
000000AB B1 return
Как видишь, несмотря на сырость и заброшенность проекта (последняя версия 1.7 (c529) была опубликована на официальном сайте аж в конце 2014 года), dirtyJOE представляет собой весьма полезный инструмент, незаменимый для патча обфусцированных проектов и приложений, накрытых протекторами. Помимо описанных выше, у него масса других полезных фич: с его помощью можно редактировать и добавлять новые константы и поля (можно добавлять даже новые методы, правда пустые). Для расшифровки криптованных строк есть возможность подключить пользовательские скрипты на питоне, сама программа имеет 32- и 64-битные версии и даже существует в виде плагина к Total Commander. Надеюсь, что знакомство с данной утилитой поможет тебе осваивать реверс и патчинг JVM-приложений.
Читайте ещё больше платных статей бесплатно: https://t.me/hacker_frei