Хакер - В обход стражи. Как вскрывают приложения, защищенные аппаратным ключом Sentinel
hacker_freiМВК
Реверс‑инжиниринг написанных на Java приложений, если разработчики не позаботились заранее об их защите, обычно не представляет трудностей. Именно чтобы усложнить жизнь хакерам и исследователям, используются различные инструменты, один из которых — защита с аппаратным ключом Sentinel. Сегодня мы рассмотрим способ обхода такой защиты.
Тем, кто читал мои предыдущие статьи, посвященные защите приложений на Java, известно самое слабое место такой защиты: прозрачность байт‑кода и невыносимая легкость восстановления его до исходников.
Поэтому разработчики таких инструментов стремятся как можно хитрее спрятать код от любопытных глаз хакеров, используя различные приемы: обфускацию, компиляцию в натив, шифрование. Наглядный пример последнего мы начали разбирать в статье «Беззащитная Java. Ломаем Java bytecode encryption». Тогда мы остановились на инлайн‑патчинге расшифрованного байт‑кода прямо из библиотеки агента JVMTI. Сегодня мы продолжим эту тему и научимся патчить непосредственно шифрованный код.
В качестве примера мы возьмем приложение, защищенное Sentinel Licensing API Java Class Library — специальной библиотекой защиты Java-классов, поставляемой к ключам типа Hasp. Почитать о том, как устроена эта библиотека, можно на сайте разработчика. Само приложение защищено ключом Hasp, в его рабочем каталоге присутствуют характерные для Sentinel файлы hasp_rt.exe
, hasp_windows_x64_34344.dll
, haspvlib_34344.dll
и специфические для Java HASPJava.dll
, HASPJava_x64.dll
, sntljavaclsrt.dll
, sntljavaclsrt_x64.dll
. Detect It Easy предсказуемо указывает на NetHASP dongle reference и Hardlock dongle reference.
Мы по опыту знаем, что ломать эти библиотеки «в лоб» лучше даже не пытаться, они весьма сурово виртуализованы и защищены от отладки и модификации. Поэтому смотрим на компилированные классы, содержащиеся внутри JAR. Там тоже все мрачно: кроме главного класса, все остальные пошифрованы с высокой энтропией, примерно как описано в статье «Беззащитная Java. Ломаем Java bytecode encryption». За исключением того, что криптор подключается не как JavaAgent при запуске приложения, а из главного класса, по сути представляющего собой загрузчик приложения c единственно открытым кодом:
public static void main(String[] stringArray) {
try {
String string;
String string2 = System.getProperty("java.class.path");
int n = Math.max(string2.lastIndexOf("\"), string2.lastIndexOf("/"));
String string3 = string = string2.substring(0, ++n);
if (JavaClsEntry.isWindows()) {
string3 = string3 + "sntljavaclsrt";
if (0 == System.getProperty("sun.arch.data.model").compareTo("64")) {
string3 = string3 + "_x64";
}
string3 = string3 + ".dll";
} else if (JavaClsEntry.isLinux()) {
string3 = string3 + "libsntljavaclsrt_x86_64.so";
} else {
return;
}
File file = new File(string3);
string3 = file.getAbsolutePath();
System.load(string3);
Class<?> clazz = Class.forName("com.MainApp");
Method method = clazz.getMethod("main", String[].class);
method.invoke(null, new Object[]{stringArray});
}
catch (Exception exception) {
exception.printStackTrace();
}
Загрузчик ничего не делает, кроме как загружает сентинеловскую нативную библиотеку защиты, а затем главный класс программы. Если ключ не в порядке, то главный класс попросту не расшифровывается и приложение рушится по неправильному формату байт‑кода класса. Собственно, даже такую ошибку можно получить только после того, как библиотека выдаст окошко отсутствия ключа.
Как же нам теперь быть? Ведь, поскольку здесь не происходит прямая подмена JIT-компилятора через JavaAgent, предложенный в упомянутой выше статье способ не годится. Попробуем подключиться отладчиком непосредственно к JIT-компилятору. Немного покурив документацию, обнаруживаем в библиотеке JVM.DLL
такую функцию:
JVM_DefineClassWithSource(JNIEnv *env, const char *name, jobject loader, const jbyte *buf, jsize len, jobject pd, const char *source)
Как видно из описания, эта функция компилирует класс из массива байт‑кода. Длина массива, название класса и даже исходного Java-модуля прилагается. При наличии ключа, установив в нашем любимом отладчике x64dbg точку останова на вход этой функции, мы получаем в регистре R9
расшифрованный класс, начиная с сигнатуры CAFEBABE
, и его длину в регистре R14
. В итоге его можно легко сдампить на диск следующей командой:
savedata MainApp.class, R9, R14
В некоторых, наиболее простых случаях, когда зашифрован только главный класс, этого вполне достаточно, чтобы снять защиту целиком, — просто запаковываем расшифрованный главный класс вместо старого и переопределяем его в манифесте. Если зашифрованных классов немного, то их тоже можно сдампить по очереди и бороться с привязкой к ключу уже в расшифрованных классах. Обычно подобная защита предполагает наличие в модуле уже Java-классов Aladdin/Hasp.class
, Aladdin/HaspApiVersion.class
, Aladdin/HaspStatus.class
и Aladdin/HaspTime.class
. Однако зашифрованных классов может быть очень много, так что дампить их вручную по очереди непродуктивно.
Можно, конечно, автоматизировать процесс при помощи костылей — сделать загрузчик, который подключается к запущенному процессу и сохраняет классы из памяти по очереди, но это слишком извращенное решение даже для меня. К тому же мы хоть и ленивые, но достаточно любопытные и нам интересно докопаться до сути. Для начала — понять, как именно происходит расшифровка криптованных классов.
На первый взгляд, задача выглядит жутковато — как я уже говорил, сентинеловские библиотеки по‑взрослому покриптованы и виртуализированы, вдобавок сражаются с отладчиком при попытке их трассировки. Но мы все‑таки попробуем отловить момент расшифровки. Для начала просто прервемся в отладчике, когда появляется сообщение об отсутствии ключа. По стеку вызовов мы с удивлением обнаруживаем, что уши проверки ключа в сентинеловской библиотеке sntljavaclsrt_x64
торчат из кернеловской функции чтения файла ReadFile
, а еще точнее — из ее низкоуровневого вызова NtReadFile
.
В библиотеке sntljavaclsrt_x64
есть даже соответствующая экспортируемая функция, подменяющая системную. Проверить это достаточно просто: установив точку останова на вызов ReadFile
из java.dll
(выделено на стеке вызовов на скриншоте), мы обнаруживаем, что ReadFile
считывает уже вполне себе расшифрованные блоки байт‑кода порциями по 0x400
байт.
Таким образом, мы узнали механизм встраивания сентинеловской защиты в JIT-компилятор Java. Однако на этот раз нам хотелось бы пойти дальше, чем в предыдущей статье, и выяснить алгоритм шифрования байт‑кода. Для этого мы сначала ставим точку останова на вызов ReadFile
из java.dll
.
Чтобы отфильтровать все побочные чтения из других файлов, установим фильтр на чтение блоков только размером в 0x400
байт. Для этого в условии остановки прописываем r8==0x400
. По остановке в этой точке адрес буфера чтения будет находиться в регистре RDX
— ставим точку останова на первый байт этого буфера. Выполнение останавливается, когда в буфер записываются зашифрованные данные. Чтение байта из этого буфера тормозится в очень интересном месте — мы видим обфусцированный код sntljavaclsrt_x64
:
00007FFC46EB31F5 | movzx ecx,byte ptr ds:[r12+rax]
00007FFC46EB31FA | mov edx,edi
00007FFC46EB31FC | shr edx,18
00007FFC46EB31FF | add edx,ecx
00007FFC46EB3201 | movzx ecx,dl
00007FFC46EB3204 | movzx r11d,byte ptr ds:[r13+rcx]
00007FFC46EB320A | lea rcx,qword ptr ds:[7FFC46EB1A46]
00007FFC46EB3211 | mov edx,ecx
00007FFC46EB3213 | and edx,ebp
00007FFC46EB3215 | xor rcx,rbp
00007FFC46EB3218 | lea rcx,qword ptr ds:[rcx+rdx*2]
00007FFC46EB321C | jmp rcx
В регистре R12
— указатель на зашифрованный буфер, в регистре RDI
— смещение в нем, в регистре R13
— указатель на некую таблицу перекодировки. Результат этого фрагмента кода хранится в регистре R11
. 7FFC46EB1A46
— слегка закодированный адрес следующей инструкции. Немного попрыгав по обфусцированному коду, находим следующий фрагмент:
00007FFC46EB32DF | mov ecx,edi
00007FFC46EB32E1 | shr ecx,10
00007FFC46EB32E4 | add ecx,r11d
00007FFC46EB32E7 | movzx ecx,cl
00007FFC46EB32EA | movzx ecx,byte ptr ds:[r13+rcx+100]
00007FFC46EB32F3 | mov edx,edi
00007FFC46EB32F5 | shr edx,8
00007FFC46EB32F8 | add edx,ecx
00007FFC46EB32FA | movzx ecx,dl
00007FFC46EB32FD | movzx edx,byte ptr ds:[r13+rcx+200]
00007FFC46EB3306 | lea rcx,qword ptr ds:[7FFC46EB1A22]
00007FFC46EB330D | mov ebx,ecx
00007FFC46EB330F | and ebx,ebp
00007FFC46EB3311 | xor rcx,rbp
00007FFC46EB3314 | lea rcx,qword ptr ds:[rcx+rbx*2]
00007FFC46EB3318 | jmp rcx
Тут на выходе результат уже в RDX
. И наконец, финальный фрагмент, который представляет собой конец цикла по всем байтам (длина в R9
) и условный переход на начало:
00007FFC46EB3273 | movzx ecx,dl
00007FFC46EB3276 | add ecx,edi
00007FFC46EB3278 | movzx ecx,cl
00007FFC46EB327B | movzx ecx,byte ptr ds:[r13+rcx+300]
00007FFC46EB3284 | mov byte ptr ds:[r12+rax],cl
00007FFC46EB3288 | add edi,1
00007FFC46EB328B | add rax,1
00007FFC46EB328F | mov ecx,r8d
00007FFC46EB3292 | and ecx,ebp
00007FFC46EB3294 | lea rdx,qword ptr ds:[7FFC46EB1A92]
00007FFC46EB329B | mov ebx,edx
00007FFC46EB329D | and ebx,ebp
00007FFC46EB329F | xor rdx,rbp
00007FFC46EB32A2 | cmp rax,r9
00007FFC46EB32A5 | lea rcx,qword ptr ds:[r10+rcx*2]
00007FFC46EB32A9 | lea rdx,qword ptr ds:[rdx+rbx*2]
00007FFC46EB32AD | cmove rdx,rcx
00007FFC46EB32B1 | jmp rdx
Как видно, несмотря на всю навороченность защиты, алгоритм расшифровки простой как две копейки. Он представляет собой четыре табличных преобразования. В переводе на понятный язык программирования он выглядит примерно так (array
— раскодируемый фрагмент, table
— таблица):
for (int i=0;i<array.Length;i++)
{
byte r11 = table[(byte)(array[i] + (i >> 0x18))];
byte ecx = table[(byte)(r11 + (i >> 0x10))+0x100];
byte edx = table[(byte)(ecx + (i >> 0x8)) + 0x200];
array[i] = table[(byte)(edx + i)+0x300];
}
Таблица перекодировки размером 0x400
байт, указатель на которую находится в регистре R13
, прекрасно дампится на диск из отладчика при помощи следующей консольной команды:
savedata table.bin, R13, 0x400
После этого можно писать простенький пакетный раскодировщик для любого количества классов.
В заключение рассмотрим совсем уж экзотический случай. Предположим, у нас есть защищенное подобным протектором приложение и по неким причинам не нужно его полностью отвязывать от ключа, а надо просто немного расширить его функции: продлить триал, повысить редакцию, добавить опций. А требуемые возможности, как назло, находятся именно в защищенном классе. В прошлый раз мы выкрутились из такой неприятной ситуации, на лету поправляя расшифрованный байт‑код непосредственно в библиотеке защиты. В нынешнем случае мы имеем гораздо больше пространства для маневра — алгоритм шифрования простой, симметричный и реверсируемый. Грубо говоря, обратный алгоритм шифрования выглядит примерно так:
for (int i=0;i<array.Length;i++)
{
byte a=byte(indexOf(&table[0x300],array[i])-i);
byte b=byte(indexOf(&table[0x200],a)-(i >> 0x8));
byte c=byte(indexOf(&table[0x100],b)-(i >> 0x10));
array[i]=byte(indexOf(&table,c)-(i >> 0x18));
}
Это означает, что теперь нам не нужно извращаться, конструируя инлайн‑патч. Имея валидный ключ, мы можем расшифровать нужный класс, поправить его, например при помощи dirtyJoe (как — я описал в статье «Грязный Джо. Взламываем Java-приложения с помощью dirtyJOE»), затем снова закодировать файл и перепаковать его в JAR-архив.
Читайте ещё больше платных статей бесплатно: https://t.me/hacker_frei