Беззащитная Java. Ломаем Java bytecode encryption
the Matrix
У начинающего программиста, немного освоившего Java, создается ложное впечатление, что писать программы на ней не просто, а очень просто. У начинающего хакера подобное впечатление может сложиться о взломе написанных на Java программ. И вправду, делов‑то: берешь обычный ZIP-архиватор, распаковываешь JAR-файлы, затем выбираешь декомпилятор на свой вкус и декомпилируешь полученные CLASS-файлы хочешь по одному, а хочешь — весь проект разом. На выходе получаешь исходники проекта на блюдечке.
Статья имеет ознакомительный характер и предназначена для специалистов по безопасности, проводящих тестирование в рамках контракта. Автор и редакция не несут ответственности за любой вред, причиненный с применением изложенной информации. Распространение вредоносных программ, нарушение работы систем и нарушение тайны переписки преследуются по закону.
Правда, иногда приходится повозиться с обфускацией или поковыряться грязными руками в 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), из которой экспортируются какие‑то из следующих функций:
Мы уже обнаружили в рабочем каталоге динамическую библиотеку с оригинальным названием JavaLoader.dll, при загрузке которой в дизассемблер IDA и находим искомые функции — перед нами действительно JVMTIAgent, декриптующий байт‑код при загрузке нужного класса. Но как он это делает?
Снова покурим спецификацию JVM Tool Interface, ссылку на которую я привел выше. Общий принцип работы агента — установка собственных пользовательских callback-обработчиков на определенные события. В данном случае нас интересует событие JVMTI_EVENT_CLASS_FILE_LOAD_HOOK, вызываемое сразу после загрузки массива байт‑кода нужного класса из файла, но перед JIT-компиляцией данного класса. Примерная реализация установки такого обработчика выглядит так:
Покопавшись при помощи IDA в коде процедуры Agent_OnLoad, находим соответствующее место.

Здесь в локальной переменной [rsp+1C0h+var_170] имеется структура callbacks, которая очищается по адресу 18002C0F8. Затем по адресу 18002C102 в нее заталкивается обработчик ClassFileLoadHook, и, наконец, следует вызов SetEventCallbacks: call qword ptr [rax+3C8h]. Итак, мы нашли callback-функцию, выполняющую расшифровку байт‑кода класса перед его компиляцией. В нашем случае это sub_18002BDC0. По спецификации гуглим описание данного обработчика:
Теперь, запустив программу в отладчике 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, мы находим подходящее место для патча сразу после расшифровки байт‑кода:
Придется пожертвовать проверкой правильности расшифровки, ведь мы и так уверены, что у нас все декриптуется правильно. А если нет, программа все равно работать не будет. Так что мы используем этот «просвет» в пару десятков байтов с пользой. Другое допущение — у нас не хватит места на полную проверку имени класса, придется ограничиться последними 4 байтами. В проекте явно нет другого класса с именем длиной 45 байт и окончанием -ard. Итак, нам нужно после расшифровки байт‑кода проверить имя класса с окончанием -ard в заданной позиции и в случае успеха поменять в расшифрованном байт‑коде байт по смещению 1B36 на B1:
Запускаем программу, проверяем — все работает! Итак, мы разобрались с одним из способов сокрытия байт‑кода в приложении на Java. Я думаю, что, внимательно прочитав статью, ты уже понял, что этот способ далеко не единственный и количество вариантов и модификаций зависит только от извращенной фантазии разработчика. Тем не менее, надеюсь, эта статья подскажет пытливому уму направление, в котором следует двигаться, если возникнут нестандартные ситуации. Возможно, я даже когда‑нибудь опишу самые интересные из них поподробнее.
Наши проекты:
- Кибер новости: the Matrix
- Хакинг: /me Hacker
- Кодинг: Minor Code
- Киб.Безопасность: Access Allowed
- IT Агрегатор : Бункер Айтишника