Суровая жаба. Изучаем защиту Excelsior JET для программ на Java
На какие только ухищрения не приходится идти разработчикам программ на Java, чтобы усложнить взлом и реверс! Однако у всех подобных приложений есть слабое место: в определенный момент исполнения программа должна быть передана в JVM в исходных байт‑кодах, дизассемблировать которые очень просто. Чтобы избежать этого, некоторые программисты вовсе избавляются от JVM-байт‑кода. Как хакеры обычно поступают в таких случаях? Сейчас разберемся!
Статья написана в исследовательских целях, имеет ознакомительный характер и предназначена для специалистов по безопасности. Автор и редакция не несут ответственности за любой вред, причиненный с применением изложенной информации. Использование или распространение ПО без лицензии производителя может преследоваться по закону.
Авторы одной программы, суровые сибирские программисты, решили поступить совсем радикальным способом: скомпилировали Java-код в натив, причем (по их собственному утверждению) с обфускацией и оптимизацией, как бы противоречиво это ни звучало. Фактически они пожертвовали кросс‑платформенностью (ну и зачем она, спрашивается, нужна в уже скомпилированной программе, заточенной под определенную архитектуру?).
Уж не знаю, насколько такой подход способствует оптимизации, — исследованное мной приложение чертовски неторопливо и прожорливо к ресурсам компьютера, а главное, занимает несколько сот мегабайт. Но реверс‑инженерам предложенный разработчиками подход сильно усложняет жизнь. Лично я не нашел в паблике внятного мануала по организации данных в таких программах, и во многих обзорах эта технология считается лучшей для защиты Java-приложений от взлома и декодинга. Называется она Excelsior JET.
Что ж, попробуем изучить эту технологию при помощи подручных средств. В качестве подопытного кролика возьмем одно из офлайновых веб‑приложений, о которых я многократно рассказывал в своих статьях. В качестве дизассемблера по старой традиции воспользуемся IDA.
Несмотря на то что код не запакован, не виртуализирован и практически не обфусцирован, поначалу задача реверса кажется неподъемной — в дизассемблированном коде напрочь отсутствуют не только названия классов и методов, но и читаемые текстовые строки. Между тем мы точно знаем, что и строки, и названия классов, методов, и даже номера строк в коде все‑таки хранятся.
Дело в том, что программа пишет в лог стек вызовов при возникновении исключений — там присутствуют и полные названия методов с классами, и даже имена исходных файлов Java, из которых они были скомпилированы вместе с номерами строк, выполняющих вложенные вызовы.
Это вдохновило меня на дальнейшие поиски. Как минимум при входе в каждый метод информация о нем каким‑то образом должна заноситься в отладочный стек. Бегло рассмотрев код, находим первую зацепку. На подавляющем большинстве процедур начало кода выглядит следующим образом (схожие места помечены стрелкой):
mov eax, [rsp+8+var_C08] ; (1)
mov eax, [rsp+0D8h+var_CD8] ; (1)
mov [rsp+0D8h+var_D8], rax ; (3)
mov eax, [rsp+78h+var_C78] ; (1)
mov [rsp+78h+var_78], rax ; (3)
Строка 1 чисто рудиментарная и никакой полезной нагрузки (во всяком случае, в приведенных выше примерах) не несет. Здесь в eax
присваивается значение, лежащее на стеке выше текущего положения на C00h
байт. Можно предположить, что это своеобразная защита от переполнения, — при вызове каждой процедуры на стеке гарантированно должен быть запас из C00h
байт.
А вот следующие две строки вызывают интерес: при входе в каждую процедуру следом за адресом возврата на стек кладется адрес некоей структуры, причем он практически всегда уникальный. Структура эта не инициализирована при загрузке программы, поэтому придется подключать к работе отладчик.
Здесь нас ожидает первая подножка: наш любимый x64dbg не годится. Не знаю и не хочу разбираться, специально ли это задумано авторами или стало следствием прожорливости Excelsior JET, но при запуске приложения из x64dbg программа сразу же кончает жизнь самоубийством с предсмертным сообщением о нехватке памяти. Приаттачиться к работающей программе можно, однако работать она все равно не хочет, ссылаясь на ту же самую проблему.
По счастью, создатели старенького легендарного отладчика OllyDbg перед тем, как проект закрылся, успели сделать тестовую 64-битную версию своей программы, очень сырую, с урезанными возможностями, но не конфликтующую с капризной и жадной до ресурсов софтиной. Итак, загружаем исследуемую программу в OllyDbg и останавливаем ее на начале любой из подобных процедур. Структура, ссылку на которую упорно кладут на стек, выглядит примерно так.
Видно, что у каждой процедуры есть своя собственная запись размером 0x40
байт. Не знаю, как они правильно называются, давай для удобства называть их структура-40 по их размеру. Назначение полей этой структуры малопонятно, за исключением указателя на процедуры (выделено синим) и по нулевому смещению указателя на другую, более интересную структуру, выделенную зеленым. У соседних записей ссылка на эту новую структуру одинакова, и, если присмотреться, в ней явно видно полное имя класса. Структура инициализирована в исходном коде, но без имени класса и некоторых полей.
При первом же взгляде на блок данных в месте, где должно располагаться имя класса, возникают смутные сомнения, что этот блок просто зашифрован каким‑то нехитрым шифром типа XOR. Дела продвигаются: у нас наметились уже два направления дальнейшей работы — определить соответствие процедур классам в изначальном коде и расшифровать их имена.
Как ни странно, метод решения у этих задач один: ставим точку останова типа Memory
на интересный нам адрес и ждем в засаде, пока не поймается изменяющий его кусок кода.
Начнем с расшифровки имен классов. Ставим Memory breakpoint
на первый байт строки java/
по адресу 72B7718
и запускаем программу. Наша ловушка сразу срабатывает на простенькой процедуре расшифровки:
На входе RCX-адрес зашифрованной строки и RDX-адрес расшифрованной строки (в нашем случае исходный RCX). А еще R8-байт, с которым строка ксорится, в нашем случае это F9h
.
loc_93A542: ; CODE XREF: sub_93A540+40↓j
loc_93A54A: ; CODE XREF: sub_93A540↑j
movsx eax, byte ptr [rcx] ; EAX <- текущий байт строки
jz short loc_93A561 ; Проверки на конец строки — 0 или F9h
xor eax, r8d ; EAX <- EAX XOR R8D
; --------------------------------------
loc_93A561: ; CODE XREF: sub_93A540+14↑j
; --------------------------------------
loc_93A56F: ; CODE XREF: sub_93A540+F↑j
loc_93A57B: ; CODE XREF: sub_93A540+1F↑j
mov [rdx], al ; Текущий байт <- новое значение EAX
Предчувствия нас не обманули: это простейший XOR с фиксированным байтом F9h
. Причем похоже, что названия всех описателей классов расшифровываются сразу в одной процедуре при старте программы. В таком случае попробуем извлечь из нее список всех классов и положение строки имени класса в структуре описателя.
sub_9BA640 proc near ; На входе RCX — адрес структуры CDES
movsx edx, byte ptr [rcx+0ACh] ; По смещению ACh в этой структуре байт-шифровальщик F9h
call sub_9C4500 ; Инициализация указателей на таблицу описателей классов
; --------------------------------------
loc_9BA66A: ; CODE XREF: sub_9BA640+6B↓j
mov rcx, rax
mov rsi, rax
call sub_9C4540 ; Возвращает RAX — адрес текущего описателя класса
mov ecx, [rax+74h]
and ecx, 20h
cmp ecx, 20h ; Если бит 20h в двойном слове по смещению 74h от начала описателя класса установлен — название класса уже расшифровано
jz short loc_9BA6A0
mov ecx, [rax+14h] ; Двойное слово по смещению 14h от начала описателя класса — смещение до зашифрованного имени
movsxd rcx, ecx
add rcx, rax ; Абсолютный адрес строки имени в ECX
mov rdx, rcx ; И в RDX тоже
mov r8d, ebp ; Байт‑шифровальщик F9h
mov rdi, rax
call sub_93A540 ; Расшифровать имя
mov eax, [rdi+74h]
or eax, 20h
mov [rdi+74h], eax ; Установить флаг расшифрованности имени
jle short loc_9BA66A ; Следующий класс
Итак, мы наконец‑то получили локализацию строки имени внутри описателя класса — относительное смещение до него. Попутно мы обнаружили еще одну интересную структуру, адрес которой данная процедура получает на вход. Назовем ее условно «структура CDES» по сигнатуре 53454443h
в начале. Выглядит она так.
Это самая базовая структура Excelsior JET. Помимо байта, которым шифруются текстовые строки (выделен красным), в ней присутствуют ссылки на все базовые структуры и таблицы. Находится она по смещению 8 от начала секции _bss
и, к сожалению, не инициализирована в исходном коде. К вопросу ее инициализации и получения из нее интересующих нас структур и таблиц мы вернемся чуть позже, для начала же попробуем локализовать таблицу описателей классов. Немного повозившись с процедурой sub_9C4500
, находим внутри следующий код:
mov rax, [rdx+0C0h] ; Адрес таблицы описателей классов по смещению 0C0h от начала структуры CDES (на предыдущем рисунке выделено синим)
mov edx, [rax-2Ch] ; Номер первого рассматриваемого элемента в таблице — по смещению -2Ch от начала таблицы (на следующем рисунке выделено синим)
mov edx, [rax-2Ch] ; Номер первого рассматриваемого элемента в таблице — по смещению -2Ch от начала таблицы (на следующем рисунке выделено синим)
mov r8d, [rax-28h] ; Количество рассматриваемых элементов в таблице — по смещению -28h от начала таблицы (на следующем рисунке выделено зеленым)
lea eax, [rdx+r8] ; Номер последнего рассматриваемого элемента в таблице (на следующем рисунке выделено красным)
; --------------------------------------
loc_9C44BE: ; В цикле перебираем все элементы таблицы, начиная с первого рассматриваемого, на предмет принадлежности к описателям класса
loc_9C44C7: ; CODE XREF: sub_9C4480+3C↑j
cmp edx, 3 ; У описателей класса 16-битное слово по смещению 8 от начала структуры должно быть 3
jnz short sub_9C4480 ; ...или 4
loc_9C44E3: ; CODE XREF: sub_9C4480+58↑j
mov [rcx+18h], rax ; Если элемент таблицы удовлетворяет этим условиям, возвращаем его адрес
Сама таблица описателей классов выглядит так.
Судя по всему, в ней содержатся не только описатели классов, но еще и множество других элементов, назначение которых ты можешь при желании выяснить самостоятельно. Нас же пока интересуют ее элементы, начиная с номера, выделенного на рисунке синим цветом. Однако и эти элементы вовсе не обязательно описатели классов, надо внимательно смотреть на флажки в заголовке структуры.
Теперь вернемся к инициализации «структуры-40». Записи «структуры-40» хоть в исходном коде и не инициализированы, однако расположены по вполне фиксированным адресам, на которые можно ставить бряки. Для примера берем первый же адрес 9F2E060
из одной такой процедуры. По остановке в данной точке мы получаем процедуру, инициализирующую «структуры-40» для каждого метода заданного класса. Насколько я понимаю, это происходит каждый раз при создании объекта. В упрощенном виде эта процедура выглядит примерно так (интересные места я выделил комментариями):
loc_936576: ; Цикл по всем элементам таблицы методов класса
mov [r11], r9 ; Место, на котором срабатывает breakpoint, R9-адрес текущего описателя класса — по смещению 0 «структуры-40»
mov ebp, [r9+0B0h] ; Относительный адрес таблицы методов находится по смещению B0h внутри описателя класса
mov rsi, [r9+30h] ; По смещению 30h внутри описателя класса — указатель на «структуру CDES»
mov rsi, [rsi+58h] ; По смещению 58h внутри «структуры CDES» — базовый адрес исполняемого модуля 400000h, на рисунке «Структура CDES» выделен оранжевым
add rbp, rsi ; Абсолютный адрес таблицы методов класса в RBP
; --------------------------------------
loc_93673D: ; CODE XREF: sub_936500+228↑j
loc_93673F: ; CODE XREF: sub_936500+23B↑j
mov ebx, [rbp+rbx*4+0] ; RBP[RBX] — относительный адрес текущего метода
mov r9, [r9+30h] ; По смещению 30h внутри описателя класса — указатель на «структуру CDES»
mov r9, [r9+58h] ; По смещению 58h внутри «структуры CDES» — базовый адрес исполняемого модуля 400000h, на рисунке «Структура CDES» выделен оранжевым
add r9, rbx ; Абсолютный адрес текущего в R9
mov [rdx+r10+18h], r9 ; Адрес метода по смещению 18h «структуры-40»
jnz loc_936576 ; Перейти к обработке следующего метода класса и следующего блока «структуры-40»
Разумеется, описанная процедура значительно сложнее и выполняет множество других функций, но на данный момент мы ищем вполне определенные фичи, и, похоже, мы их нашли. Резюмируя, попробуем для наглядности нарисовать примерную схему описателя класса.
На этом рисунке оранжевым обозначен тип блока (описатель класса — 3 или 4), красным — имя класса и смещение на него относительно начала описателя, зеленым — указатель на таблицу описателей классов, синим — таблица методов класса и смещение на нее относительно базового адреса.
Ко всему прочему мы выяснили еще один интересный факт. Несмотря на то что нативное приложение 64-битное, адреса методов внутри описателя классов — 32-битные смещения относительно базового адреса модуля. На самом деле, если внимательно присмотреться, мы найдем в описателе класса таблицу с прямыми длинными ссылками (как я понимаю, на методы из внешних классов), но нам она в данный момент не нужна.
Не знаю, как создатели приложения выкручиваются в случае с большими исполняемыми модулями, не адресуемыми 32 битами. Возможно, они имеют несколько секций и «структур CDES». Мне, во всяком случае, таковые не попадались, поэтому вернемся к нашим баранам, то бишь к жабам
Мы разобрали, какие классы скомпилированы в нативный код приложения и какие методы соответствуют каждому классу. Но хотелось бы знать имена этих методов. Ведь я уже упоминал в начале статьи, что приложение при возникновении исключения легко показывает стек вызовов с полными названиями классов, методов, с именами исходных файлов и даже с номерами строк. Последние, конечно, нам не особо нужны, но имена методов знать бы хотелось.
Направление, в котором следует копать в данном случае, очевидно: если обработчик исключений знает имена классов — спросим его об этом! Поскольку мы теперь знаем адреса методов каждого класса, тупо ставим бряки на всякий случай на все методы класса. Ну, например, на Throwable
, а еще лучше на StackTrace
.
Поискав в памяти загруженного модуля, мы находим такой класс — его полное имя com/excelsior/jet/runtime/excepts/stacktrace/StackTrace
. Методов у этого класса немного, порядка двадцати, поэтому, просто установив точки останова на каждый из них, получаем срабатывание по адресу D193C0
.
Указанный метод по адресу возврата определяет имя метода, из которого был выполнен вызов, имя исходного Java-файла, содержащего этот метод, и номер строки. Забегая вперед, скажу, что его полное имя com/excelsior/jet/runtime/excepts/stacktrace/StackTrace/a
, и, к сожалению, код перед целевой компиляцией обфусцируется. Схематически он выглядит вот так:
sub_D193C0 proc near ; На входе в RDX — адрес возврата со стека почему-то минус 1, то есть адрес байта, предшествующего адресу возврата из call
mov rcx, [rax+30h] ; RCX <- Адрес CDES
mov r9, [rcx+0E8h] ; CDES[E8h] — таблица имен методов по адресам, на рисунке «Структура CDES» выделена фиолетовым
mov r10, [rcx+58h] ; Базовый адрес исполняемого модуля
sub rdx, r10 ; RDX <- относительный адрес возврата
call sub_D1A100 ; Эта процедура ищет в таблице имен методов по адресам относительный адрес RDX, то есть участок кода, внутри которого он находится
test rax, rax ; На выходе в RAX — абсолютный адрес найденного элемента таблицы имен методов по адресам, содержащего адрес возврата
mov edx, [rax+8] ; 32-битное слово по смещению 8 в найденном элементе — относительный адрес структуры описания метода
mov r8, [rsi+58h] ; Базовый адрес исполняемого модуля
add rdx, r8 ; Получаем абсолютный адрес структуры описания метода
; --------------------------------------
loc_D1944D: ; CODE XREF: sub_D193C0+7C↑j
loc_D1944F: ; CODE XREF: sub_D193C0+8B↑j
mov eax, [rax] ; 32-битное слово по смещению 0 найденного элемента таблицы — стартовый адрес метода
sub ebx, eax ; Смещение от начала метода до адреса возврата
call sub_D1A000 ; Методом половинного деления ищем в структуре описания метода номер строки по относительному смещению адреса возврата
mov ecx, [rax+4] ; Номер строки исходного Java-файла
mov edx, [r12+4] ; Индекс зашифрованной строки имени метода
mov eax, [r12+8] ; Индекс зашифрованной строки имени исходного Java-файла, содержащего метод
Надеюсь, к настоящему моменту я тебя не слишком напугал обилием новых структур с собственными именами? Как ты, вероятно, заметил, приведенный выше кусок кода содержит целых три новых термина: таблица имен методов по адресам, структура описания метода и индекс зашифрованной строки. Остановимся на каждом из них подробнее.
Чтобы при возникновении ошибки отследить метод и строку вызова, Excelsior Jet хранит упорядоченные таблицы соответствия адрес — метод, и для каждого метода существует таблица «адрес — строка». Таблица соответствия имен методов по адресам, как ты уже догадался, абсолютно адресуется из «структуры CDES» 64-битным адресом по смещению E8h
. На рисунке «Структура CDES» она выделена фиолетовым. Структура у нее совершенно прозрачная, она показана на следующей иллюстрации.
Первое 32-битное слово (выделено красным) — количество элементов в таблице. Каждый элемент занимает 12 байт (первые два элемента выделены фиолетовым) и соответствует одному скомпилированному методу. Первое 32-битное слово элемента (выделено желтым) — относительный адрес точки входа метода. Последнее 32-битное слово (выделено голубым) — относительный адрес структуры описания метода.
Как видно, здесь применен такой же мухлеж с 32-битной адресацией кода и данных внутри 64-битного приложения: вместо нормальных 64-битных адресов используются 32-битные смещения относительно базы модуля, хранящегося в «структуре CDES».
Вернемся ко второй найденной структуре — структуре описания метода, которую я так назвал из‑за скудности собственной фантазии. Она не слишком отличается от таблицы имен методов по адресам и тоже представляет собой упорядоченную таблицу c размером в начале (выделено красным) и 12-байтовым элементом (первый элемент выделен фиолетовым).
Отличие от предыдущей таблицы состоит в том, что в самом начале структуры описания метода идут три 32-битных слова. Первое из них (на рисунке обведено оранжевым) — индекс класса, содержащего метод в таблице описателей классов. Второе (обведено синим) — индекс зашифрованного имени метода, а третье (обведено зеленым) — индекс зашифрованного имени исходного Java-файла.
Записи устроены следующим образом. Первый 32-битный элемент — смещение кода, в который компилируется Java-строка относительно начала метода, второй — номер строки в исходном Java-файле, а третий, судя по всему, не задействован вообще, поскольку я не видел его ненулевых значений. Поскольку обе описанные структуры представляют собой упорядоченные массивы (по сути, упорядоченные словари), любое место кода быстро и однозначно идентифицируется по ним методом дихотомии.
Таким образом, можно значительно облегчить реверс скомпилированных приложений, просто пробежавшись по этим двум структурам и расставив в коде метки соответствия классам, методам и строкам. Предоставляю тебе самостоятельно заняться написанием подобных скриптов или плагинов, а нам осталось разобраться, что означает индекс зашифрованной строки из комментария к предыдущему фрагменту кода.
Как я уже говорил, из‑за паранойи разработчиков в Excelsior Jet, помимо имен классов, шифруются вообще все‑все‑все текстовые строки, причем одним и тем же алгоритмом — тупым XOR с хранящимся в «структуре CDES» байтом‑шифровальщиком (у нас это F9h
). Но если имена классов стоят на своих местах в соответствующих структурах описания класса, то почти все остальные строки собраны в одном зашифрованном блоке, на который указывает абсолютный адрес по смещению C8h
в «структуре CDES» (на рисунке «Структура CDES» выделено зеленым). Таким образом, упомянутые выше индексы представляют собой смещения относительно начала этого блока. Здесь я снова обращу твое внимание: создатели компилятора явно делают допущение, что в любом 64-битном приложении размер этого блока будет адресоваться 32 битами.
И в заключение для тех, кого эта немного сумбурная статья вдохновила написать более дружественный к пользователю декомпилятор, скрипт или плагин к отладчику, вернемся к вопросу поиска и локализации основных структур данных, упомянутых в статье. Как я уже говорил, основная «структура CDES», из которой идут ссылки на главные структуры, блоки и таблицы, инициализируется после запуска программы. То есть в уже запущенной программе все эти адреса посмотреть можно, но из дизассемблера придется искать. Попробуем это сделать. Инициализируется эта структура в самое начало секции _bss
, при помощи IDA мы легко находим код инициализации:
sub_743B80 proc near ; CODE XREF: sub_743FA0+25↓p
mov dword ptr [rcx], 53454443h ; Сигнатура CDES
lea rax, unk_9E31718 ; Hardcoded-адрес таблицы описателей классов
lea rax, unk_14F3F6F4 ; Hardcoded-адрес блока зашифрованных строк
mov [rcx+0E8h], rax ; Hardcoded-адрес таблицы имен методов по адресам
Стратегия поиска такова: ищем в коде адрес присваивания сигнатуры 53454443h
и, начиная с этого места, по маскам команд и смещений извлекаем нужные адреса. Идея весьма скользкая, поскольку в каждой новой версии компилятора авторы могут менять этот инициализационный код, из‑за чего нам придется перерабатывать алгоритм, но как рабочий вариант такой подход сгодится.
Ты, наверное, заметил, что байт‑шифровальщик F9h
тоже не инициализируется в этой процедуре, он берется из другой странной структуры, находящейся в самом начале секции _config
с сигнатурой CPB
.
Как видишь, смещение этого байта в структуре равно 1Ch
. Последний кусочек пазла встал на место, и остается только пожелать удачи энтузиастам реверса этого хитрого компилятора.