Исследование защиты игры Limbo. Кейген
Всем привет. Многие знают об этой замечательной игре — LIMBO! Вы даже наверняка покупали ее в Стиме, или качали с торрентов…Я тоже ее купил когда-то (что и вам советую!), и прошел). Но, как всегда, мне было этого мало, и я, из спортивного интереса, решил изучить ее защиту. Так и появился кейген к игре LIMBO.В этой статье я расскажу и покажу Вам, как я это делал.
Этап первый: Поверхностный осмотр пациента Полный инсталлятор игры можно скачать здесь. Установив игру, первым делом, как обычно, выясняем, на чем написан главный исполняемый файл. Я воспользуюсь для этого ExeInfo PE. Видим: Visual Studio 2008. IDA Pro прекрасно с ней справляется, поэтому туда и отправим. Я буду пользоваться связкой IDA Pro + HexRays, т.е. с декомпилятором — для ускорения работы.
Этап второй: что мы ищем? Первым делом, дадим Иде проанализировать limbo.exe — главный исполняемый файл игры.Далее, нужно определить, что именно мы, собственно, хотим найти здесь.=) Запустим игру!
Видим волшебную надпись «UNLOCK FULL GAME». На нее и нажмем. Далее нас ожидает нежданчик (по крайней мере, я, когда первый раз выбирал этот пункт меню, я ожидал увидеть поле ввода на графическом движке игры, или типа того, а оказалось все гораздо проще…):
Да, да! Именно обычное окошко!=) Нам же легче. Попробуем что-нибудь ввести, и нажать Unlock. Как-то так:
Ну что ж, поищем по тексту в IDA, чтобы затем от него отталкиваться, и найти место проверки. И тут меня ожидал облом…По тексту сообщения в окне ошибки Ида мне ничего не нашла! То же самое сказал мне и поиск по содержимому через Total Commander. Возможно, сообщение зашифровано. Можно попробовать найти вызов окна отталкиваясь от вызова MessageBoxA/W. Но, я пошел другим путем, который, почему-то, мало где в статьях описывают.
Этап третий: Нажми меня Мы поступим следующим образом. Откроем любой удобный Вам редактор ресурсов, затащим в него ехе-шник, найдем окошко диалога ввода ключа, а в нем — кнопку Unlock. Сказано — сделано: На скрине я выделил ID нашей кнопки. По нему мы и будем искать, где именно обрабатывается нажатие. Откроем Иду, нажмем Alt+I (Search → immediate value…), введем число 203 (без 0x, т.к. десятичное), и посмотрим, что найдется. А нашлось вот это:
Видите те строчки, которые Ида пометила как; nIDDlgItem? С них и начнем. Двойным кликом переходим на первый из таких результатов:
Зеленой стрелкой я обозначил место, на которое указала Ида, а чуть ниже (привычка: прокручивать выше/ниже искомого места) — стрелкой обозначено место вызова одной интересной API-функции: GetDlgItemTextA. Судя по названию по MSDN, эта функция получает текст указанного элемента окна в буфер.
Почему я сразу не искал по ID поля ввода? Можно, конечно и так было сделать. Но, мало ли какие действия происходят после нажатия кнопки, еще до вычитывания текста из поля.
Итак, проследим, куда уходит полученный серийник. Прокручиваем листинг, чтобы видеть место вызова API-функции целиком: Мой «намыленный» взгляд подсказывает мне, что полученный буфер (Ида обозначила его как var_134) передается прямиком в следующую за вызовом GetDlgItemTextA функцию, которая возвращает в al нулевое, либо ненулевое значение (похоже на результат проверки ключа). Давайте проверим догадку…
Этап четыре: Декомпиляция Заходим в функцию. Видим там прыжок на еще один адрес — переходим по нему. Видим нормальный код, поэтому смело жмем там F5 (вызываем HexRays Decompiler).Результат декомпиляции bool __cdecl sub_48D410(int a1) { int v1; // esi@7 char *v2; // ebp@7 unsigned int v3; // edi@7 int v4; // ST28_4@9 int v5; // edx@9 int v6; // eax@12 bool result; // al@12 char v8; // [sp+4h] [bp-44h]@8 char v9; // [sp+Ch] [bp-3Ch]@12 char v10; // [sp+1Ch] [bp-2Ch]@7 char v11; // [sp+3Ch] [bp-Ch]@12
if (strlen ((const char *)a1) != 37 || *(_BYTE *)(a1 + 5) != 45 || *(_BYTE *)(a1 + 11) != 45 || *(_BYTE *)(a1 + 17) != 45 || *(_BYTE *)(a1 + 23) != 45 || *(_BYTE *)(a1 + 30) != 45) { result = 0; } else { v1 = 0; v2 = &v10; v3 = 0; do { v8 = *(_BYTE *)(v3 + a1); if (v8!= 45) { v4 = (char)sub_412EBD (v8); v1 += v4 << 5 * (3 - v5); if ( v5 == 3 ) { v2 += sprintf(v2, "%05lx", v1); v1 = 0; } } ++v3; } while ( v3 < 0x25 ); v6 = sub_40C48C(&v10, 32); sprintf(&v9, "%08x", v6); result = strcmp(&v9, &v11) == 0; } return result; } Теперь можно попытаться привести этот код к более адекватному.Первым делом, замечаем, что входной параметр имеет тип int, что не совсем правда. Обозначим его как "char *". Для этого становимся на имя функции и жмем там клавишу Y (Set item type). Исправляем тип и имя входного параметра (я обозвал его как key).Далее… Видим строчку:
if (strlen (key) != 37 || key[5] != 45 || key[11] != 45 || key[17] != 45 || key[23] != 45 || key[30] != 45) Т.к. наш входной параметр — строка, давайте в тех местах, где символы ключа сверяются с числами, исправим на сравнение с символами. Для этого на каждом из таких чисел нажмем R (Char). Уже лучше: if (strlen (key) != 37 || key[5] != '-' || key[11] != '-' || key[17] != '-' || key[23] != '-' || key[30] != '-') Теперь цикл: Цикл №1 v1 = 0; v2 = &v10; v3 = 0; do { v8 = key[v3]; if (v8!= 45) { v4 = (char)sub_412EBD (v8); v1 += v4 << 5 * (3 - v5); if ( v5 == 3 ) { v2 += sprintf(v2, "%05lx", v1); v1 = 0; } } ++v3; } while ( v3 < 0x25 ); Для наглядности дадим v3 имя i, т.к. похоже, что она используется как итератор. Переименовываем нажатием на имени клавиши N (Name).Замечаем, что в цикле происходит взятие каждого символа из ключа, и передача его в пока неизвестную нам функцию. Предлагаю выяснить, что это за функция. Двойным щелчком переходим в нее. Видим там вызов еще одной функции, переходим туда. И, вот оно — обработка одиночного символа! (Здесь есть куча работы для клавиши R, но я лишь покажу сразу результат обработки).Функция convert_char char __cdecl convert_char(char C) { char _C; // al@1
_C = C; if (C < '0' ) return -1; if ( C <= '9' ) return C - '0'; if ( C < 'A' ) return -1; if ( C <= 'Z' ) { if ( C != 'I' && C != 'L' && C != 'O' && C != 'U' ) { if ( C >= 'U') _C = C — 1; if (_C >= 'O') --_C; if (_C >= 'L') --_C; if (_C >= 'I') --_C; return _C — '7'; } return -1; } if (C < 'a' || C > 'z' || C == 'i' || C == 'l' || C == 'o' || C == 'u') return -1; if (C >= 'u') _C = C — 1; if (_C >= 'o') --_C; if (_C >= 'l') --_C; if (_C >= 'i') --_C; return _C — 'W'; } Прекрасно! Теперь возвращаемся назад клавишей Esc до основной функции. Замечаем, что IDA сама переопределила для нас тип результата возвращаемого функцией обработки символа. Именуем дальше, обозначаем типы, и получаем следующий код цикла: Цикл №2 sum = 0; x5buf = v10; i = 0; do { C = key[i]; if (C!= '-') { new_c = j_convert_char©; sum += new_c << 5 * (3 - itr); if ( itr == 3 ) { x5buf += sprintf(x5buf, "%05lx", sum); sum = 0; } } ++i; } while ( i < 0x25 ); Если вы заметили, то тут есть одна интересная бага декомпилера. Видим, что переменная, обозначенная у меня как itr, совершенно не инкрементируется. Чтобы выяснить, что на самом деле происходит, жмем ПКМ -> Copy to assembly, и смотрим, где же используется наша itr. Выясняем: она инкрементируется прямо в этом цикле (чего и стоило ожидать), а до цикла — обнуляется. Учтем это при написании кейгена.Теперь вторая часть функции проверки ключа… У нас осталась одна неисследованная функция, которая, кстати, очень похожа на функцию подсчета CRC32. Результат обработки (пусть и на скорую руку, но читаемый):
crc32 int __cdecl calc_crc32(char *my_key, int len) { int i; // ebp@1 unsigned int _xor; // ecx@1 char *_my_key; // edi@2 char C; // al@3 signed int mask; // edx@3 int B; // esi@3 bool bit; // al@4 int crc32; // eax@10 signed int _len; // edx@10
i = len; _xor = 0xFFFFFFFF; if (len) { _my_key = my_key; do { C = *_my_key; --i; ++_my_key; mask = 1; B = (unsigned __int8)C; do { bit = (_xor & 0×80000000) == 0×80000000; _xor *= 2; if (B & mask) bit = bit == 0; if (bit) _xor ^= 0×4C11DB7u; mask *= 2; } while ((_BYTE)mask); } while (i); } crc32 = _xor & 1; _len = 0×1F; do { _xor >>= 1; crc32 = _xor & 1×2 * crc32; --_len; } while (_len); return ~crc32; } Оставшийся кусок (преобразованный): crc32 = j_calc_crc32(my_key, 32); sprintf (crc32_,»%08x», crc32); result = strcmp (crc32_, &my_key[32]) == 0; Этап пять: написание кейгена Задача: определить, что именно происходило с ключом, чтобы написать обратную функцию. Писать я буду, вопреки здравому смыслу и выдаче HexRays, на Delphi, а Вы можете писать на том языке, который проще именно Вам.Путем отладки выясняем произошедшее:
Игре нужен ключ в 32 символа без дефисов (37 — с дефисами). Берется по четыре символа из ключа (не учитываются дефисы). Каждый из них пропускается через функцию convert_char и суммируется по формуле: sum += new_c << 5 * (3 — itr); Каждая такая сумма преобразовывается в lower-case хекс-строку (5 символов) и доклеивается до имеющейся (итого 40 символов); Берется CRC32 от первых 32-х символов получившейся строки и сравнивается с оставшимися восемью символами полученной в предыдущем пункте строки; Если строки не совпали — наш ключ неправильный. Обратное мышление:
Написать функцию, преобразующую 40-символьный хэш обратно в ключ; Сгенерировать 32-символьный хэш; Посчитать от него 8-символьный CRC32; Склеить строки, полученные на этапах (2) и (3); Передать в функцию (1) — получим искомый ключ. Мысли о преобразующей функции:
Т.к. входной хэш был получен из восьми 5-символьных хэш-кусков, будем обрабатывать его так же, по «пятеркам»; Каждая «пятерка» была получена из четырех символов ключа; Т.к. при каждом вычислении «пятерки», она сдвигалась на 5 бит влево, получается, что на каждый символ ключа приходится 5 бит; Внимательное рассмотрение кода функции convert_char приводит нас к такой мысли, что набор символов ключа ограничивается лишь символами набора »0123456789ABCDEFGHJKMNPQRSTVWXYZ»; Итого: 32 символа хэша генерятся из «пятерок». 32% 5 = 24 целых символа и 2 в остатке — т.е. два символа нам придется просто догенерить. Итоговый вариант функции, генерирующей хэш (Delphi) function GenHash (len: byte): string; var i, k: byte; sum: integer; begin Randomize; Result:= ''; sum:= 0; k:= 0;
for i:= 1 to len do begin sum:= sum + (Random (Length (alphabet)) shl ((3 — k) * 5)); Inc (k);
if k = 4 then begin Result:= Result + AnsiLowerCase (Int2Hex (sum, 5)); sum:= 0; k:= 0; end; end;
Result:= Result + 'a0'; // Решил не баловаться, а оставить два случайных хекс символа end; Далее считаем CRC32 от хэша: var key, hash, crc32: string;
begin hash:= GenHash (24); crc32:= Crc32b (hash); // нашел первый попавшийся модуль, реализующий данный хэш-алгоритм Код функции-преобразователя: Функция-преобразователь хэша в код function Hash2Code (const Hash: string): string; var s: string; five: integer; begin Result:= ''; s:= Hash;
while Length (s) > 0 do begin five:= Hex2Int (Copy (s, 1, 5)); Delete (s, 1, 5);
Result:= Result + alphabet[(five and $F8000 shr 15) + 1] + alphabet[(five and $07C00 shr 10) + 1] + alphabet[(five and $003E0 shr 05) + 1] + alphabet[(five and $0001F shr 00) + 1]; end; end; Ну и, наконец, результирующее получение лицензионного ключа:
key:= Hash2Code (hash + crc32); lic_code:= Format ('%s-%s-%s-%s-%s-%s', [Copy (key, 1, 5), Copy (key, 6, 5), Copy (key, 11, 5), Copy (key, 16, 5), Copy (key, 21, 6), Copy (key, 27, 6) ]); Проверяем, и… Ввод сгенеренного ключа активировал игру, пункт активации исчез! Итоги Главное при написании кейгена — уметь обратно думать!=) Т.е. уметь написать такой алгоритм, который будет обратным тому, который вы имеете. Это непростая задача, но, и она решаема в большинстве случаев! P.S. Возможно, статья получилась слишком сумбурной, не знаю. Главная идея, которую я хотел ей донести: кейген — не такая и сложная штука, если есть мозги, и желание вместе с усидчивостью.P.P. S. В следующей статье я опишу, как я писал кейген к другой игре — Unepic.