C++
C
Reverse engineering
9 April

Ломаем простую «крякми» при помощи Ghidra — Часть 1

From Sandbox
О том, что это за зверь такой — Ghidra («Гидра») — и с чем его едят она ест программки, многие уже, наверняка, знают не понаслышке, хотя в открытый доступ сей инструмент попал совсем недавно — в марте этого года. Не буду докучать читателям описанием Гидры, ее функциональности и т.д. Те, кто в теме, уже, уверен, всё это сами изучили, а кто еще не в теме — могут это сделать в любое время, благо на просторах веба сейчас найти подробную информацию не составит труда. Кстати, один из аспектов Гидры (разработку плагинов к ней) уже освещался на Хабре (отличная статья!) Я же дам только основные ссылки:


Итак, Гидра — это бесплатный кроссплатформенный интерактивный дизассемблер и декомпилятор с модульной структурой, с поддержкой почти всех основных архитектур ЦПУ и гибким графическим интерфейсом для работы с дизассемблированным кодом, памятью, восстановленным (декомпилированным) кодом, отладочными символами и многое-многое другое.

Давайте попробуем уже что-нибудь сломать этой Гидрой!

Шаг 1. Находим и изучаем крякми


В качестве «жертвы» найдем простую «крякми» (crackme) программку. Я просто зашел на сайт crackmes.one, указал в поиске уровень сложности = 2-3 («простой» и «средний»), исходный язык программы = «C/C++» и платформу = «Multiplatform», как на скриншоте ниже:



Поиск выдал 2 результата (внизу зеленым шрифтом). Первая крякми оказалась 16-битной и не запустилась на моей Win10 64-bit, а вот вторая (level_2 by seveb) подошла. Вы можете скачать ее по этой ссылке.

Скачиваем и распаковываем крякми; пароль на архив, как указано на сайте, — crackmes.de. В архиве находим два каталога, соответствующие ОС Linux и Windows. На своей машине я перехожу в каталог Windows и встречаю в нем единственную «экзешку» — level_2.exe. Давайте запустим и посмотрим, чего она хочет:



Похоже, облом! При запуске программа ничего не выводит. Пробуем запустить еще раз, передав ей произвольную строку в качестве параметра (вдруг, она ждет ключ?) — и вновь ничего… Но не стоит отчаиваться. Давайте предположим, что и параметры запуска нам тоже предстоит выяснить в качестве задания! Пора расчехлять наш «швейцарский нож» — Гидру.

Шаг 2. Со��дание проекта в Гидре и предварительный анализ


Предположим, что Гидра у тебя уже установлена. Если еще нет, то все просто.

Установка Ghidra
1) установи JDK версии 11 или выше (у меня 12)

2) скачай Гидру (например, отсюда) и установи ее (на момент написания статьи последняя версия Гидры — 9.0.2, у меня стоит 9.0.1)

Запускаем Гидру и в открывшемся Менеджере проектов сразу создаем новый проект; я дал ему имя crackme3 (т.е.проекты crackme и crackme2 уже у меня созданы). Проект — это, по сути, каталог файлов, в него можно добавлять любые файлы для изучения (exe, dll и т.д.). Мы сразу же добавим наш level_2.exe (File | Import или просто клавиша I):



Видим, что уже до импорта Гидра определила нашу подопытную крякми как 32-разрядный PE (portable executable) для ОС Win32 и платформы x86. После импорта наш ждет еще больше информации:



Здесь, кроме вышеуказанной разрядности, нас может еще заинтересовать порядок байтов (endianness), который в нашем случае — Little (от младшего к старшему байту), что и следовало ожидать для «интеловской» 86-й платформы.

С предварительным анализом мы закончили.

Шаг 3. Выполнение автоматического анализа


