Статья Грязный Джо. Взламываем Java-приложения с помощью dirtyJOE

Master Zed

Модератор
PREMIUM БИБЛИОТЕКА
PREMIUM
Verified
unconfirmed
Регистрация
19.11.19
Сообщения
159
Реакции
268
Кредиты
0 ₽
Баллы
483
U-coins
2,773.5
Способы обхода триала в различных программах — одна из самых интересных тем прикладного реверс‑инжиниринга. Настало время вернуться к этой тематике снова. Наш сегодняшний пациент — приложение, выполненное в виде JAR-модуля, которое мы исследуем без полного реверса и пересборки проекта.

В заметке «В обход стражи. Отлаживаем код на 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.

Вам необходимо зрегистрироваться для просмотра изображений


Вам необходимо зрегистрироваться для просмотра изображений


Редактирование параметров в 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-приложений.

Автор МВК, mikhail kondakov
хакер.ру
 
Верх Низ