Приветствую! Сам ArtMoney был закейгенен мной давным-давно. Я не первый раз уже пробую начать писать статью о том, как происходил кейгенинг этой программы, но, всегда где-то стопорился. На этот раз, я решил доделать все до конца! Плюс, эту статью можно считать продолжением цикла статей о крякинге для новичков.

Итак, в этой статье вы узнаете, как я писал кейген к 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)


  1. Evengard
    11.02.2017 23:12
    +3

    Слушайте, а можете написать статью про ручной анпак экзешника под каким нить достаточно серьёзным пакером? Ну например Темиды. Желательно реальной программы, а не анпакми.


    1. DrMefistO
      11.02.2017 23:16
      +3

      Может кто-то и согласится, но мне данная тема не очень интересна.:) Да и зависеть всё будет от выбранного уровня виртуализации кода, установленного при протекте. Там геморроя много.


  1. morello
    13.02.2017 10:09

    Интересен один момент — а как к подобной статье относится разработчик? Почему бы не рассмотреть схожую тему с программой, которая более не поддерживается? Это явно было бы более «безобидно».


    1. DrMefistO
      13.02.2017 10:20

      Есть один момент в алгоритме, который не позволит каждому сделать себе ключ. Но о нём далее.

      А разработчик сказал как-то, что проблемы индейцев шерифа, мягко скажем, не беспокоят.
      Что бы это не значило.


      1. morello
        13.02.2017 10:28

        Всё равно было бы правильнее использовать, для анализа, приложение, которое более не поддерживается. Но тема, безусловно — интересная.
        Я, в своё время, анализировал защиту для одной программы, потому что отсутствовала какая-либо связь с разработчиком — а новую копию установить «ой как требовалось»! И тогда понял, что защитить программу на C#, да еще и на .Net Compact (для Windows CE) не такая уже и легкая задача, потому как код можно «достать» даже с правильным обозначением переменных, т.е. получить исходник практически один-в-один.


  1. spiiin
    13.02.2017 14:16

    Первым делом укажем, что это bp-based функция

    Пояснить бы, что это такое, гугл не помогает


    1. DrMefistO
      13.02.2017 14:50
      +2

      Это ebp-фреймы. Когда адресация локальных переменных идёт через регистр EBP.