Реверс-инжиниринг для самых маленьких: взлом кейгена
Этот пост будет интересно действительно тем, кто только начинает интересоваться этой темой. У людей с опытом он, возможно, вызовет только зевки. За исключением разве что, может быть, эпилога…Реверс-инжиниринг в той менее легальной части, где он не касается отладки и оптимизации собственного продукта, касается в том числе и такой задачи: «узнать, а как у них это работает». Иначе говоря, восстановление исходного алгоритма программы, имея на руках ее исполнимый файл.Для того, чтобы держаться азов и избежать некоторых проблем — «взломаем» не что-нибудь, а… кейген. В 90% он не будет запакован, зашифрован или иным способом защищен — в том числе и нормами международного права…Вначале было слово. Двойное Итак, нам нужен кейген и дизассемблер. Что касается второго — то предположим, что это будет Ida Pro. Подопытный безымянный кейген, найденный на просторах Сети: Открыв файл кейгена в Ida, видим список функций.
Проанализировав этот список, мы видим несколько стандартных функций (WinMain, start, DialogFunc) и кучу вспомогательных-системных. Все это стандартные функции, составляющие каркас.Пользовательские функции, которые представляют реализацию задач программы, а не ее обертку из API-шных и системных вызовов, дизассемблер не распознает и называет попросту sub_цифры. Учитывая, что такая функция здесь всего одна — она и должна привлечь наше внимание как, скорее всего, содержащая интересующий нас алгоритм или его часть.
Давайте запустим кейген. Он просит ввести две 4-значных строки. Предположим, в функцию расчета ключа отправляются сразу восемь символов. Анализируем код функции sub_401100. Ответ на гипотезу содержится в первых двух строках:
var_4= dword ptr -4arg_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##. Затем:
Команда SHL сдвигает битовое содержимое регистра eax на 1 байт или, другими словами, умножает арифметическое содержимое на 100 в шестнадцатиричной системе или на 256 в десятичной. В результате еах принимает вид 0000##00 (например, 00001200). Выполняется операция OR между полученным значением eax и регистром ecx в виде 000000## (пусть это будет 00000034). В результате еах будет выглядеть так: 00001234. В «освободившийся» есх записывается последний, четвертый байт строки. Содержимое еах снова сдвигается на байт, освобождая место в младшем байте для следующей команды OR. Теперь еах выглядит так: 00123400. Инструкция OR выполняется, на этот раз между еах и edx, который содержит, допустим, 00000056. Теперь еах — 00123456. Повторяются два шага 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:
Основная функция кейгена:
Тестирование и сравнение:
Заключение Теперь мы могли бы генерировать ключи к некому игровому ПО прямо из консоли Linux, однако это невозможно по нескольким причинам: во-первых, я не знаю, для какого именно ПО предназначен этот кейген — я скачал его наугад в интернете; во-вторых, использование поддельных ключей и нелицензионного проприетарного ПО запрещено нормами международного права. ;)