Pull to refresh

Реверс-инжиниринг для самых маленьких: взлом кейгена

Reading time8 min
Views110K
Этот пост будет интересно действительно тем, кто только начинает интересоваться этой темой. У людей с опытом он, возможно, вызовет только зевки. За исключением разве что, может быть, эпилога
Реверс-инжиниринг в той менее легальной части, где он не касается отладки и оптимизации собственного продукта, касается в том числе и такой задачи: «узнать, а как у них это работает». Иначе говоря, восстановление исходного алгоритма программы, имея на руках ее исполнимый файл.
Для того, чтобы держаться азов и избежать некоторых проблем — «взломаем» не что-нибудь, а… кейген. В 90% он не будет запакован, зашифрован или иным способом защищен — в том числе и нормами международного права…

Вначале было слово. Двойное

Итак, нам нужен кейген и дизассемблер. Что касается второго — то предположим, что это будет Ida Pro. Подопытный безымянный кейген, найденный на просторах Сети:
image


Открыв файл кейгена в Ida, видим список функций.
image

Проанализировав этот список, мы видим несколько стандартных функций (WinMain, start, DialogFunc) и кучу вспомогательных-системных. Все это стандартные функции, составляющие каркас.
Пользовательские функции, которые представляют реализацию задач программы, а не ее обертку из API-шных и системных вызовов, дизассемблер не распознает и называет попросту sub_цифры. Учитывая, что такая функция здесь всего одна — она и должна привлечь наше внимание как, скорее всего, содержащая интересующий нас алгоритм или его часть.
image

Давайте запустим кейген. Он просит ввести две 4-значных строки. Предположим, в функцию расчета ключа отправляются сразу восемь символов. Анализируем код функции sub_401100. Ответ на гипотезу содержится в первых двух строках:
var_4= dword ptr -4
arg_0= dword ptr 8

Вторая строка недвусмысленно намекает нам на получение аргумента функции по смещению 8. Однако размер аргумента — двойное слово, равное 4 байтам, а не 8. Значит, вероятнее всего за один проход функция обрабатывает одну строку из четырех символов, а вызывается она два раза.
Вопрос, который наверняка может возникнуть: почему для получения аргумента функции резервируется смещение в 8 байт, а указывает на 4, ведь аргумент всего один? Как мы помним, стек растет вниз; при добавлении в стек значения стековый указатель уменьшается на соответствующее количество байт. Следовательно, после добавления в стек аргумента функции и до начала ее работы в стек добавляется что-то еще. Это, очевидно, адрес возврата, добавляемый в стек после вызова системной функции call.

Найдем места в программе, где встречаются вызовы функции sub401100. Таковых оказывается действительно два: по адресу DialogFunc+97 и DialogFunc+113. Интересующие нас инструкции начинаются здесь:
Относительно длинный кусок кода
loc_401196:
mov     esi, [ebp+hDlg]
mov     edi, ds:SendDlgItemMessageA
lea     ecx, [ebp+lParam]
push    ecx             ; lParam
push    0Ah             ; wParam
push    0Dh             ; Msg
push    3E8h            ; nIDDlgItem
push    esi             ; hDlg
call    edi ; SendDlgItemMessageA
lea     edx, [ebp+var_1C]
push    edx             ; lParam
push    0Ah             ; wParam
push    0Dh             ; Msg
push    3E9h            ; nIDDlgItem
push    esi             ; hDlg
call    edi ; SendDlgItemMessageA
pusha
movsx   ecx, byte ptr [ebp+lParam+2]
movsx   edx, byte ptr [ebp+lParam+1]
movsx   eax, byte ptr [ebp+lParam+3]
shl     eax, 8
or      eax, ecx
movsx   ecx, byte ptr [ebp+lParam]
shl     eax, 8
or      eax, edx
shl     eax, 8
or      eax, ecx
mov     [ebp+arg_4], eax
popa
mov     eax, [ebp+arg_4]
push    eax
call    sub_401100


