Хакер - Беззащитная Java. Ломаем Java bytecode encryption
hacker_frei
МВК
Код на Java не так прост, как кажется. На первый взгляд взлом Java-приложения выглядит несложной задачей, благо подходящих декомпиляторов великое множество. Но если тебе доведется столкнуться с защитой Bytecode encryption, задача многократно усложняется. В этой статье я подробно расскажу, как бороться с этой напастью.
У начинающего программиста, немного освоившего Java, создается ложное впечатление, что писать программы на ней не просто, а очень просто. У начинающего хакера подобное впечатление может сложиться о взломе написанных на Java программ. И вправду, делов‑то: берешь обычный ZIP-архиватор, распаковываешь JAR-файлы, затем выбираешь декомпилятор на свой вкус и декомпилируешь полученные CLASS-файлы хочешь по одному, а хочешь — весь проект разом. На выходе получаешь исходники проекта на блюдечке.
WARNING
Статья имеет ознакомительный характер и предназначена для специалистов по безопасности, проводящих тестирование в рамках контракта. Автор и редакция не несут ответственности за любой вред, причиненный с применением изложенной информации. Распространение вредоносных программ, нарушение работы систем и нарушение тайны переписки преследуются по закону.
Правда, иногда приходится повозиться с обфускацией или поковыряться грязными руками в JVM-байт‑коде (процесс я описывал в статье «Грязный Джо. Взламываем Java-приложения с помощью dirtyJOE»). Все равно это выглядит намного проще маеты с нативным кодом под злобными протекторами типа «Фемиды» или даже дотнет‑приложениями.
На самом деле Java ничем не лучше упомянутых технологий, и столь простые случаи бывают далеко не всегда. Попробую подготовить тебя к неожиданностям на этом тернистом пути, для чего расскажу об одном из способов защиты Java-кода от декомпиляции и о методах борьбы с ним.
Как обычно, сразу переходим к примеру. Есть некая графическая программа, при загрузке которой происходит валидация лицензии на удаленном сервере. Если валидной лицензии нет или сервер недоступен, программа вежливо предлагает повторить попытку или закрывается, выбор невелик. Попробуем, просто чтобы поучиться и повысить свою эрудицию, заставить ее работать даже после отказа удаленного сервера.
При ближайшем рассмотрении нам бросается в глаза, что исполняемый модуль программы — простенький загрузчик Java Runtime Environment, вызывающий javaw c длиннющей командной строкой, которая содержит перечень JAR-модулей и библиотек. В конце этого списка мы видим имя главного класса.
Запустив поиск по JAR-архивам, мы находим сам файл класса. И вот здесь нас поджидает неприятный сюрприз: все имеющиеся в нашем распоряжении декомпиляторы напрочь отказываются работать с этим файлом, и даже dirtyJOE ругается на неподдерживаемый формат. Открыв файл в hex-редакторе, мы обнаруживаем, что ругается он вполне справедливо: от нормального скомпилированного CLASS-файла здорового человека здесь осталась только сигнатура CAFEBABE, все остальное содержимое заполнено высокоэнтропийным белым шумом упакованных или зашифрованных данных.

Глядя на эту картину, я испытывал сильное чувство дежавю. С чем‑то подобным я уже сталкивался, когда разбирал bytcode-обфускатор PHP SourceGuardian или хотя бы тот же .NET Reactor: кто‑то явно вклинивается в процесс загрузки JVM-байт‑кода и расшифровывает его на лету. Но как такое можно реализовать на Java?
Для ответа на этот вопрос попробуем слегка углубиться в теорию функционирования Java-машины. Поскольку она столь же кросс‑платформенна, как и .NET (на самом деле в определенном смысле даже более кросс‑платформенна), то байт‑код JVM не интерпретируется, а однократно компилируется в платформенно зависимый нативный код при загрузке класса. Мы даже знаем, как называется данный процесс, — JIT-компиляция (just in time, «временная компиляция на лету»). А значит, точно так же, как и в .NET, мы можем приаттачиться во время работы программы отладчиком x64dbg к процессу javaw.exe, что позволит нам отлаживать скомпилированный нативный код как родной.
Правда, удовольствия в этом мало, поскольку скомпилированный код недружественно выглядит (в отличие, скажем, от результата JIT-компиляции того же .NET). Код сильно оптимизирован, многопоточен и шустр, но крайне неудобен для реверсинга.