Время запустить полный автоматический анализ программы в Гидре. Это делается двойным кликом на соответствующем файле (level_2.exe). Имея модульную структуру, Гидра обеспечивает всю свою основную функциональность при помощи системы плагинов, которые можно добавлять / отключать или самостоятельно разрабатывать. Так же и с анализом — каждый плагин отвечает за свой вид анализа. Поэтому сначала перед нами открывается вот такое окошко, в котором можно выбрать интересующие виды анализа:

Окно настройки анализа

Для наших целей имеет смысл оставить настройки по умолчанию и запустить анализ. Сам анализ выполняется довольно быстро (у меня занял около 7 секунд), хотя пользователи на форумах сетуют на то, что для больших проектов Гидра проигрывает в скорости IDA Pro. Возможно, это и так, но для небольших файлов эта разница несущественна.

Итак, анализ завершен. Его результаты отображены в окне браузера кода (Code Browser):



Это окно является основным для работы в Гидре, поэтому следует изучить его более внимательно.

Обзор интерфейса браузера кода
Настройки интерфейса по умолчанию разбивают окно на три части.

В центральной части располагается основное окно — листинг дизассемблера, который более или менее похож на своих «собратьев» в IDA, OllyDbg и т.д. По умолчанию столбцы в этом листинге таковы (слева направо): адрес памяти, опкод команды, ASM команда, параметры ASM команды, перекрестная ссылка (если применимо). Естественно, отображение можно изменить, нажав на кнопку в виде кирпичной стены в тулбаре этого окна. Если честно, подобной гибкой настройки вывода дизассемблера я нигде не видел, это чрезвычайно удобно.

В левой части 3 панели:

  1. Секции программы (для перехода по секциям кликаем мышью)
  2. Дерево символов (импорты, экспорты, функции, заголовки и т.д.)
  3. Дерево типов используемых переменных

Для нас самое полезное здесь окно — это дерево символов, которое позволяет быстро найти, например, функцию по ее имени и перейти на соответствующий адрес.

В правой части — листинг декомпилированного кода (в нашем случае на языке C).

Кроме окон по умолчанию, в меню Window можно выбрать и расположить в любом месте браузера еще с десяток других окон и отображений. Для удобства я добавил окно просмотра памяти (Bytes) и окно с графом функций (Function Graph) в центральную часть, а в правую часть — строковые переменные (Strings) и таблицу функций (Functions). Эти окна теперь доступны в отдельных вкладках. Также любые окна можно открепить и сделать «плавающими», размещая и изменяя их размер по своего усмотрению — это так��е очень продуманное, на мой взгляд, решение.

Шаг 4. Изучение алгоритма программы — функция main()


Что ж, приступим к непосредственному анализу нашей крякми-программки. Начинать следует в большинстве случаев с поиска точки входа программы, т.е. основной функции, которая вызывается при ее запуске. Зная, что наша крякми написана на C/C++, догадываемся, что имя основной функции будет main() или что-то в этом духе :) Сказано-сделано. Вводим «main» в фильтр Дерева символов (в левой панели) и видим функцию _main() в секции Functions. Переходим на нее кликом мыши.

Обзор функции main() и переименование непонятных функций


В листинге дизассемблера сразу же отображается соответствующий участок кода, а справа видим декомпилированный C-код этой функции. Здесь стоит отметить еще одну удобную фишку Гидры — синхронизацию выделения: при выделении мышью диапазона ASM-команд выделяется и соответствующий участок кода в декомпиляторе и наоборот. Кроме того, если открыто окно просмотра памяти, выделение синхронизируется и с памятью. Как говорится, все гениальное просто!

Сразу отмечу важную особенность работы в Гидре (в отличие, скажем, от работы в IDA). Работа в Гидре ориентирована, в первую очередь, именно на анализ декомпилированного кода. По этой причине создатели Гидры (мы помним — речь о шпионах из АНБ :)) уделили большое внимание качеству декомпиляции и удобству работы с кодом. В частности, перейти к определению функций, переменных и секций памяти можно просто двойным кликом в коде. Также любую переменную и функцию можно тут же переименовать, что весьма удобно, так как дефолтные имена не несут в себе смысла и могут сбить с толку. Как ты увидишь далее, этим механизмом мы будем часто пользоваться.