Сначала подряд вызываются две функции SendDlgItemMessageA. Эта функция берет хэндл элемента и посылает ему системное сообщение Msg. В нашем случае Msg в обоих случаях равен 0Dh, что является шестнадцатиричным эквивалентом константы WM_GETTEXT. Здесь извлекаются значения двух текстовых полей, в которые пользователь ввел «две 4-символьных строки». Буква А в названии функции указывает, что используется формат ASCII — по одному байту на символ.
Первая строка записывается по смещению lParam, вторая, что очевидно — по смещению var_1C.
Итак, после выполнения функций SendDlgItemMessageA текущее состояние регистров сохраняется в стеке с помощью команды pusha, затем в регистры ecx, edx и eax записывается по одному байту одной из строк. В результате каждый из регистров принимает вид: 000000##. Затем:
  1. Команда SHL сдвигает битовое содержимое регистра eax на 1 байт или, другими словами, умножает арифметическое содержимое на 100 в шестнадцатиричной системе или на 256 в десятичной. В результате еах принимает вид 0000##00 (например, 00001200).
  2. Выполняется операция OR между полученным значением eax и регистром ecx в виде 000000## (пусть это будет 00000034). В результате еах будет выглядеть так: 00001234.
  3. В «освободившийся» есх записывается последний, четвертый байт строки.
  4. Содержимое еах снова сдвигается на байт, освобождая место в младшем байте для следующей команды OR. Теперь еах выглядит так: 00123400.
  5. Инструкция OR выполняется, на этот раз между еах и edx, который содержит, допустим, 00000056. Теперь еах — 00123456.
  6. Повторяются два шага SHL eax,8 и OR, в результате чего новое содержимое ecx (00000078) добавляется в «конец» еах. В итоге, еах хранит значение 12345678.

Затем это значение сохраняется в «переменной» — в области памяти по смещению arg_4. Состояние регистров (их прежние значения), ранее сохраненное в стеке, вытаскивается из стека и раздается регистрам. Затем в регистр еах снова записывается значение по смещению arg_4 и это значение выталкивается из регистра в стек. После этого следует вызов функции sub_401100.

В чем смысл этих операций? Выяснить очень просто даже на практике, без теории. Поставим в отладчике брейкпойнт, например, на инструкции push eax (перед самым вызовом подфункции) и запустим программу на выполнение. Кейген запустится, попросит ввести строки. Введя qwer и tyui и остановившись на брейкпойнте, смотрим значение еах: 72657771. Декодируем в текст: rewq. То есть физический смысл этих операций — инверсия строки.

Теперь мы знаем, что в sub_401100 передается одна из исходных строк, перевернутая задом наперед, в размере двойного слова, целиком умещающаяся в любом из стандартных регистров. Пожалуй, можно взглянуть на инструкции sub_401100.
Еще один относительно длинный кусок кода

sub_401100 proc near

var_4= dword ptr -4
arg_0= dword ptr  8

push    ebp
mov     ebp, esp
push    ecx
push    ebx
push    esi
push    edi
pusha
mov     ecx, [ebp+arg_0]
mov     eax, ecx
shl     eax, 10h
not     eax
add     ecx, eax
mov     eax, ecx
shr     eax, 5
xor     eax, ecx
lea     ecx, [eax+eax*8]
mov     edx, ecx
shr     edx, 0Dh
xor     ecx, edx
mov     eax, ecx
shl     eax, 9
not     eax
add     ecx, eax
mov     eax, ecx
shr     eax, 11h
xor     eax, ecx
mov     [ebp+var_4], eax
popa
mov     eax, [ebp+var_4]
pop     edi
pop     esi
pop     ebx
mov     esp, ebp
pop     ebp
retn
sub_401100 endp