Конечно, определенные вкусности имеются и там. К примеру, если покопаться в модулях jvm.dll, java.dll, jli.dll и других, то можно обнаружить много стандартных базовых функций, упрощающих процесс отладки. Возможно, я когда‑нибудь расскажу о них в другой статье. Много материала по этой теме можно найти в интернете: например, переводные статьи, публиковавшиеся на «Хабрахабре»: «Java HotSpot JIT компилятор — устройство, мониторинг и настройка» и «Как работает Graal — JIT-компилятор JVM на Java». Но сейчас цель у нас другая: разобраться, как именно происходит процесс шифрования и дешифрования байт‑кода, а также восстановить исходный код Java из шифрованного. В .NET, к примеру, нам удалось найти вход JIT-компилятора, попробуем это сделать и для Javа.
Снова коснусь теории, не углубляясь в подробности. По сути, для реализации подмены байт‑кода в JVM обычно используется два основных интерфейса, каждый из которых реализует собственный подход. Один из них называется JVMCI — JVM compiler interface — и служит непосредственно для подключения собственного JIT-компилятора Java, написанного опять же на Java. По понятным причинам это явно не наш случай (у нас все классы зашифрованы, начиная с главного).
А вот второй, JVM Tool Interface (JVMTI), похоже, именно то, что нам надо, поэтому рассмотрим его поподробнее. JVM Tool Interface — это чертовски полезный интерфейс взаимодействия с виртуальной машиной JVM. Он позволяет расширить ее функциональность, не затрагивая код. Для полного описания возможностей этого инструмента одной статьи не хватит, поэтому я снова отсылаю любопытных к матчасти.
Все полезняшки этого интерфейса реализуются через так называемые агенты — внешние плагины. У них множество функций, но главное — они дают полный доступ к загружаемому байт‑коду и контроль над ним. Именно это нам сейчас и нужно. Агенты загружаются из javaw, для этого нужно указать специальные параметры в манифесте или же в командной строке. К примеру, самый распространенный тип агентов — javaagent. Они написаны на Java и подключаются через соответствующую командную строку javaagent:agent.jar. Этот тип агентов тоже имеет полный доступ к байт‑коду, его подмена часто используется в обфускации и модификации кода, но не в нашем случае.
В нашем приложении задействован нативный JVMTIAgent, вызов которого можно обнаружить, внимательно проанализировав командную строку и найдя в ней следующий параметр: -agentlib:JavaLoader. JVMTIAgent представляет собой динамическую библиотеку (в случае Windows это DLL, а в случае, например, «Линукса» — SO), из которой экспортируются какие‑то из следующих функций:
JNIEXPORT jint JNICALL Agent_OnLoad(JavaVM *vm, char *options, void *reserved);— эта функция вызывается при запуске агента, если он указан в параметре командной строки-agentpath:или-agentlib:, как в нашем случае;JNIEXPORT jint JNICALL Agent_OnAttach(JavaVM* vm, char* options, void* reserved);— данная функция вызывается, если агент не загружается при запуске, тогда мы сначала подключаемся к целевому процессу, а затем отправляем команду соответствующему целевому процессу для загрузки агента;JNIEXPORT void JNICALL Agent_OnUnload(JavaVM *vm);— функция вызывается при удалении агента, опционально.
Мы уже обнаружили в рабочем каталоге динамическую библиотеку с оригинальным названием JavaLoader.dll, при загрузке которой в дизассемблер IDA и находим искомые функции — перед нами действительно JVMTIAgent, декриптующий байт‑код при загрузке нужного класса. Но как он это делает?
Снова покурим спецификацию JVM Tool Interface, ссылку на которую я привел выше. Общий принцип работы агента — установка собственных пользовательских callback-обработчиков на определенные события. В данном случае нас интересует событие JVMTI_EVENT_CLASS_FILE_LOAD_HOOK, вызываемое сразу после загрузки массива байт‑кода нужного класса из файла, но перед JIT-компиляцией данного класса. Примерная реализация установки такого обработчика выглядит так:
JNIEXPORT jint JNICALL Agent_OnLoad(JavaVM *vm, char *options, void *reserved) {
jvmtiEventCallbacks callbacks;
jvmtiEnv * jvmtienv = jvmti(agent);
jvmtiError jvmtierror;
memset(&callbacks, 0, sizeof(callbacks));
callbacks.ClassFileLoadHook = &eventHandlerClassFileLoadHook; // Новый обработчик JVMTI_EVENT_CLASS_FILE_LOAD_HOOK
jvmtierror = (*jvmtienv)->SetEventCallbacks( jvmtienv, &callbacks, sizeof(callbacks)); // Установка обработчиков
jvmtierror = (*jvmtienv)->SetEventNotificationMode(jvmtienv, JVMTI_ENABLE,
JVMTI_EVENT_CLASS_FILE_LOAD_HOOK,
(jthread)NULL); // Разрешаем обработку события JVMTI_EVENT_CLASS_FILE_LOAD_HOOK
Покопавшись при помощи IDA в коде процедуры Agent_OnLoad, находим соответствующее место.

Здесь в локальной переменной [rsp+1C0h+var_170] имеется структура callbacks, которая очищается по адресу 18002C0F8. Затем по адресу 18002C102 в нее заталкивается обработчик ClassFileLoadHook, и, наконец, следует вызов SetEventCallbacks: call qword ptr [rax+3C8h]. Итак, мы нашли callback-функцию, выполняющую расшифровку байт‑кода класса перед его компиляцией. В нашем случае это sub_18002BDC0. По спецификации гуглим описание данного обработчика:
void JNICALL
eventHandlerClassFileLoadHook(
jvmtiEnv * jvmtienv,
JNIEnv * jnienv,
jclass class_being_redefined,
jobject loader,
const char* name, // Имя класса
jobject protectionDomain,
jint class_data_len, // Размер класса
const unsigned char* class_data, // Загруженный из файла криптованный байт-код
jint* new_class_data_len, // Размер декриптованных данных
unsigned char** new_class_data // Декриптованные данные
)
Теперь, запустив программу в отладчике x6dbg, устанавливаем брейк‑пойнт на этот обработчик. Брейк‑пойнт будет срабатывать на загрузке и расшифровке каждого класса. На входе в него параметром name (регистр rsi) мы видим имя класса, а по завершении обработчик отдаст нам в new_class_data расшифрованный байт‑код, который можно дампить и спокойно декомпилировать. Дальнейшие наши действия — дело техники: перво‑наперво ставим логирование загружаемых классов (текст журнала {s:rsi}).

Теперь при работе программы в журнал отладчика будет записываться последовательность загружаемых программой классов, по которой мы легко отслеживаем класс, ответственный за запрос на сервер и проверку лицензии. Допустим, мы нашли этот класс и, для примера, у нас он называется com/coreui/app/license/wizard/licenseWizard. Добавив в этот брейк‑пойнт условие для остановки (strcmp(utf8(rsi),"com/coreui/app/license/wizard/licenseWizard")), мы можем останавливать программу на нужном нам классе, который потом вручную сдампим в файл и затем декомпилируем любым понравившимся нам декомпилятором.
Декомпилировав его, мы обнаруживаем и место однобайтового патча — для отвязки от проверки лицензии достаточно закоротить метод j(), поставив в байт‑коде ret (B1) по смещению 1B36 от начала класса. Фактически задача решена: править байт‑код без перекомпиляции мы уже умеем. Однако тут не все так просто.
Править нам придется криптованный код, а анализ криптора JavaLoader в IDA показывает, что покриптован он отнюдь не XOR’ом, а по‑взрослому, несимметричным алгоритмом с использованием эллиптических кривых. То есть халявный способ патча здесь не годится. Более того: поскольку алгоритм несимметричный, мы даже перекриптовать исправленный код не сможем, не зная приватного ключа.
В итоге складывается трагикомическая ситуация: мы вроде бы сломали проверку лицензии, но работать она может только из отладчика. Иными словами, мы останавливаемся на загрузке нужного класса, после его расшифровки патчим руками байт‑код и запускаем приложение дальше. Можно, конечно, автоматизировать этот процесс скриптом, но все равно выглядит как‑то неспортивно для уважающего себя хакера. Как же нам выкрутиться из этой неприятной ситуации?
Самый хардкорный способ — расшифровать все‑все‑все классы и убить криптор насовсем. Для этого придется написать Java-приложение, загружающее все классы по списку, и скрипт для x64dbg, дампящий их на диск. При избытке свободного времени и достойной материальной мотивации можно даже разреверсить алгоритм расшифровки и написать свой декриптор без отладчика и Java. Это был бы идеальный вариант — полное снятие протектора, после чего можно восстановить исходный код проекта (разумеется, если на нем нет обфускатора, а на нашем он таки присутствует).
Но нам, по счастью, такое вовсе не нужно. Нам бы, как всегда, попроще и побыстрее программу запустить. В качестве простого и остроумного костыля я предлагаю внедрить патч прямо в тело агента. Слегка поковырявшись в IDA, мы находим подходящее место для патча сразу после расшифровки байт‑кода:
// Декрипт байт-кода, возвращает в rax длину декриптованного блока или 0 при неудаче
18002BF0C E8 0F FA FF FF call sub_18002B920
18002BF11 8B D0 mov edx, eax
18002BF13 85 C0 test eax, eax
18002BF15 7F 10 jg short loc_18002BF27 // Если 0, то ошибка
18002BF17 48 8B D5 mov rdx, rbp // rbp — имя класса
18002BF1A 48 8D 0D 57 76 0E 00 lea rcx, aDecryptionFail ; "Decryption failed: %s\n"
18002BF21 E8 CA FC FF FF call sub_18002BBF0 // Выход из программы с ошибкой
18002BF26 CC db 0CCh
18002BF27 loc_18002BF27:
18002BF27 49 8B 0F mov rcx, [r15] // Добавление к байт-коду сигнатуры CAFEBABE
18002BF2A 48 8B 84 24 A0 00 00 00 mov rax, [rsp+A0] // Указатель на расшифрованный байткод
18002BF32 48 89 08 mov [rax], rcx
18002BF35 83 C2 08 add edx, 8
Придется пожертвовать проверкой правильности расшифровки, ведь мы и так уверены, что у нас все декриптуется правильно. А если нет, программа все равно работать не будет. Так что мы используем этот «просвет» в пару десятков байтов с пользой. Другое допущение — у нас не хватит места на полную проверку имени класса, придется ограничиться последними 4 байтами. В проекте явно нет другого класса с именем длиной 45 байт и окончанием -ard. Итак, нам нужно после расшифровки байт‑кода проверить имя класса с окончанием -ard в заданной позиции и в случае успеха поменять в расшифрованном байт‑коде байт по смещению 1B36 на B1:
18002BF13 | 48:8B8424 A0000000 | mov rax,qword ptr ss:[rsp+A0] // Указатель на расшифрованный байт-код
18002BF1B | 817D 2E 61726400 | cmp dword ptr ss:[rbp+2A],647261 // Имя класса+42=="ard"\0?
18002BF22 | 75 08 | jne javaloader.18002BF2C
18002BF24 | C680 361B0000 B1 | mov byte ptr ds:[rax+1B36],B1 // Если да, то поменять нужный байт в байт-коде на ret
18002BF2B | 90 | nop
18002BF2C | 49:8B0F | mov rcx,qword ptr ds:[r15]
18002BF2F | 90 | nop
18002BF30 | 90 | nop
18002BF31 | 90 | nop
Запускаем программу, проверяем — все работает! Итак, мы разобрались с одним из способов сокрытия байт‑кода в приложении на Java. Я думаю, что, внимательно прочитав статью, ты уже понял, что этот способ далеко не единственный и количество вариантов и модификаций зависит только от извращенной фантазии разработчика. Тем не менее, надеюсь, эта статья подскажет пытливому уму направление, в котором следует двигаться, если возникнут нестандартные ситуации. Возможно, я даже когда‑нибудь опишу самые интересные из них поподробнее.
Читайте ещё больше платных статей бесплатно: https://t.me/hacker_frei