Обратная разработка коммерческой программы: кейген для Zuma Deluxe

:

Вступление

Здравствуйте, Хабралюди.
Судя по последним статьям в блоге Assembler, тема кейгенов становится здесь весьма популярной. Что ж, внесу и я свои пять копеек.
Наш сегодняшний подопытный — игра Zuma Deluxe, на которую я никак не мог отгуглить себе кейген (не подумайте, что я игроман: на всё данное исследование меня вдохновил тов. k_d с его самоиграйкой для Zuma). И сразу дисклеймер: данный взлом от начала и до конца проделан в образовательных целях и не имеет целью нести убытки компании PopCap Games.
Итак, гуглим дистрибутив Зумы, скачиваем его, приводим в боеготовность OllyDBG, и начинаем разбор.
Да, заранее оговорюсь — я с некоторых пор заделался линуксоидом, поэтому запускаться вся эта радость будет из-под WINE. Впрочем, забегая вперёд, отмечу, что для данной задачи в этом есть свои плюсы, такие как, например, простота редактирования записей и отслеживания изменений в WINE`овском реестре, в силу его хранения в обычном текстовом файле.

Часть 1: коварный Flash

В общем, запускаем игру, играем дольше положенного (или сразу же лезем в ветку реестра HKLM/Software/PopCap/Zuma и выставляем нули в ключах TimesExecuted и TimesPlayed) — и вуаля:

Отлично, выбираем «Buy Now», закрываем вылезшее окошко браузера с предложением купить такую-то игру за жалкие 16.99 евро, и жмём «Enter the Registration Key Manually».

Так-с, поле ввода. Уже от чего-то можно плясать. Пробуем ввести какую-нибудь абракадабру, ожидаемо получаем «Please enter a valid key», и идём разбираться, что к чему. Первое, что настораживает при вдумчивом осмотре — наличие в папке, прямо рядом с бинарём игры, двух файлов, намекающих на использование в программе технологии Flash: собственно, Flash.ocx и drm.swf… Видимо, с закрытием браузера можно было повременить. Ладно, открываем этот самый drm.swf — и что же мы видим:

Вся гламурненькая оболочка для регистрации ключа, как оказалось, выполнена в том самом .SWF-файле. Может быть, и сам код проверки находится там же? Давайте посмотрим. Берём любой декомпилятор ActionScript (я, например, использовал Flare) и вынимаем из drm.swf исходный код.
Смотрим, что же у нас там надекомпилировалось. Рано ли, поздно ли натыкаемся на вот такую интересную строчку:

    gFrameLabels[4] = 'RegFailed';

Ищем по «RegFailed» и выходим на вот этот блок кода:
    if (_root.RegCodeEdit.text.length >= 23 && _root.validate_regkey(_root.RegCodeEdit.text)) {
        _root.APError.text = '';
        gRegFailedHeader = gHeader_RegFail;
        gRegFailedMessage = gMessage_RegFail;
        gRegFailedRetryLocation = 'APScreen';
        fscommand('Register', _root.RegCodeEdit.text);
    }

Вот оно. Правильный ключ имеет в длину 23 символа (больше просто не позволяет ввести само текстовое поле) и заставляет validate_regkey() возвращать True. На то, что в этом же блоке кода происходит инициализация таких «страшных» значений как gRegFailedMessage, можно не обращать внимания, т.к. здесь, вне зависимости от них, из флэш-объекта посредством fscommand() происходит передача данных в родительский процесс.
Теперь пора заняться самой функцией validate_regkey(). Вот она целиком:
    function validate_regkey(string) {
      if (string.substr(5, 1) == '-' && string.substr(11, 1) == '-' && string.substr(17, 1) == '-') {
        char = new Array();
        k = 0;
        while (k <= string.length - 1) {
          char = string.slice(k, k + 1);
          if (char == '0' || char == '1' || char == '2' || char == '3' || char == '4' || char == '5' || char == '6' || char == '7' || char == '8' || char == '9' || char == 'A' || char == 'B' || char == 'C' || char == 'D' || char == 'E' || char == 'F' || char == 'G' || char == 'H' || char == 'I' || char == 'J' || char == 'K' || char == 'L' || char == 'M' || char == 'N' || char == 'O' || char == 'P' || char == 'Q' || char == 'R' || char == 'S' || char == 'T' || char == 'U' || char == 'V' || char == 'W' || char == 'X' || char == 'Y' || char == 'Z' || char == 'a' || char == 'b' || char == 'c' || char == 'd' || char == 'e' || char == 'f' || char == 'g' || char == 'h' || char == 'i' || char == 'j' || char == 'k' || char == 'l' || char == 'm' || char == 'n' || char == 'o' || char == 'p' || char == 'q' || char == 'r' || char == 's' || char == 't' || char == 'u' || char == 'v' || char == 'w' || char == 'x' || char == 'y' || char == 'z' || char == '-' || char == ' ') {
            if (k == string.length - 1) {
              result = 'Thank you for submitting !';
              return true;
            }
          } else {
            result = 'Unauthorized character ' + char;
            return false;
          }
          ++k;
        }
      } else {
        result = 'Error in delimiters';
        return false;
      }
    }

Что ж, центральная проверка — однозначный шедевр. Надо, наверное, запостить это на govnokod.ru, ну да ладно, мы сюда не позубоскалить собрались. Главное, что эта функция дала нам структуру лицензионного ключа:

#####-#####-#####-#####
где # — символ из алфавита «0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-   ».
Ровно 23 символа.

Опять забегая вперёд, скажу что набор верных символов внутри самой программы будет несколько сокращён. Но пока что нам без разницы. Запускаем OllyDBG и загружаем в него нашу подопытную. Запускаем на выполнение (F9) и дожидаемся момента прорисовки главного окна.
Куда копать дальше?
А помните найденный нами вызов fscommand() с первым параметром, равным строке «Register»?
Поэтому открываем Memory Map (Alt+M) и ищем вхождение этой строки (Ctrl+B). А вот и она, лежит себе по адресу 0x4417D0:

Ставим на неё точку останова по чтению (выделение → Shift+F3). Далее, в окне проверки, идём по ссылке внизу «Already purchased this game?» → «Enter the Registration Key Manually», и вводим в поле для ключа любую алфавитно-цифровую белиберду, разделённую дефисами на 4 блока по 5 символов. Жмём «Register».

Часть 2: we need to go deeper

Вот мы и попали, наконец, внутрь алгоритма проверки. Идём в список контрольных точек на память и удаляем 0x4417D0 (Alt+Y → Del), выполняемся (Ctrl+F9) до конца функции, т.к. там ничего интересного для нас нет, только цикл сравнения, далее возвращаемся в вызывавшую функцию (F8), и видим там проверку возвращённого результата. Опять пропускаем всё до конца функции и возвращаемся в вызывавшую:

Ага! А вот теперь мы в сердце анализатора лицензионного ключа. Чтобы не забыть это место, поставим (F2) контрольную точку на 0x04066CA и осмотримся вокруг. Чуть ниже ( 0x406757 и 0x4067A8) видны вызовы функций с очень интересными строковыми параметрами «RegSucceeded» и «RegFailed». А повыше ( 0x406748) находится ветвление, которое передаёт управление в нужную функцию. Это ветвление завязано на сравнение ( 0x40672D) регистров AL и BL. Похоже, что функция 0x404260, вызываемая ещё двумя командами ранее — это как раз то, что мы искали, т.е. Самая Важная Функция Проверки.

Сначала, проверим свою догадку: изменим сравнение так, чтобы оно оказывалось верным при некорректных исходных данных. Наведём выделение на 0x406748 и нажмём пробел. Откроется окно «Assemble». Заменим переход по равенству, JE, на переход по неравенству — JNE. Запустим на выполнение (F9)…

Ура, первый бастион взят!
Но мы сейчас пишем не просто кряк, а очень даже кейген. А это значит, что на достигнутом останавливаться рано, и придётся перезапустить программу (Ctrl+F2) и углубиться в недра функции 0x404260.

Сейчас наша задача — понять, как себя ведут внутри этой функции регистры AL и BL, и где именно расположен кусок кода, ответственный за их равенство либо неравенство.
Перейдём в «хвост» функции, поближе к точке выхода, RETN, и включим подсветку регистра BL (контекстное меню → Highlight register → EBX).

Как видите, содержимое регистра AL на протяжении практически всего «хвоста» хранится в BL, и только перед самым выходом копируется обратно, с дальнейшим восстановлением исходного значения EBX из стэка.
При том, само значение AL берётся из функции, на картинке вверху помеченной выделением.
Переходим внутрь этой функции — и что же мы видим:

У этой функции всего два возможных выходных значения — 0 и 1. Первое генерируется, когда строка байт, указатель на структуру с которой передан как параметр функции, не совпадает со строкой, чей указатель лежит по адресу [ECX + 8]. Второе же (то самое, которое нам нужно) — при обратной ситуации, т.е. когда строки идентичны.

Часть 3: MD5, RSA и все-все-все

Вернёмся в родительскую функцию и взглянем, откуда берутся значения из [ECX + 8] и [ARG.1 + 8].
Для этого поставим (выделение → Shift+F5) «аппаратные» точки контрольного останова на два адреса в стэке, в которые должны быть помещены эти строки. Для разных конфигураций машин и операционных систем данные адреса, скорее всего, будут различаться. Так, в моём случае это 0x33E624 и 0x33E660 (вообще, рекомендую завести себе сбоку дополнительное окно, показывающее состояние стэка, независимое от положения его вершины, ESP, как на картинке).
Аппаратными эти контрольные точки быть обязаны, т.к. другие типы контрольных точек, будучи поставленными на стэк, либо приведут к «обвалу» программы, либо не будут сохраняться между запусками. Пока что эти точки нужно сделать неактивными (контекстное меню → Breakpoint → Hardware disable).

Теперь перезапустим программу и остановимся на входе в нашу главную функцию проверки ( 0x404260). Поставим туда контрольную точку и начнём трассировать функцию построчно (F8), следя за состоянием двух наших аппаратных точек останова. Трассировка показывает, что до строки 0x404546 оба значения остаются неизменными. А вот дальше уже любопытнее.

Функция, непосредственно вызываемая из 0x404546, является «трамплином» для запуска функции 0x41E320, так что в ней нет ничего интересного. Ставим точку останова на 0x41E320 и жмём F9.
На данный момент, в стэке можно заметить строку, состоящую из странных, но тем не менее печатных символов (к примеру, у меня это A.....6..O6NBBO....E4GXF3O0..), символа перевода строки и постфикса ZUMA. Трассируем далее, и набредаем на 0x41E37F:

Так-так-так… да это же векторы инициализации для алгоритма MD5!
MD5. Так-то лучше. Теперь анализ остального кода функции проходит легко и беззаботно:

  • 0x41E3200x41E397: инициализация данных и выделение памяти под результат
  • 0x41E39B0x41E3C3: вычисление MD5-хэша от вышеупомянутой строки и подготовка структуры (далее по тексту я буду называть её «фреймом»), которая будет содержать в себе ссылку на результат
  • 0x41E3C50x41E408: цикл, перестраивающий байты в результате задом наперёд
  • 0x41E40A0x41E424: проверка, которая в силу фиксированности четвёртого параметра функции (0x5E) всегда оказывается истинной
  • 0x41E4260x41E474: код, который, в силу предыдущей проверки, никогда не исполняется
  • 0x41E4760x41E4B5: функции «обрезки» 128-битного MD5-хэша до 96 бит; выход из функции

Теперь давайте посмотрим на первую из наших аппаратных контрольных точек:

Такая структура из пяти значений, как я уже говорил, впоследствии будет называться «фрейм».

  • Первый DWORD никогда не изменяется, и всегда равен 0x44543C
  • Второй DWORD имеет неясное назначение (да в общем-то и не особо важен, как далее покажет практика)
  • Третий DWORD равен адресу в памяти, где хранится строка байт
  • Четвёртый DWORD задаёт её «эффективную» длину в WORD`ах (т.е. сколько WORD`ов из строки будет использоваться в дальнейших вычислениях)
  • Пятый DWORD задаёт её «суммарную» длину в WORD`ах (т.е. сколько WORD`ов было изначально выделено для размещения строки)

Так вот, после выполнения только что протрассированной нами процедуры, первый из фреймов уже заполнен. Т.е., у нас уже есть одна из строк, назовём её условно «эталонной», которая подвергнется сравнению с той, которую нам ещё только предстоит вычислить в 0x40454F0x40458C.

Теперь взглянем попристальнее на строку 0x404560, откуда вызывается функция 0x41E5A0.
Функция очень длинная и очень страшная, однако мы для того и здесь, чтобы сделать длинное и страшное простым и понятным. Эта функция обрабатывает введённую нами строку регистрационного ключа, и пересчитывает её в число.

  • 0x41E5A00x41E608: инициализация, выделение памяти, установка обработчика исключений
  • 0x41E60B0x41E6EF: цикл, преобразующий буквы строки в верхний регистр, и заменяющий символы «1» на «L», а «O» и «0» — на «Q»
  • 0x41E6F50x41E70A: подготовка ко второму циклу
  • 0x41E7100x41E7E4: второй цикл, строящий из строки число по принципу преобразования его из 28-ичной системы счисления (первый символ — младший разряд, последний — старший), с набором знаков «234679ACDEFGHJKLMNPQRTUVWXYZ», игнорируя дефисы, и выдающий ошибку при отсутствии текущего символа в наборе
  • 0x41E7EA0x41E844: копирование результата, освобождение временных буферов, выход
  • 0x41E8450x41E874: «хвост», выполняющийся, если второй цикл выдал ошибку

Отлично, строка пересчитана в число, и теперь мы что-то с нею делаем.

До заветного момента, когда можно сказать, что реверс произведён, осталась одна функция, 0x41E100 (вызов из 0x40458C).

Ну тут уж я не буду вас мучать прочтением и расшифровкой ассемблерных листингов, поскольку один хороший товарищ, к тому моменту как я только начал её разбирать, подкинул мне очень вовремя утёкший в Сеть кусок исходника PopCap`овского фреймворка, содержащий в себе пусть не реализацию указанной функции, но хотя бы её название. В общем, барабанная дробь… та функция, на которую мы сейчас собираемся пойти в лобовую атаку, называется aSignature.ModPow(e, n).
Интересующиеся могут проследовать к строке 00069 и обнаружить поразительное сходство функции bool SexyApp::Validate() (SexyApp — во назвали-то; я хренею без баяна, милостивые господа) с нашей, ставшей уже такой родной, 0x404260.

Также, рекомендую обратить внимание на сами e и n:

    BigInt n("42BF94023BBA6D040C8B81D9");
    BigInt e("11");

или, в ассемблерном представлении,

Как можно догадаться из названия, ModPow() означает возведение в степень по модулю.
А комментарий на строке 00478

    // Public RSA stuff
    BigInt n("D99BC76AB7B2578738E606F7");
    BigInt e("11");

    BigInt aHash = HashData(aFileData, aFileDataPos, 94);
    delete aFileData;

    BigInt aSignature(aSigStr);
    BigInt aHashTest = aSignature.ModPow(e, n);

окончательно проясняет ситуацию: алгоритм, с которым мы имеем дело — это RSA.

Часть 4: генератор ключей

Что ж, у нас есть практически все исходные данные, чтобы начать писать кейген. Единственное, что осталось сделать — это факторизовать публичный модуль 0x42BF94023BBA6D040C8B81D9 и вычислить приватную экспоненту. Ну что ж, берём в руки MSieve + TMG RSA Tool, и получаем на выходе 0x03AE5465C52D0C4C0A8FE303D.
Всё, осталось написать (или спереть готовую, хе-хе) реализацию длинной арифметики. Сам же алгоритм для генерации ключа у нас есть:
  1. посчитать MD5 от строки ИМЯПОЛЬЗОВАТЕЛЯ,0Ah,ZUMA
  2. выкинуть последний DWORD, и записать оставшееся в обратном порядке байтов
  3. по-WORD-ово пройтись по результату, и применить (чит.: скопипастить в кейген) сдвиг; см. 0x41D280
  4. снова поменять порядок байтов
  5. вычислить от полученного функцию ModPow(D, N), где D = 0x3AE5465C52D0C4C0A8FE303D, N = 0x42BF94023BBA6D040C8B81D9, E = 0x11
  6. последовательным делением на 28, подставляя остатки по таблице «234679ACDEFGHJKLMNPQRTUVWXYZ», раскрыть из вычисленного лицензионный ключ

Здесь я намеренно опустил несколько часов изысканий по поводу того, чем является та самая заветная строка «A.....6..O6NBBO....E4GXF3O0..», которую я по мере разбора принял как данность, а в вышеизложенном алгоритме обозначил как ИМЯПОЛЬЗОВАТЕЛЯ. Оказывается, она генерируется на основе железа компьютера, в частности количество сетевых адаптеров на компьютере отвечает за её длину.
Сам код этой генерации, на мой взгляд, писали какие-то параноики-наркоманы. Вот, например, реальная ситуация: в моём компьютере в каждый момент времени может находиться три или четыре сетевых адаптера (возвратник lo, Ethernet-интерфейс eth0, WiFi-интерфейс wlan0, а также мобильник, подключаемый через USB-порт и играющий роль GPRS-модема, ppp0). Как только я подключаю мобильник, их становится 4. Как отключаю — 3. Этим двум состояниям, по мнению генератора, соответствуют разные строки. Следовательно, в одном из них регистрация Zuma, купленная за, простите, €16.99, просто слетит.

В общем, исходя из вышесказанного, код, генерирующий эту пакость, я решил в кейген не копипастить, а банально красть уже готовую строку из памяти игры посредством ReadProcessMemory(). В качестве небольшого хулиганства, также я добавил возможность записывать в строку имени что-то своё (с помощью WriteProcessMemory(), как несложно догадаться). Но, к сожалению, работает (т.е. сохраняет валидность регистрации) такой трюк только на WINE, но не на «реальной» Windows.

В остальном — прошу любить и жаловать: Zuma keygen, proof-of-concept.
Язык написания — ассемблер. Алгоритм MD5 скопипащен из бинаря игры, и слегка доработан напильником. 96-битная арифметика — аутентична =)

Кейген проверялся не только на той версии Zuma, про которую идёт речь в статье, но также ещё и на других, более ранних или поздних (я не разбирался). Несмотря на то, что адреса с именем пользователя разнились, сам лицензионный ключ подходил к ним всем, что свидетельствует о неизменности алгоритма аж с 2003 года.

Послесловие и использованные ссылки

Эта статья была бы невозможна без помощи нескольких грамотных ребят с WASM.RU Ѳорум, наставивших меня на путь истинный вот в этой теме.
Также мне сильно помогла утилита RSA Tool от хакерской команды TMG, и онлайновый RSA-калькулятор http://nmichaels.org/rsa.py.
Статьи в Википедии по RSA и MD5 также много мне дали для понимания сути происходящего в недрах игры.
Если кто-то заинтересовался — до кучи выкладываю .UDD-файл для OllyDBG со всеми комментариями и контрольными точками.

P.S. Если кто-то может посоветовать более надёжный файлообменник, с которого файлы не удаляются через 30 дней простоя — буду крайне признателен.

P.P.S. Каково же было моё удивление, когда уже после взлома я обнаружил, что Zuma.exe из обсуждаемого пакета — это просто обёртка, архив, который по лицензионному ключу разархивирует в свою же папку реальный бинарь с Zuma, названный popcapgame1.exe

[UPDATE:] перенёс картинки на Habrastorage.