В самом начале здесь ничего интересного — состояния регистров заботливо сохраняются в стеке. А вот первая команда, которая нам интересна — следующая за инструкцией PUSHA. Она записывает в есх аргумент функции, хранящийся по смещению arg_0. Потом это значение перекидывается в еах. И обрезается наполовину: как мы помним, в нашем примере в sub_401100 передается 72657771; логический сдвиг влево на 10h (16 в десятичной) превращает значение регистра в 77710000.
После этого значение регистра инвертируется инструкцией NOT. Это значит, что в двоичном представлении регистра все нули превращаются в единицы, а единицы — в нули. Регистр после выполнения этой инструкции содержит 888ЕFFFF.
Инструкция ADD добавляет (прибавляет, плюсует, и т.д.) получившееся значение к исходному значению аргумента, которое все еще содержится в регистре есх (теперь понятно, зачем было записывать его сначала в есх, а затем в еах?). Результат сохраняется в есх. Проверим, как будет выглядеть есх после выполнения этой операции: FAF47770.
Этот результат копируется из есх в еах, после чего к содержимому еах применяется инструкция SHR. Эта операция противоположна SHL — если последняя сдвигает разряды влево, то первая сдвигает их вправо. Подобно тому, как операция логического сдвига влево эквивалентна умножению на степени двойки, операция логического сдвига вправо эквивалентна такому же делению. Посмотрим, какое значение окажется результатом этой операции: 7D7A3BB.
Теперь совершим еще одно насилие над содержимым еах и есх: инструкция XOR — сложение по модулю 2 или «исключающее ИЛИ». Суть этой операции, грубо говоря, в том, что в результат ее равен единице (истине) только, если операнды ее раЗнозначные. Например, в случае 0 xor 1 результатом будет истина, или единица. В случае 0 xor 0 или 1 xor 1 — результатом будет ложь, или ноль. В нашем случае в результате выполнения этой инструкции применительно к регистрам еах (7D7A3BB) и есх (FAF47770) в регистр еах запишется значение FD23D4CB.

Следующая команда LEA ecx, [eax+eax*8] элегантно и непринужденно умножает еах на 9 и записывает результат в есх. Затем это значение копируется в edx и сдвигается вправо на 13 разрядов: получаем 73213 в еdx и E6427B23 в есх. Затем — снова ксорим есх и edx, записывая в есх E6454930. Копируем это в еах, сдвигаем влево на 9 разрядов: 8А926000, затем инвертируем это, получая 756D9FFF. Прибавляем это значение к регистру есх — имеем 5BB2E92F. Копируем это в еах, сдвигаем вправо аж на 17 разрядов — 2DD9 — и ксорим с есх. Получаем в итоге 5BB2C4F6. Затем… затем… что там у нас? Что, все?..
Итак, мы сохраняем это значение в область памяти по смещению var_4, загружаем из стека состояния регистров, снова берем из памяти итоговое значение и окончательно забираем из стека оставшиеся там состояния регистров, сохраненные в начале. Выходим из функции. Ура!.. впрочем, радоваться еще рано, пока что на выходе из первого вызова функции мы имеем максимум — четыре полупечатных символа, а ведь у нас еще целая необработанная строка есть, да и эту еще к божескому виду привести надо.

Перейдем на более высокий уровень анализа — от дизассемблера к декомпилятору. Представим всю функцию DialogFunc, в которой содержатся вызовы sub_401100, в виде С-подобного псевдокода. Собственно говоря, это дизассемблер называет его «псевдокодом», на деле это практически и есть код на С, только страшненький. Глядим:
Нужно больше кода. Нужно построить зиккурат.
  SendDlgItemMessageA(hDlg, 1000, 0xDu, 0xAu, (LPARAM)&lParam);
  SendDlgItemMessageA(hDlg, 1001, 0xDu, 0xAu, (LPARAM)&v15);
  v5 = sub_401100((char)lParam | ((SBYTE1(lParam) | ((SBYTE2(lParam) | (SBYTE3(lParam) << 8)) << 8)) << 8));
  v6 = 0;
  do
  {
    v21[v6] = v5 % 0x24;
    v7 = v21[v6];
    v5 /= 0x24u;
    if ( v7 >= 10 )
      v8 = v7 + 55;
    else
      v8 = v7 + 48;
    v21[v6++] = v8;
  }
  while ( v6 < 4 );
  v22 = 0;
  v9 = sub_401100(v15 | ((v16 | ((v17 | (v18 << 8)) << 8)) << 8));
  v10 = 0;
  do
  {
    v19[v10] = v9 % 0x24;
    v11 = v19[v10];
    v9 /= 0x24u;
    if ( v11 >= 10 )
      v12 = v11 + 55;
    else
      v12 = v11 + 48;
    v19[v10++] = v12;
  }
  while ( v10 < 4 );
  v20 = 0;
  wsprintfA(&v13, "%s-%s-%s-%s", &lParam, &v15, v21, v19);
  SendDlgItemMessageA(hDlg, 1002, 0xCu, 0, (LPARAM)&v13);