Итак, перед нами функция main(), которую Гидра «препарировала» следующим образом:

Листинг main()
int __cdecl _main(int _Argc,char **_Argv,char **_Env)

{
  bool bVar1;
  int iVar2;
  char *_Dest;
  size_t sVar3;
  FILE *_File;
  char **ppcVar4;
  int local_18;
  
  ___main();
  if (_Argc == 3) {
    bVar1 = false;
    _Dest = (char *)_text(0x100,1);
    local_18 = 0;
    while (local_18 < 3) {
      if (bVar1) {
        _text(_Dest,0,0x100);
        _text(_Dest,_Argv[local_18],0x100);
        break;
      }
      sVar3 = _text(_Argv[local_18]);
      if (((sVar3 == 2) && (((int)*_Argv[local_18] & 0x7fffffffU) == 0x2d)) &&
         (((int)_Argv[local_18][1] & 0x7fffffffU) == 0x66)) {
        bVar1 = true;
      }
      local_18 = local_18 + 1;
    }
    if ((bVar1) && (*_Dest != 0)) {
      _File = _text(_Dest,"rb");
      if (_File == (FILE *)0x0) {
        _text("Failed to open file");
        return 1;
      }
      ppcVar4 = _construct_key(_File);
      if (ppcVar4 == (char **)0x0) {
        _text("Nope.");
        _free_key((void **)0x0);
      }
      else {
        _text("%s%s%s%s\n",*ppcVar4 + 0x10d,*ppcVar4 + 0x219,*ppcVar4 + 0x325,*ppcVar4 + 0x431);
        _free_key(ppcVar4);
      }
      _text(_File);
    }
    _text(_Dest);
    iVar2 = 0;
  }
  else {
    iVar2 = 1;
  }
  return iVar2;
}


Вроде бы с виду все нормально — определения переменных, стандартные C-шные типы, условия, циклы, вызовы функций. Но взглянув на код внимательнее, замечаем, что имена некоторых функций почему-то не определились и заменены псевдофункцией _text() (в окне декомпилятора — .text()). Давайте сразу начнем определения, что это за функции.

Перейдя двойным кликом в тело первого вызова

 _Dest = (char *)_text(0x100,1);

видим, что это — всего лишь функция-обертка вокруг стандартной функции calloc(), служащей для выделения памяти под данные. Поэтому давайте просто переименуем эту функцию в calloc2(). Установив курсор на заголовке функции, вызываем контекстное меню и выбираем Rename function (горячая клавиша — L) и вводим в открывшееся поле новое название:



Видим, что функция тут же переименовалась. Возвращаемся назад в тело main() (кнопка Back в тулбаре или Alt + <--) и видим, что здесь вместо загадочного _text() уже стоит calloc2(). Отлично!

То же самое проделываем и со всеми остальными функциями-обертками: поочередно переходим в их определение, смотрим, что они делают, переименовываем (я к стандартным названиям C-функций добавлял индекс 2) и возвращаемся назад в основную функцию.

Постигаем код функции main()


Ладно, с непонятными функциями разобрались. Начинаем изучать код основной функции. Пропуская объявления переменных, видим, что функция возвращает значение переменной iVar2, которое равно нулю (признак успеха функции) только в случае если выполняется условие, заданное строкой

if (_Argc == 3) { ... }

_Argc — это количество параметров (аргументов) командной строки, передаваемых в main(). То есть, наша программа «кушает» 2 аргумента (первый аргумент, мы помним, — это всегда путь к исполняемому файлу).

ОК, идем дальше. Вот здесь мы создаем C-строку (массив char) из 256 символов:

char *_Dest;

_Dest = (char *)calloc2(0x100,1); // эквивалент new char[256] в C++

Дальше у нас цикл из 3 итераций. В нем сначала проверяем, установлен ли флаг bVar1 и если да — копируем следующий аргумент командной строки (строку) в _Dest:

