Итак, в этой статье вы узнаете, как я писал кейген к ArtMoney (здесь будет описана версия 7.45.1).
Этап первый: Анализ исполняемого файла
Я установил себе английскую версию программы, чтобы IDA и прочие утилиты нормально искали текст.
Первым делом, нужно выяснить, на чем написана/чем упакована AM. Откроем ее (файлик am745.exe) в моем любимом ExeInfo PE:
Нам говорят, что это Aspack v2.24 — 2.34. Что ж, вроде бы не сложный упаковщик. Я сниму его первым автоматическим распаковщиком (ибо статья не о распаковке).
Этап второй: Снова анализы
Снова смотрим в ExeInfo PE, и видим, что программа написана на Borland Delphi. Прекрасно! Воспользуемся супер-программой для анализа Delphi-программ: IDR (Interactive Delphi Reconstructor). Кстати, также у неё есть открытые исходники. Версию IDE там и определим.
Скачаем все доступные базы, и положим в каталог с IDR. Затаскиваем в реконструктор нашего распакованного подопытного, ждем окончания анализа.
Весь процесс исследования я буду производить в IDA Pro (плюс Hex Rays), поэтому давайте сгенерим IDC-скрипт, который скажет ей обо всех именах форм, методов, классов и т.п. Жмем Tools > IDC Generator в меню IDR. Ждем, пока создается скрипт.
Далее, откроем IDA, и тоже затолкаем в нее нашу программу. Дождемся окончания анализа. Затем применим сгенеренный IDC-скрипт: в IDA Pro жмем File > Script File..., выбираем скрипт. Ждем применения.
Для того, чтобы IDA могла нормально декомпилить Delphi код, ей необходимо сказать, что мы имеем дело с Delphi компилятором, и __fastcall вызовами. К сожалению, сгенерированный IDC, как и сама IDA об этом ничего не говорят/не знают.
Переходим в настройки компилятора IDA: Options > Compiler...:
Далее жмём переанализировать программу: Options > General > Analysis > Reanalyze program и OK.
Ещё не всё...:) IDC-скрипт почему-то не разметил границы библиотечных функций. Из-за чего процесс дизассемблирования и декомпиляции не становится проще. Придётся размечать, и указывать типы (клавиша Y). Берём call на известную функцию и смотрим на её границы. Если функция начинается не там, куда ведёт call, исправляем. Переходим на инструкцию, что находится выше начала функции (чаще всего, это retn), и жмём там E (указать адрес конца функции). Так-то лучше.
Теперь нужно указать тип функции. Возьмём для примера @LStrClr. Заботливый IDR указал в комментарии, что данная функция принимает один аргумент — адрес строки, значит обозначим прототип функции как (помним, что у Delphi конвенция вызовов fastcall):
void __fastcall LStrClr(char*)
Надеюсь, понятно почему именно так. Ну а void потому, что procedure.
И так повторяем с многими и многими функциями… При указании прототипов пользуемся такими несложными правилами:
- Аргументы передаются через eax, edx, ecx, стэк (слева направо). Это поможет, если декомпилятор натыкается на "positive sp value";
- У всяких StrCatN функций, если указан ArgCnt: Integer, то это vararg функции, и прототип будет содержать .... В декомпиляторе нужно становиться на каждом месте вызова таких функций, и жать +/- на Numpad-клавиатуре, добавляя/удаляя аргументы. Количество можно посмотреть в дизазм-листинге. Пример прототипа: "void LStrCatN(char *, char *, ...)";
- Если функция, судя по Delphi-прототипу, возвращает указатель на строку, то, чаще всего, это неявный выходной аргумент в прототипе, и он должен быть добавлен как последний аргумент тоже.
Ну, теперь можно приступать к поиску процедуры регистрации…
Этап три: Где ты моя, ненаглядная, где?
Переходим в IDR к формам, и, (скажу сразу, открываем форму Form28), переключаем на визуальный просмотр. Видим окошко регистрации. Прокрутим окно и найдем три контура кнопок. Левая из них — кнопка ОК, применение регистрации:
Этап четыре: Анализ OnClick(). Поверхность
Жмем ПКМ по ней и переходим в обработчик OKClick. В IDA перейдем на адрес начала этой процедуры (кнопка G).
Первым делом укажем, что это bp-based функция (т.е. адресация локальных переменных идёт через регистр EBP минус смещение), а то локальные переменные не распознаются. Жмём Alt+P (или ПКМ по функции, Edit function...), и ставим галку BP-based frame.
Попробуем декомпилировать… Если всё прошло успешно, увидим ужасный псевдокод декомпилера, иначе — фиксим прототипы:
Жмём Y на каждом вызове библиотечной/самописной функции, и следим, чтобы прототипы были с __fastcall, и аргументы были правильно заданы.
Первый цикл, судя по IDR — это проверка ключа на принадлежность символов алфавиту, и склейка их в глобальную переменную. Обзовём её g_LicKey.
Далее идёт вызов неизвестной пока функции, и, судя по всему, это собственно сама функция проверки ключа…
Этап пять: Проверка ключа (вид сбоку)
Данная функция принимает два аргумента: первый — это out параметр, он будет содержать какой-то код ошибки, а второй — буква ('A' — в английской версии, либо 'B' — в русской).
Декомпилируем…
Код довольно большой, согласен, но, если подходить грамотно, не спеша, и не шарахаясь от обилия кода, можно успешно разобрать, что же у нас тут происходит.
Очень часто вы можете встретить подобный код, который выдал декомпилятор (имена переменных, понятно, могут отличаться):
v2 = g_LicKey;
if ( g_LicKey )
v2 = (char *)*((_DWORD *)g_LicKey - 1);
Здесь стоит сказать, что у Delphi свой особый строковый тип, и в виде структуры его можно описать следующим образом:
d_str struc ; (sizeof=0x8, mappedto_243, variable size)
_top dd ?
length dd ?
string db 0 dup(?) ; string(C)
delphi_string ends
Первый дворд всегда равен 0xFFFFFFFF, второй — это длина строки, и далее идёт сама строка. Поэтому, подобные конструкции кода лишь получают длину строки.
Ещё одно важное замечание: Индексы в строках у Delphi из-за этого идут с 1, поэтому вы часто будете встречать в декомпиляторе/дизассемблере вычитание 1 из индекса (для соответствия реальному расположению символов в памяти).
Далее идёт проверка длины: >= 70 и <= 500.
Далее видим проверку первого символа ключа на равенство второму аргументу функции, т.е. 'A', либо 'B', и установку флага в зависимости от символа. Я обозвал этот флаг как rus_ver.
Согласитесь, выглядит громоздко:
LStrFromChar((char *)&v193, (char *)(unsigned __int8)g_LicKey[2]);
gvar_006F58C0 = Pos(v193, *(char **)_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ1234567890KLMNOPQRSTUVWXYZ_);
gvar_006F58C8 = *(_BYTE *)(*(_DWORD *)_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ1234567890KLMNOPQRSTUVWXYZ_
+ (unsigned __int8)gvar_006F58C0
- 3);
gvar_006F58C9 = *(_BYTE *)(*(_DWORD *)_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ1234567890KLMNOPQRSTUVWXYZ_
+ (unsigned __int8)gvar_006F58C0
- 4);
LStrFromChar((char *)&v192, (char *)(unsigned __int8)g_LicKey[1]);
v242 = Pos(v192, *(char **)_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ1234567890KLMNOPQRSTUVWXYZ_);
А вот так куда лучше:
LStrFromChar((char *)&lic_key, g_LicKey[2]);
g_PosChar2 = Pos(lic_key, str_EngAlpha);
g_AlphaChar1 = str_EngAlpha[g_PosChar2 - 3];
g_AlphaChar2 = str_EngAlpha[g_PosChar2 - 4];
LStrFromChar((char *)&key_char_1, g_LicKey[1]);
key_char1_pos = Pos(key_char_1, str_EngAlpha);
Далее — проверка key_char1_pos на равенство 12.
Теперь происходит суммирование символов ключа, кроме последних двух, и, пока сумма больше 0xFF, деление на 2, и инкремент на 1:
idx = key_len_minus_2 - 2;
if ( key_len_minus_2 - 2 > 0 )
{
key_idx = 1;
do
{
key_sum += (unsigned __int8)g_LicKey[key_idx++ - 1];
--idx;
}
while ( idx );
}
for ( ; key_sum > 0xFF; key_sum = (key_sum >> 1) + 1 )
;
Преобразовываем полученную сумму в hex-строку, и, затем, в lowercase.
Теперь получаем два последних символа ключа, передаём каждый из них в какую-то функцию, и на выходе получаем по преобразованному символу. Давайте разберём эту функцию…
Этап шесть: Преобразование символа
В общем виде, функция преобразования символа выглядит вот так:
LStrFromChar((char *)&inChar_2, inChar);
v3 = Pos(inChar_2, str_EngAlpha);
if ( v3 > 0 )
{
if ( g_PosChar2 > 0xAu )
{
for ( i = 1; i < g_PosChar2 - 5; i += 2 )
{
if ( i == v3 )
{
v3 = i + 1;
}
else if ( v3 == i + 1 )
{
v3 = i;
}
}
}
for ( j = g_PosChar2 + 1; j < 60; j += 2 )
{
if ( j == v3 )
{
v3 = j + 1;
}
else if ( v3 == j + 1 )
{
v3 = j;
}
}
v6 = v3 - g_PosChar2 + ((char)(v3 - g_PosChar2) < 0 ? 0x3E : 0);
if ( flag_1 )
v2 = str_RusAlpha1[v6 - 1];
else
v2 = str_EngAlpha1[v6 - 1];
}
return v2;
Вроде выглядит просто. Скажу сразу, нам придётся её инвертировать. Назовём её DecodeChar.
В итоге, два последних символа ключа преобразовываются этой функцией, и сравниваются с ранее полученной hex-суммой всех остальных символов ключа.
ToRevert (данным словом я буду отмечать важные моменты в реверсе функции проверки ключа): В самом конце, когда ключ будет готов, считаем сумму его символов, преобразовываем в хекс (0xXX), затем, каждый ниббл — инвертированным DecodeChar, и доклеиваем к ключу.
Видим далее, что третий символ ключа преобразовывается, переводится из хекса в int, и проверяются биты. Так это всё и обзовём:
v202 = meffi_DecodeChar(g_LicKey[3], 0);
PStrCpy(&v132, str_Hex);
v134 = v202;
v133 = 1;
PStrNCat(&v132, &v133, 2);
LStrFromString((char *)&v129, &v132);
key_char_3 = StrToInt(v129);
k3_flag1 = (key_char_3 & 1) != 0;
k3_flag2 = (key_char_3 & 2) != 0;
k3_flag3 = (key_char_3 & 4) != 0;
k3_flag4 = (key_char_3 & 8) != 0;
Пока назначение битов мы не знаем, поэтому даём им хоть какие-то осмысленные названия.
Снова функция, которую мы не знаем, и в неё передаётся один из флагов:
key_idx = 5;
sub_66408C(&key_idx, k3_flag1, &v128);
Последний параметр, судя по всему, выходная строка, т.к. передаётся дальше в Trim() и используется далее.
Сразу дадим этой функции соответствующий прототип:
void __fastcall sub_66408C(int idx, bool flag, char *output)
Этап семь: Чтение строки из ключа
Да, именно этим данная функция и занимается. Что показывает отладка, и беглый обзор кода. Но обо всём по-порядку.
while ( g_LicKey[*(_DWORD *)idx_1 - 1] != g_AlphaChar1 && LStrLen(g_LicKey) >= *(_DWORD *)idx_1 )
Сразу отметим, что первый параметр используется как указатель на dword (int), поэтому меняем ему тип на int * (в Delphi это var-аргументами зовётся).
После всех преобразований типов, и корректировки аргументов, наша функция приобретает вид:
while ( g_LicKey[*idx_1 - 1] != g_AlphaChar1 && LStrLen(g_LicKey) >= *idx_1 )
{
c1 = g_LicKey[*idx_1 - 1];
if ( c1 == g_AlphaChar2 )
{
LStrFromChar((char *)&c1_str, c1);
c1_str_ = c1_str;
LStrFromChar((char *)&c2_str, g_LicKey[*idx_1]);
c2_str_ = c2_str;
LStrFromChar((char *)&c3_str, g_LicKey[*idx_1 + 1]);
LStrCatN(c3_str, c2_str_, c1_str_, gvar_0070DF4C);
PStrCpy(&str_hex, str_Hex_0);
cc[1] = meffi_DecodeChar(g_LicKey[*idx_1], 0);
cc[0] = 1;
PStrNCat(&str_hex, cc, 2);
PStrCpy(&hexVal, &str_hex);
cc[1] = meffi_DecodeChar(g_LicKey[*idx_1 + 1], 0);
cc[0] = 1;
PStrNCat(&hexVal, cc, 3);
LStrFromString((char *)&hexVal_1, &hexVal);
value = ValLong(hexVal_1, &outCode);
LStrFromChar((char *)&value_1, value);
LStrCat((char *)&output_2, value_1);
*idx_1 += 2;
}
else
{
LStrFromChar((char *)&keyChar, g_LicKey[*idx_1 - 1]);
LStrCat((char *)&gvar_0070DF4C, keyChar);
c = meffi_DecodeChar(g_LicKey[*idx_1 - 1], flag_1);
LStrFromChar((char *)&c_str, c);
LStrCat((char *)&output_2, c_str);
}
++*idx_1;
}
++*idx_1;
Видим, что происходит чтение символов ключа, до тех пор, пока не встретится g_AlphaChar1. Если символ не равен g_AlphaChar2, доклеиваем его в глобальную переменную, а преобразованный с помощью DecodeChar() символ доклеиваем в выходной буфер.
Если же нам попался g_AlphaChar2 символ, читаем следующие за ним два символа, преобразовываем их, переводим в число, и приклеиваем к выходному буферу. Эти же два символа в непреобразованном виде доклеиваем вместе с g_AlphaChar2 к глобальной переменной. Назовём её g_stringFromKey1.
Судя по всему, эту функцию можно назвать DecodeString.
ToRevert: Строку, которую мы захотим закодировать в ключ, придётся преобразовывать с помощью функции, обратной для DecodeString().
P.S. На этом первую часть статьи про кейгенинг ArtMoney я пожалуй закончу. Во второй части мы продолжим декомпиляцию кода проверки ключа, столкнувшись с новыми трудностями, и дурацким на вид кодом. Но, разве нас это остановит?
P.P.S. Даёшь больше интересных статей по реверсу!
Комментарии (7)
morello
13.02.2017 10:09Интересен один момент — а как к подобной статье относится разработчик? Почему бы не рассмотреть схожую тему с программой, которая более не поддерживается? Это явно было бы более «безобидно».
DrMefistO
13.02.2017 10:20Есть один момент в алгоритме, который не позволит каждому сделать себе ключ. Но о нём далее.
А разработчик сказал как-то, что проблемы индейцев шерифа, мягко скажем, не беспокоят.
Что бы это не значило.morello
13.02.2017 10:28Всё равно было бы правильнее использовать, для анализа, приложение, которое более не поддерживается. Но тема, безусловно — интересная.
Я, в своё время, анализировал защиту для одной программы, потому что отсутствовала какая-либо связь с разработчиком — а новую копию установить «ой как требовалось»! И тогда понял, что защитить программу на C#, да еще и на .Net Compact (для Windows CE) не такая уже и легкая задача, потому как код можно «достать» даже с правильным обозначением переменных, т.е. получить исходник практически один-в-один.
Evengard
Слушайте, а можете написать статью про ручной анпак экзешника под каким нить достаточно серьёзным пакером? Ну например Темиды. Желательно реальной программы, а не анпакми.
DrMefistO
Может кто-то и согласится, но мне данная тема не очень интересна.:) Да и зависеть всё будет от выбранного уровня виртуализации кода, установленного при протекте. Там геморроя много.