Это уже легче читать, чем ассемблерный листинг. Однако не во всех случаях можно положиться на декомпилятор: нужно быть готовым часами следить за нитью ассемблерной логики, за состояниями регистров и стека в отладчике… а потом давать письменные объяснения сотрудникам ФСБ или ФБР. Под вечер у меня особенно смешные шутки.
Как я уже сказал, читать это легче, но до совершенства еще далеко. Давайте проанализируем код и дадим переменным более удобочитаемые названия. Ключевым переменным дадим понятные и логичные названия, а счетчикам и временным — попроще.
То же самое, только переведенное с китайского на индусский.
SendDlgItemMessageA(hDlg, 1000, 0xDu, 0xAu, (LPARAM)&first_given_string);
  SendDlgItemMessageA(hDlg, 1001, 0xDu, 0xAu, (LPARAM)&second_given_string);
  first_given_string_encoded = sub_401100((char)first_given_string | ((SBYTE1(first_given_string) | ((SBYTE2(first_given_string) | (SBYTE3(first_given_string) << 8)) << 8)) << 8));
  i = 0;
  do
  {
    first_result_string[i] = first_string_encoded % 0x24;
    temp_char = first_result_string[i];
    first_string_encoded /= 0x24u;
    if ( temp_char >= 10 )
      next_char = temp_char + 55;
    else
      next_char = temp_char + 48;
    first_result_string[i++] = next_char;
  }
  while ( i < 4 );

  some_kind_of_data = 0;
  second_string_encoded = sub_401100(byte1 | ((byte2 | ((byte3 | (byte4 << 8)) << 8)) << 8));
  j = 0;
  do
  {
    second_result_string[j] = second_string_encoded % 0x24;
    temp_char2 = second_result_string[j];
    second_string_encoded /= 0x24u;
    if ( temp_char2 >= 10 )
      next_char2 = temp_char2 + 55;
    else
      next_char2 = temp_char2 + 48;
    second_result_string[j++] = next_char2;
  }
  while ( j < 4 );
  yet_another_some_kind_of_data = 0;
  wsprintfA(&buffer, "%s-%s-%s-%s", &first_given_string, &second_given_string, first_result_string, second_result_string);
  SendDlgItemMessageA(hDlg, 1002, 0xCu, 0, (LPARAM)&buffer);


Эпилог

Level complete. Cледующая (и заключительная) цель — это написание своего кейгена по этому алгоритму. Писать я, по привычке, буду на языке скриптов командной оболочки Linux bash. test ${#reg1} -gt && reg1=`echo "${reg1: -8}"` — это обрезка строки, содержащей эмулированное значение регистра, до 8 младших символов. При выполнении операций туда добавлялись лишние старшие разряды. Все остальное — кропотливая эмуляция ассемблерного листинга. Я же указал вверху хаб «Ненормальное программирование», да?..

bash-реализация пресловутой sub_401100:
image

Основная функция кейгена:
image

Тестирование и сравнение:
image

image


Заключение

Теперь мы могли бы генерировать ключи к некому игровому ПО прямо из консоли Linux, однако это невозможно по нескольким причинам: во-первых, я не знаю, для какого именно ПО предназначен этот кейген — я скачал его наугад в интернете; во-вторых, использование поддельных ключей и нелицензионного проприетарного ПО запрещено нормами международного права. ;)
image
Tags:
Hubs:
+50
Comments32

Articles

Change theme settings