while (i < 3) {
				/* цикл по аргументам ком. строки */
  if (bVar1) {
				/* инициализировать массив */
	memset2(_Dest,0,0x100);
				/* скопировать строку в _Dest и прервать цикл */
	strncpy2(_Dest,_Argv[i],0x100);
	break;
  }
...
}

Этот флаг устанавливается при анализе следующего аргумента:

n_strlen = strlen2(_Argv[i]);
if (((n_strlen == 2) && (((int)*_Argv[i] & 0x7fffffffU) == 0x2d)) && 
  (((int)_Argv[i][1] & 0x7fffffffU) == 0x66)) {
      bVar1 = true;
}

Первая строка вычисляет длину этого аргумента. Далее условие проверяет, что длина аргумента должна равняться 2, предпоследний символ == "-" и последний символ == «f». Обрати внимание, как декомпилятор «перевел» извлечение символов из строки при помощи байтовой маски.
Десятичные значения чисел, а заодно и соответствующие ASCII-символы можно подсмотреть, удерживая курсор над соответствующим шестнадцатеричным литералом. Отображение ASCII не всегда работает (?), поэтому рекомендую глядеть ASCII таблицу в Интернете. Также можно прямо в Гидре конвертировать скаляры из любой системы счисления в любую другую (через контекстное меню --> Convert), в этом случае данное число везде будет отображаться в выбранной системе счисления (в дизассемблере и в декомпиляторе); но лично я предпочитаю в коде оставлять hex'ы для стройности работы, т.к. адреса памяти, смещения и т.д. везде задаются именно hex'ами.
После цикла идет этот код:

if ((bVar1) && (*_Dest != 0)) {
                    /* если получили аргументы 1) "-f" и 2) строку -
                       открыть указанный файл для чтения в двоичном формате */
      _File = fopen2(_Dest,"rb");
      if (_File == (FILE *)0x0) {
                    /* вернуть 1 при ошибке чтения */
        perror2("Failed to open file");
        return 1;
      }
 ...
}

Здесь я сразу добавил комментарии. Проверяем правильность аргументов ("-f путь_к_файлу") и открываем соответствующий файл (2-й переданный аргумент, который мы скопировали в _Dest). Файл будет читаться в двоичном формате, на что указывает параметр «rb» функции fopen(). При ошибке чтения (например, файл недоступен) выводится сообщение об ошибке в поток stderror и программа завершается с кодом 1.

Далее — самое интересное:

			/* !!! ПРОВЕРКА КЛЮЧА В ФАЙЛЕ !!! */
ppcVar3 = _construct_key(_File);
if (ppcVar3 == (char **)0x0) {
			/* если получили пустой массив, вывести "Nope" */
puts2("Nope.");
_free_key((void **)0x0);
}
else {
			/* массив не пуст - вывести ключ и освободить память */
printf2("%s%s%s%s\n",*ppcVar3 + 0x10d,*ppcVar3 + 0x219,*ppcVar3 + 0x325,*ppcVar3 + 0x431);
_free_key(ppcVar3);
}
fclose2(_File);

Дескриптор открытого файла (_File) передается в функцию _construct_key(), которая, очевидно, и производит проверку искомого ключа. Эта функция возвращает двумерный массив байтов (char**), который сохраняется в переменную ppcVar3. Если массив оказывается пуст, в консоль выводится лаконичное «Nope» (т.е. по-нашему «Не-а!») и память освобождается. В противном случае (если массив не пуст) — выводится по-видимому верный ключ и память также освобождается. В конце функции закрывается дескриптор файла, освобождается память и возвращается значение iVar2.

Итак, теперь мы поняли, что нам необходимо:

1) создать двоичный файл с верным ключом;
2) передать его путь в крякми после аргумента "-f"


Во второй части статьи мы будем анализировать функцию _construct_key(), которая, как мы выяснили, отвечает за проверку искомого ключа в файле.

+56
25.6k 238
Comments 30