Введение в реверс-инжиниринг#
Рассказ о том как, зачем и почему?#
Приложения всегда имеют интересные "фичи", которые на саомм деле явлюятся дырами. Вспомним закон дырявых абстаркций из прошлого семестра.
Tip
Согласно этому закону, любая нетривиальная абстракция в некоторой степени дырявая, то есть может ломаться, иногда немного, иногда значительно.
С одной интересной дыркой познакомился пользователь с хабра. Внезапно, открыв приложение «Ситимобил» он увидел, что один интересный запрос выполняется без какой-либо аутентификации.
Это был запрос на получение информации о ближайших машинах. Выполнив этот запрос несколько раз с разными параметрами он понял, что можно выгружать данные о таксистах практически в реалтайме. Вы только представьте, сколько интересного можно теперь узнать!
Как добросовестный гражданин и программист, автор статьи бросил репорт на горячую линию поддержки, однако получил незамедлительный ответ, что данные не относятся пользователю и следовательно могут быть публичны.
Bug
Репорт был отправлен через платформу bug bounty (hackerone).
Ответ: данные не считаются чувствительными, поэтому защиты не требуют.
Он стал исследовать такие данные и получил следующие карты:
Данная уязвимость, которая таковой не посчиталась компанией, позволялала даже построить траекторию движения водителя по улицам Москвы:
К чему эта история? Я не специалист в области ИБ, поэтому заострю внимание лишь на проблематике: анализируя поведения ПО, можно обнаружить крайне нелогичные вещи или же "поспешные", но так хорошо работающие решения. Поэтому в ходе работы мы бьемся об две проблемы: как читать ассемблер и как понять логику того мощного разума, что руководил золотыми руками, вбивающими код. Теперь после такого интересного предисловия можно перейти к делу.
Тестирование иреверс-инжинирнг#
Современное тестирование программного обеспечения опирается на три ключевых подхода: тестирование как "чёрный ящик", "белый ящик" и "серый ящик". Каждый из них определяет, насколько глубоко тестировщик или аналитик должен понимать внутреннее устройство программы.
- Чёрный ящик — тестировщик не имеет доступа к исходному коду, и тестирование строится только на основе внешнего поведения программы.
- Белый ящик — тестирование проводится с полным знанием исходного кода и внутренней логики работы системы.
- Серый ящик — промежуточный подход: частичное понимание внутренней структуры дополняет внешний анализ.
Однако в реальной практике не всегда возможен доступ к исходному коду, документации или даже полной информации о входных и выходных данных. Это создаёт серьёзные проблемы как для тестирования, так и для анализа безопасности, анализа совместимости, миграции ПО и других задач. В таких условиях возникает необходимость использовать реверс-инжиниринг — процесс исследования программного продукта с целью восстановления его архитектуры, логики работы или исходных компонентов.
Реверс-инжиниринг применяется, когда:
- требуется понять, как работает устаревшее программное обеспечение, для которого утрачена документация;
- необходимо выявить уязвимости в бинарном коде;
- производится анализ поведения вредоносных программ;
- осуществляется проверка совместимости нового программного обеспечения со старыми компонентами;
- ведётся исследование патентов и лицензионных соглашений на предмет нарушения интеллектуальной собственности.
Таким образом, реверс-инжиниринг служит не только техническим инструментом, но и важным звеном в обеспечении надёжности, безопасности и поддерживаемости программных систем.
Какие знания нам понадобятся?#
Многим из нас хочется понять, как устроены программы не только снаружи, но и на самом низком уровне — как исполняемые файлы формируются из двоичных данных и возможно ли изменить поведение программы, даже не имея доступа к её исходному коду.
Но на этом пути почти сразу появляется серьёзное препятствие — ассемблер. Именно он отпугивает большинство, кто только начинает интересоваться этой областью.
Поэтому цель этого краткого введения — помочь сделать первые шаги. Мы не будем углубляться в теорию, а сосредоточимся на том, с чем чаще всего сталкиваются новички на практике. Предполагается, что вы умеете находить дополнительную информацию и готовы к экспериментам.
Главное здесь — не выучить всё сразу, а увидеть направление и понять, как можно начать. Возможно, реверс-инжиниринг окажется не таким сложным и далёким, как кажется на первый взгляд.
Примечание
: предполагается, что читатель обладает элементарными знаниями о шестнадцатеричной системе счисления
, а также о языке программирования С
. В качестве примера используется 32-разрядный исполняемый файл Windows — результаты могут отличаться на других ОС/архитектурах.
Компиляция#
Когда мы пишем программу на компилируемом языке, например на C или C++, результатом работы становится двоичный исполняемый файл — например, .exe
.
Этот процесс выполняет компилятор — специальная программа, которая сначала проверяет синтаксис исходного кода, а затем преобразует его в машинный код, понятный процессору.
Двоичный код#
Как мы уже говорили, результирующий выходной файл содержит двоичный код, понятный для CPU. По сути, это последовательность инструкций различной длины, которые должны выполняться по порядку — вот так выглядят некоторые из них:
Преимущественно, это арифметические инструкции. Они манипулируют регистрами/флагами CPU, а также энергозависимой памятью по мере выполнения.
Регистр и память в работе процессора#
Регистры#
Регистры процессора можно представить как быстрые переменные, доступные для чтения и записи. Их немного и каждый из них выполняет важную роль: хранит данные, промежуточные результаты, адреса или счётчики во время выполнения инструкций.
Одним из особенных регистров является FLAGS (в 32-битной архитектуре — EFLAGS
). Он содержит флаги — логические индикаторы, отражающие состояние процессора. Например:
- ZF
— флаг нуля (устанавливается, если результат равен нулю),
- OF
— флаг переполнения,
- PF
— флаг чётности,
- SF
— флаг знака (определяет, положительный или отрицательный результат).
В отладчиках, таких как x64dbg, можно увидеть регистры вроде EAX
, ESP
(указатель стека), EBP
(базовый указатель) и другие.
Работа с памятью: стек и куча#
Когда процессор выполняет код, ему необходим доступ к памяти. Здесь задействуются две ключевые области: стек и куча.
Стек (Stack)#
Это упорядоченная, компактная структура по принципу «последний пришёл — первый ушёл» (LIFO). Используется для хранения:
- локальных переменных,
- аргументов функций,
- адресов возврата (для отслеживания вызовов — именно отсюда берётся термин трассировка стека).
Операции со стеком выполняются быстро благодаря строгому порядку и предсказуемому управлению.
Куча (Heap)#
Куча, в отличие от стека, менее структурирована. Она используется для динамического выделения памяти, особенно когда:
- заранее неизвестен точный размер данных,
- требуется большой объём,
- необходима гибкость в изменении размера данных в процессе выполнения.
Именно здесь размещаются объекты и сложные структуры, создаваемые «на лету».
Основные инструкции x86 Assembly#
Как я упоминал ранее, ассемблерные инструкции имеют разный «размер в байтах» и различное количество операндов.
Операндами могут быть либо непосредственные значения (значение указывается прямо в команде), либо регистры, в зависимости от инструкции:
55 push ebp ; size: 1 byte, argument: register
6A 01 push 1 ; size: 2 bytes, argument: immediate
Давайте быстро пробежимся по очень небольшому набору некоторых из наиболее употребляемых команд — не стесняйтесь самостоятельно изучать для получения более подробной информации:
Стековые операции#
push value
— помещает значение в стек (ESP уменьшается на 4).pop register
— извлекает значение из стека в регистр (ESP увеличивается на 4).
Передача данных#
mov destination, source
— копирует значение из регистра или памяти в регистр.mov destination, [expression]
— копирует значение из памяти по адресу, заданному выражением, в регистр.
Управление потоком выполнения#
jmp destination
— безусловный переход, изменяет указатель инструкций (EIP).jz destination
/je destination
— переход, если установлен нулевой флаг (ZF).jnz destination
/jne destination
— переход, если нулевой флаг не установлен.
Арифметические операции и сравнение#
cmp operand1, operand2
— сравнивает операнды, устанавливает ZF, если равны.add operand1, operand2
— складывает operand2 с operand1, результат в operand1.sub operand1, operand2
— вычитает operand2 из operand1, результат в operand1.
Вызовы и возвраты функций#
call function
— вызывает функцию, помещая адрес возврата в стек.retn
— возвращается из функции, извлекая адрес возврата из стека.
Примечание: В терминологии x86 инструкции сравнения (
cmp
) выполняют вычитание и устанавливают флаг ZF, если операнды равны, поэтому "равно" и "ноль" часто взаимозаменяемы.
Шаблоны в ассемблере#
Теперь, когда у нас есть приблизительное представление об основных элементах, используемых во время выполнения программы, давайте познакомимся с шаблонами инструкций, с которыми можно столкнуться при реверс-инжиниринге обычного 32-битного двоичного файла PE.
Пролог функции#
Пролог функции — это некоторый код, внедренный в начало большинства функций и служащий для установки нового стекового кадра указанной функции.
Обычно он выглядит так (X — число):
55 push ebp ; preserve caller function's base pointer in stack
8B EC mov ebp, esp ; caller function's stack pointer becomes base pointer (new stack frame)
83 EC XX sub esp, X ; adjust the stack pointer by X bytes to reserve space for local variables
Эпилог функции#
Эпилог — это просто противоположность пролога. Он отменяет его шаги для восстановления стека вызывающей функции, прежде чем вернуться к ней:
8B E5 mov esp, ebp ; restore caller function's stack pointer (current base pointer)
5D pop ebp ; restore base pointer from the stack
C3 retn ; return to caller function
Теперь может возникнуть вопрос — как функции взаимодействуют друг с другом? Как именно вы отправляете/обращаетесь к аргументам при вызове функций, и как вы получаете возвращаемое значение? Именно для этого существуют соглашения о вызовах.
Соглашение о вызове#
Соглашение о вызове — это протокол для взаимодействия функций. Существуют разные варианты, но все они используют общий принцип.
Рассмотрим соглашение __cdecl (от C declaration), которое является стандартным при компиляции кода на языке C.
- В 32-битной архитектуре аргументы функции передаются через стек (помещаются в обратном порядке).
- Возвращаемое значение передаётся через регистр EAX (если это не число с плавающей точкой).
Это означает, что при вызове функции func(1, 2, 3) будет сгенерировано следующее:
6A 03 push 3
6A 02 push 2
6A 01 push 1
E8 XX XX XX XX call func
Собираем все вместе#
Предположим, что func()
просто складывает аргументы и возвращает результат. Вероятно, это будет выглядеть так:
int __cdecl func(int, int, int):
prologue:
55 push ebp ; save base pointer
8B EC mov ebp, esp ; new stack frame
body:
8B 45 08 mov eax, [ebp+8] ; load first argument to EAX (return value)
03 45 0C add eax, [ebp+0Ch] ; add 2nd argument
03 45 10 add eax, [ebp+10h] ; add 3rd argument
epilogue:
5D pop ebp ; restore base pointer
C3 retn ; return to caller
Теперь, если вы внимательно следили за объяснением, но всё ещё в замешательстве, можете задать себе один из двух вопросов:
Почему мы должны сместить EBP на 8, чтобы получить первый аргумент?
Если проверить определение инструкции call, то станет понятно, что она внутренне помещает значение EIP в стек. Команда push уменьшает значение ESP (которое скопировано в EBP в прологе) на 4 байта. Кроме того, первая инструкция пролога — это тоже push, поэтому получается два уменьшения по 4 байта, то есть 8 байт смещения.
Что случилось с прологом и эпилогом, почему они кажутся «усечёнными»?
Это связано с тем, что во время выполнения функции стек не использовался — ESP не изменялся, значит, нет необходимости восстанавливать стек.
Условный оператор#
Чтобы продемонстрировать ассемблерные инструкции управления потоком выполнения, я бы хотел добавить еще один пример, иллюстрирующий, во что скомпилируется оператор if в ассемблере.
Предположим, у нас есть следующая функция:
void print_equal(int a, int b) {
if (a == b) {
printf("equal");
}
else {
printf("nah");
}
}
После ее компиляции вот дизассемблированный вид:
void __cdecl print_equal(int, int):
10000000 55 push ebp
10000001 8B EC mov ebp, esp
10000003 8B 45 08 mov eax, [ebp+8] ; load 1st argument
10000006 3B 45 0C cmp eax, [ebp+0Ch] ; compare it with 2nd
┌┅ 10000009 75 0F jnz short loc_1000001A ; jump if not equal
┊ 1000000B 68 94 67 00 10 push offset aEqual ; "equal"
┊ 10000010 E8 DB F8 FF FF call _printf
┊ 10000015 83 C4 04 add esp, 4
┌─┊─ 10000018 EB 0D jmp short loc_10000027
│ ┊
│ └ loc_1000001A:
│ 1000001A 68 9C 67 00 10 push offset aNah ; "nah"
│ 1000001F E8 CC F8 FF FF call _printf
│ 10000024 83 C4 04 add esp, 4
│
└── loc_10000027:
10000027 5D pop ebp
10000028 C3 retn
Дайте себе минутку и попытайтесь разобраться в этом дизассемблированном коде (для простоты, я изменил реальные адреса и сделал начало функции с 10000000).
В случае, если вам интересно, зачем нужна команда add esp, 4, то это просто приведение ESP к исходному значению (такой же эффект, что и у pop, только без изменения какого-либо регистра), поскольку у нас есть push строкового аргумента для printf.
Базовые структуры данных#
Давайте двигаться дальше. Поговорим о том, как хранятся данные (особенно целые числа и строки).
Endianness
— это порядок байтов, представляющих значение в памяти компьютера.
Есть 2 типа: big-endian
и little-endian
.
Для справки, процессоры семейства x86 (которые используются практически в любых компьютерах) всегда применяют little-endian.
Чтобы наглядно показать эту концепцию, я скомпилировал консольное приложение на C++ в Visual Studio, где объявил переменную типа int
со значением 1337, а затем вывел её адрес с помощью функции printf()
.
После этого запустил программу в отладчике и проверил шестнадцатеричное представление памяти по этому адресу. Вот результат, который я получил:
Уточним этот момент — переменная типа int занимает 4 байта (32 бита) (если вы об этом не знали). Это значит, что если переменная начинается с адреса D2FCB8, то заканчивается она перед адресом D2FCBC (то есть +4 байта).
Чтобы перейти от значения, удобочитаемого человеком, к байтам в памяти, сделайте следующее:
- Десятичное: 1337
- В шестнадцатеричном виде: 539
- В памяти (четыре байта): 00 00 05 39
- В формате little-endian (обратный порядок): 39 05 00 00
Строки#
В языке C строки хранятся в виде массивов типа char, поэтому здесь нет ничего особенного, кроме понятия null termination.
Если вы когда-нибудь задумывались, как функция strlen() узнаёт длину строки, то всё очень просто — строки завершаются специальным символом конца строки, называемым нулевым байтом или ‘\0’ (то есть байт со значением 00).
Если вы объявите строковую константу в C и наведёте курсор на неё, например, в Visual Studio, то увидите размер сгенерированного массива. Как видите, он на один элемент больше, чем длина видимой строки — именно из-за завершающего нулевого символа.
Примечание: концепция порядка байтов не применима к массивам, только к одиночным переменным.
Следовательно, порядок символов в памяти здесь будет нормальным.
Ghidra#
Ghidra — это фреймворк обратного инжиниринга программного обеспечения (SRE), созданный и поддерживаемый исследовательским директоратом Агентства национальной безопасности.
Эта платформа включает в себя набор высококачественных инструментов анализа программного обеспечения высокого класса, которые позволяют пользователям анализировать скомпилированный код на различных платформах, включая Windows, Mac OS и Linux.
Возможности Ghidra включают:
- ассемблирование,
- дизассемблирование,
- декомпиляцию,
- построение графов,
- написание скриптов,
- а также сотни других функций.
Ghidra поддерживает широкий спектр наборов инструкций процессоров и форматов исполняемых файлов и может работать как в интерактивном режиме, так и в автоматическом.
Пользователи могут создавать собственные подключаемые модули и скрипты на Java или Python.
Вскрытие покажет: Решаем лёгкий crackme (легкое)#
Создадим проект с exe файлом.
Сценарий 1
#include <iostream>
#include <string>
int secretFunction(int x) {
int result = (x * 42 + 13) ^ 0x5A;
return result;
}
int main() {
std::string password;
std::cout << "Enter password: ";
std::cin >> password;
if (password == "open_sesame") {
std::cout << "Access granted.\n";
int key = secretFunction(1337);
std::cout << "Secret key is: " << key << "\n";
}
else {
std::cout << "Access denied.\n";
}
return 0;
}
Сценарий 2
#include <iostream>
int main() {
int rows, cols;
std::cout << "Введите количество строк: ";
std::cin >> rows;
std::cout << "Введите количество столбцов: ";
std::cin >> cols;
for (int i = 0; i < rows; ++i) {
for (int j = 0; j < cols; ++j) {
// Вложенные условия с примером
if (i % 2 == 0) {
if (j % 2 == 0) {
std::cout << "[" << i << "," << j << "] — обе координаты чётные\n";
} else {
std::cout << "[" << i << "," << j << "] — i чётное, j нечётное\n";
}
} else {
if (j % 3 == 0) {
std::cout << "[" << i << "," << j << "] — i нечётное, j кратно 3\n";
} else {
std::cout << "[" << i << "," << j << "] — i нечётное, j не кратно 3\n";
}
}
}
}
return 0;
}
Сценарий 3
#include <iostream>
int main() {
int x, y, z;
std::cout << "Введите три целых числа (x, y, z): ";
std::cin >> x >> y >> z;
if (x > 0) {
std::cout << "x положительное\n";
if (y > 0) {
std::cout << "y положительное\n";
if (z > 10) {
std::cout << "z больше 10\n";
if (x + y == z) {
std::cout << "Сумма x и y равна z\n";
if (x - y > 0) {
std::cout << "x минус y положительное\n";
if (z % 2 == 1) {
std::cout << "z нечётное\n";
} else {
std::cout << "z чётное\n";
}
}
}
}
}
} else {
std::cout << "x не положительное\n";
}
return 0;
}
Анализ crack.me (сложное)#
Скачиваем и распаковываем архив; В архиве находим два каталога, соответствующие ОС Linux и Windows. На своей машине я перехожу в каталог Windows и встречаю в нем единственную «экзешку» — level_2.exe
. Давайте запустим и посмотрим, чего она хочет:
Похоже, облом! При запуске программа ничего не выводит. Пробуем запустить еще раз, передав ей произвольную строку в качестве параметра (вдруг, она ждет ключ?) — и вновь ничего… Но не стоит отчаиваться. Давайте предположим, что и параметры запуска нам тоже предстоит выяснить в качестве задания! Пора расчехлять наш «швейцарский нож» — Гидру.
Создание проекта в Гидре и предварительный анализ#
Запускаем Ghidra и в открывшемся Менеджере проектов создаём новый проект; я дал ему имя crackme3 (т.е. проекты crackme и crackme2 уже у меня созданы).
Проект — это, по сути, каталог файлов, в который можно добавлять любые файлы для изучения (exe, dll и т.д.).
Далее сразу же добавим наш файл level_2.exe через меню File | Import или просто нажав клавишу I.
Видим, что уже до импорта Гидра определила нашу подопытную крякми как 32-разрядный PE (portable executable) для ОС Win32 и платформы x86. После импорта наш ждет еще больше информации:
Здесь, кроме вышеуказанной разрядности, нас может еще заинтересовать порядок байтов (endianness), который в нашем случае — Little (от младшего к старшему байту), что и следовало ожидать для «интеловской» 86-й платформы.
С предварительным анализом мы закончили.
Выполнение автоматического анализа#
Время запустить полный автоматический анализ программы в Гидре. Это делается двойным кликом на соответствующем файле (level_2.exe). Имея модульную структуру, Гидра обеспечивает всю свою основную функциональность при помощи системы плагинов, которые можно добавлять / отключать или самостоятельно разрабатывать. Так же и с анализом — каждый плагин отвечает за свой вид анализа. Поэтому сначала перед нами открывается вот такое окошко, в котором можно выбрать интересующие виды анализа:
Для наших целей имеет смысл оставить настройки по умолчанию и запустить анализ. Сам анализ выполняется довольно быстро (у меня занял около 7 секунд), хотя пользователи на форумах сетуют на то, что для больших проектов Гидра проигрывает в скорости IDA Pro. Возможно, это и так, но для небольших файлов эта разница несущественна.
Итак, анализ завершен. Его результаты отображены в окне браузера кода (Code Browser):
Это окно является основным для работы в Гидре, поэтому следует изучить его более внимательно.
Обзор интерфейса браузера кода#
Настройки интерфейса по умолчанию разбивают окно на три части:
Центральная часть — основное окно, листинг дизассемблера, который визуально напоминает интерфейсы IDA, OllyDbg и других аналогичных инструментов.
По умолчанию, в листинге отображаются следующие столбцы (слева направо):
- Адрес памяти
- Опкод команды
- ASM-команда
- Параметры ASM-команды
- Перекрёстная ссылка (если есть)
Настройка отображения возможна по нажатию на кнопку в виде «кирпичной стены» на тулбаре.
🔧 Гибкость настройки дизассемблера в Ghidra — одна из лучших среди подобных программ.
Левая часть содержит 3 панели:
- Секции программы (можно быстро переходить кликом)
- Дерево символов — импорты, экспорты, функции, заголовки и т.д.
- Дерево типов используемых переменных
⭐ Самая полезная часть — дерево символов, которое позволяет быстро найти нужную функцию и перейти к её адресу.
Правая часть — листинг декомпилированного кода
Представлен на языке C, что облегчает понимание логики работы программы.
Дополнительные окна:
Через меню Window можно добавить множество других панелей. Например:
В центральную часть:
- Bytes — окно просмотра памяти
- Function Graph — граф вызовов и переходов между функциями
В правую часть:
- Strings — строковые переменные
- Functions — таблица всех функций
Окна отображаются в отдельных вкладках, и каждое можно открепить и сделать «плавающим», удобно размещая и масштабируя по своему усмотрению.
🎯 Это делает интерфейс чрезвычайно гибким и удобным для анализа.
Изучение алгоритма программы — функция main()#
Что ж, приступим к непосредственному анализу нашей крякми-программки.
Начать нужно с поиска точки входа программы, то есть функции, которая вызывается при старте.
Поскольку наша программа написана на C/C++, можно ожидать, что основной функцией будет main()
или что-то похожее.
Действуем следующим образом:
- В левой панели (в дереве символов) находим фильтр
- Вводим туда main
- Видим среди результатов функцию _main()
в разделе Functions
Кликаем по найденной функции, чтобы перейти к её дизассемблированному и декомпилированному виду.
Обзор функции main()
и переименование непонятных функций
После перехода к main()
в листинге дизассемблера сразу отображается соответствующий участок ассемблерного кода,
а справа — декомпилированный код на C.
Удобная особенность Ghidra — синхронизация выделения:
- Выделяя мышью ASM-команды, одновременно подсвечивается соответствующий участок декомпилированного C-кода.
- Если открыто окно просмотра памяти (Bytes), синхронизация работает и с ним.
Важно понимать специфику Ghidra:
Она делает упор именно на анализ декомпилированного кода,
поэтому создатели уделили большое внимание качеству декомпиляции и удобству навигации:
- Можно перейти к определению переменной или функции двойным кликом по имени.
- Любую функцию, переменную или область памяти можно тут же переименовать (по нажатию клавиши L
или через контекстное меню).
Это особенно полезно, поскольку большинство изначальных имён — вроде FUN_401000
— неинформативны и мешают анализу.
Переименование упрощает понимание логики программы.
Далее Ghidra представит нам распознанный код функции 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;
}
На первый взгляд, декомпилированный код main()
выглядит вполне обычно:
- есть объявления переменных стандартных C-типов,
- присутствуют условия, циклы, вызовы функций.
Однако при внимательном рассмотрении становится заметно, что
часть вызовов функций отображается как _text()
(в окне декомпилятора отображается как .text()
).
🔍 Это означает, что Ghidra не смогла автоматически сопоставить адрес вызова с именем функции.
Вместо этого она ссылается на общую секцию кода .text
.
🛠 Что делаем:
Переходим двойным кликом на первый такой вызов —
и Ghidra откроет листинг той функции, на которую указывает вызов.
Теперь мы можем:
- посмотреть дизассемблированный и декомпилированный код этой функции,
- проанализировать её поведение,
- дать осмысленное имя (например, check_password()
или decrypt_string()
),
- и вернуться обратно в main()
— имя уже отобразится в вызове.
Повторим этот процесс для всех непонятных вызовов, чтобы восстановить структуру программы.
_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()#
Давайте сразу посмотрим на полный листинг этой функции:
char ** __cdecl _construct_key(FILE *param_1)
{
int iVar1;
size_t sVar2;
uint uVar3;
uint local_3c;
byte local_36;
char local_35;
int local_34;
char *local_30 [4];
char *local_20;
undefined4 local_19;
undefined local_15;
char **local_14;
int local_10;
local_14 = (char **)__prepare_key();
if (local_14 == (char **)0x0) {
local_14 = (char **)0x0;
}
else {
local_19 = 0;
local_15 = 0;
_text(&local_19,1,4,param_1);
iVar1 = _text((char *)&local_19,*(char **)local_14[1],4);
if (iVar1 == 0) {
_text(local_14[1] + 4,2,1,param_1);
_text(local_14[1] + 6,2,1,param_1);
if ((*(short *)(local_14[1] + 6) == 4) && (*(short *)(local_14[1] + 4) == 5)) {
local_30[0] = *local_14;
local_30[1] = *local_14 + 0x10c;
local_30[2] = *local_14 + 0x218;
local_30[3] = *local_14 + 0x324;
local_20 = *local_14 + 0x430;
local_10 = 0;
while (local_10 < 5) {
local_35 = 0;
_text(&local_35,1,1,param_1);
if (*local_30[local_10] != local_35) {
_free_key(local_14);
return (char **)0x0;
}
local_36 = 0;
_text(&local_36,1,1,param_1);
if (local_36 == 0) {
_free_key(local_14);
return (char **)0x0;
}
*(uint *)(local_30[local_10] + 0x104) = (uint)local_36;
_text(local_30[local_10] + 1,1,*(size_t *)(local_30[local_10] + 0x104),param_1);
sVar2 = _text(local_30[local_10] + 1);
if (sVar2 != *(size_t *)(local_30[local_10] + 0x104)) {
_free_key(local_14);
return (char **)0x0;
}
local_3c = 0;
_text(&local_3c,1,1,param_1);
local_3c = local_3c + 7;
uVar3 = _text(param_1);
if (local_3c < uVar3) {
_free_key(local_14);
return (char **)0x0;
}
*(uint *)(local_30[local_10] + 0x108) = local_3c;
_text(param_1,local_3c,0);
local_10 = local_10 + 1;
}
local_34 = 0;
_text(&local_34,4,1,param_1);
if (*(int *)(*local_14 + 0x53c) == local_34) {
_text("Markers seem to still exist");
}
else {
_free_key(local_14);
local_14 = (char **)0x0;
}
}
else {
_free_key(local_14);
local_14 = (char **)0x0;
}
}
else {
_free_key(local_14);
local_14 = (char **)0x0;
}
}
return local_14;
}
С этой функцией мы поступим так же, как и ранее с main()
— для начала пройдемся по «завуалированным» вызовам функций. Как и ожидалось, все эти функции — из стандартных библиотек C.
Описывать заново процедуру переименования функций не буду — вернись к первой части статьи, если нужно.
В результате переименования «нашлись» следующие стандартные функции:
fread()
strncmp()
strlen()
ftell()
fseek()
puts()
Соответствующие функции-обертки в нашем коде (те, что декомпилятор нагло прятал за словом _text
) мы переименовали в эти, добавив индекс 2
(чтобы не возникало путаницы с оригинальными C-функциями). Почти все эти функции служат для работы с файловыми потоками.
Оно и не удивительно — достаточно беглого взгляда на код, чтобы понять, что здесь производится последовательное чтение данных из файла (дескриптор которого передается в функцию в качестве единственного параметра) и сравнение прочитанных данных с неким двумерным массивом байтов
local_14
.
Давайте предположим, что этот массив содержит данные для проверки ключа. Назовем его, скажем, key_array
.
Поскольку Гидра позволяет переименовывать не только функции, но и переменные, воспользуемся этим и переименуем непонятный local_14
в более понятный key_array
.
Делается это так же, как и для функций: через меню правой клавиши мыши (Rename local
) или клавишей L
с клавиатуры.
Итак, сразу же за объявлением локальных переменных вызывается некая функция _prepare_key()
:
key_array = (char **)__prepare_key();
if (key_array == (char **)0x0) {
key_array = (char **)0x0;
}
К _prepare_key()
мы еще вернемся — это уже 3-й уровень вложенности в нашей иерархии вызовов:
main()
→ _construct_key()
→ _prepare_key()
.
Пока же примем, что она создает и как-то инициализирует этот «проверочный» двумерный массив.
И только в случае, если этот массив не пуст, функция продолжает свою работу,
о чем свидетельствует блок else
сразу же после приведенного условия.
Далее программа читает первые 4 байта из файла и сравнивает с соответствующим участком массива key_array
.
(Код ниже — уже после произведенных переименований, в т.ч. переменную local_19
я переименовал в first_4bytes
.)
first_4bytes = 0;
/* прочитать первые 4 байта из файла */
fread2(&first_4bytes,1,4,param_1);
/* сравнить с key_array[1][0...3] */
iVar1 = strncmp2((char *)&first_4bytes,*(char **)key_array[1],4);
if (iVar1 == 0) { ... }
Таким образом, дальнейшее выполнение происходит только в случае совпадения первых 4 байтов (запомним это). Дальше читаем 2 2-байтных блока из файла (причем в роли буфера для записи данных используется тот же key_array):
fread2(key_array[1] + 4,2,1,param_1);
fread2(key_array[1] + 6,2,1,param_1);
И вновь — дальше функция работает только в случае истинности очередного условия:
if ((*(short *)(key_array[1] + 6) == 4) && (*(short *)(key_array[1] + 4) == 5)) {
// выполняем дальше ...
}
Нетрудно увидеть, что первый из прочитанных выше 2-байтных блока должно быть числом 5, а второй — числом 4 (тип данных short как раз занимает 2 байта на 32-разрядных платформах).
Дальше — вот это:
local_30[0] = *key_array; // т.е. key_array[0]
local_30[1] = *key_array + 0x10c;
local_30[2] = *key_array + 0x218;
local_30[3] = *key_array + 0x324;
local_20 = *key_array + 0x430;
Здесь мы видим, что в массив local_30
(объявленный как char *local_30[4]
) заносятся смещения указателя key_array
.
То есть local_30
— это массив строк-маркеров, в который наверняка будут читаться данные из файла.
По этому допущению я переименовал local_30
в markers
.
В этом участке кода немного подозрительной кажется только последняя строка, где присвоение последнего смещения (по индексу 0x430
, т.е. 1072) выполняется не очередному элементу markers
, а отдельной переменной local_20
(char*
).
Но с этим мы еще разберемся, а пока — давайте двигаться дальше!
Дальше нас ожидает цикл:
i = 0; // local_10 переименовал в i
while (i < 5) {
// ...
i = i + 1;
}
Т.е. всего 5 итераций от 0 до 4 включительно. В цикле сразу начинается чтение из файла и проверка на соответствие нашему массиву markers:
char c_marker = 0; // переименовал из local_35
/* прочитать след. байт из файла */
fread2(&c_marker, 1, 1, param_1);
if (*markers[i] != c_marker) {
/* здесь и далее - вернуть пустой массив при ошибке */
_free_key(key_array);
return (char **)0x0;
}
То есть читается следующий байт из файла в переменную c_marker (в оригинальном декомпилированном коде — local_35) и проверяется на соответствие первому символу i-го элемента markers. В случае несоответствия массив key_array обнуляется и возвращается пустой двойной указатель. Далее по коду мы видим, что такое проделывается всякий раз при несовпадении прочитанных данных с проверочными.
Но тут, как говорится, «зарыта собака». Давайте внимательнее посмотрим на этот цикл. В нем 5 итераций, как мы выяснили. Это можно при желании проверить, взглянув на ассемблерный код:
Действительно, команда CMP
сравнивает значение переменной local_10
(у нас это уже i
) с числом 4
,
и если значение меньше или равно 4 (команда JLE
), производится переход к метке LAB_004017eb
,
то есть к началу тела цикла.
Таким образом, условие будет соблюдаться для
i = 0, 1, 2, 3 и 4
— всего 5 итераций!
Все бы хорошо, но markers
также индексируется по этой переменной в цикле,
а ведь этот массив у нас объявлен только с 4 элементами:
char *markers [4];
Значит, кто-то кого-то явно обмануть пытается :) Помните, я сказал, что эта строка наводит сомнения?
local_20 = *key_array + 0x430;
Еще как! Просто посмотрите на весь листинг функции и попробуйте отыскать еще хоть одну ссылку на переменную local_20. Ее нет! Отсюда делаем вывод: это смещение должно также сохраняться в массив markers, а сам массив должен содержать 5 элементов. Давайте исправим это. Переходим к объявлению переменной, жмем Ctrl + L (Retype variable) и смело меняем размер массива на 5:
Готово. Скроллим ниже до кода присвоения смещений указателя элементам markers, и — о чудо! — исчезает непонятная лишняя переменная и все становится на свои места:
markers[0] = *key_array;
markers[1] = *key_array + 0x10c;
markers[2] = *key_array + 0x218;
markers[3] = *key_array + 0x324;
markers[4] = *key_array + 0x430; // убежавшее было присвоение... мы поймали тебя!
Возвращаемся к нашему циклу while (в исходном коде это, скорее всего, будет for, но нас это не волнует). Далее опять читается байт из файла и проверяется его значение:
byte n_strlen1 = 0; // переименован из local_36
/* прочитать след. байт из файла */
fread2(&n_strlen1,1,1,param_1);
if (n_strlen1 == 0) {
/* значение не должно быть нулевым */
_free_key(key_array);
return (char **)0x0;
}
ОК, этот n_strlen1 должен быть ненулевым. Почему? Сейчас увидишь, а заодно и поймешь, почему я присвоил этой переменной такое имя:
/* записываем значение n_strlen1) в (markers[i] + 0x104) */
*(uint *)(markers[i] + 0x104) = (uint)n_strlen1;
/* прочитать из файла (n_strlen1) байт (--> некая строка?) */
fread2(markers[i] + 1,1,*(size_t *)(markers[i] + 0x104),param_1);
n_strlen2 = strlen2(markers[i] + 1); // переименован из sVar2
if (n_strlen2 != *(size_t *)(markers[i] + 0x104)) {
/* длина прочитанной строки (n_strlen2) должна == n_strlen1 */
_free_key(key_array);
return (char **)0x0;
}
Я добавил комментарии, по которым должно быть все понятно. Из файла читается n_strlen1 байтов и сохраняется как последовательность символов (т.е. строка) в массив markers[i] — то есть после соответствующего «стоп-символа», которые там уже записаны из key_array. Сохранение значения n_strlen1 в markers[i] по смещению 0x104 (260) здесь не играет никакой роли (см. первую строку в коде выше). По факту этот код можно оптимизировать следующим образом (и наверняка так это и есть в исходном коде):
fread2(markers[i] + 1, 1, (size_t) n_strlen1, param_1);
n_strlen2 = strlen2(markers[i] + 1);
if (n_strlen2 != (size_t) n_strlen1) { ... }
Также проводится проверка того, что длина прочитанной строки равна n_strlen1. Это может показаться излишним, с учетом что данный параметр передавался в функцию fread, но fread читает не более столько-то указанных байтов и может прочитать меньше, чем указано, например, в случае встречи маркера конца файла (EOF). То есть все строго: в файле указывается длина строки (в байтах), затем идет сама строка — и так ровно 5 раз. Но мы забегаем вперед.
Далее вод этот код (который я также сразу прокомментировал):
uint n_pos = 0; // переименован из local_3c
/* прочитать след. байт из файла */
fread2(&n_pos,1,1,param_1);
/* увеличить на 7 */
n_pos = n_pos + 7;
/* получить позицию файлового курсора */
uint n_filepos = ftell2(param_1); // переименован из uVar3
if (n_pos < n_filepos) {
/* n_pos должна быть >= n_filepos */
_free_key(key_array);
return (char **)0x0;
}
Здесь все еще проще: берем следующий байт из файла, прибавляем 7 и полученное значение сравниваем с текущей позицией курсора в файловом потоке, полученным функцией ftell(). Значение n_pos должно быть не меньше позиции курсора (т.е. смещения в байтах от начала файла).
Завершающая строка в цикле:
fseek2(param_1,n_pos,0);
Т.е. переставляем курсор файла (от начала) на позицию, указанную n_pos функцией fseek(). ОК, все эти операции в цикле мы проделываем 5 раз. Завершается же функция _construct_key() следующим кодом:
int i_lastmarker = 0; // переименован из local_34
/* прочитать последние 4 байт из файла (int32) */
fread2(&i_lastmarker,4,1,param_1);
if (*(int *)(*key_array + 0x53c) == i_lastmarker) {
/* это число должно == key_array[0][1340]
...тогда все ОК :) */
puts2("Markers seem to still exist");
}
else {
_free_key(key_array);
key_array = (char **)0x0;
}
Таким образом, последним блоком данных в файле должно быть 4-байтовое целочисленное значение и оно должно равняться значению в key_array[0][1340]. В этом случае нас ждет поздравительное сообщение в консоли. А в противном случае — все так же возвращается пустой массив без всяких похвал
Обзор функции __prepare_key()#
У нас осталась только одна неразобранная функция — __prepare_key()
.
Мы уже догадались, что именно в ней формируются проверочные данные в виде массива key_array
,
который затем используется в функции _construct_key()
для проверки данных из файла.
Осталось выяснить, какие именно там данные!
Я не буду подробно разбирать эту функцию и сразу приведу полный листинг с комментариями после всех необходимых переименований переменных:
void ** __prepare_key(void)
{
void **key_array;
void *pvVar1;
/* key_array = new char*[2]; // 2 4-байтных указателя (char*) */
key_array = (void **)calloc2(1,8);
if (key_array == (void **)0x0) {
key_array = (void **)0x0;
}
else {
pvVar1 = calloc2(1,0x540);
/* key_array[0] = new char[1340] */
*key_array = pvVar1;
pvVar1 = calloc2(1,8);
/* key_array[1] = new char[8] */
key_array[1] = pvVar1;
/* "VOID" */
*(undefined4 *)key_array[1] = 0x404024;
/* 5 и 4 (2-байтные слова) */
*(undefined2 *)((int)key_array[1] + 4) = 5;
*(undefined2 *)((int)key_array[1] + 6) = 4;
/* key_array[0][0] = 'b' */
*(undefined *)*key_array = 0x62;
*(undefined4 *)((int)*key_array + 0x104) = 3;
/* 'W' */
*(undefined *)((int)*key_array + 0x218) = 0x57;
/* 'p' */
*(undefined *)((int)*key_array + 0x324) = 0x70;
/* 'l' */
*(undefined *)((int)*key_array + 0x10c) = 0x6c;
/* 152 (не ASCII) */
*(undefined *)((int)*key_array + 0x430) = 0x98;
/* последний маркер = 1122 (int32) */
*(undefined4 *)((int)*key_array + 0x53c) = 0x462;
}
return key_array;
}
Единственное место, достойное рассмотрения, — это вот эта строка:
*(undefined4 *)key_array[1] = 0x404024;
Как я понял, что здесь кроется строка «VOID»? Дело в том, что 0x404024 — это адрес в адресном пространстве программы, ведущий в секцию .rdata. Двойной клик на это значение позволяет нам как на ладони увидеть, что там находится:
Кстати, это же можно понять из ассемблерного кода для этой строки:
004015da c7 00 24 MOV dword ptr [EAX], .rdata = 56h V
40 40 00
Данные, соответствующие строке «VOID», находятся в самом начале секции .rdata (по нулевому смещению от соответствующего адреса).
Итак, на выходе из этой функции должен быть сформирован двумерный массив со следующими данными:
[0] [0]:'b' [268]:'l' [536]:'W' [804]:'p' [1072]:152 [1340]:1122
[1] [0-3]:"VOID" [4-5]:5 [6-7]:4
Готовим двоичный файл для крякми#
Теперь можем приступить к синтезу двоичного файла. Все исходные данные у нас на руках:
1) проверочные данные («стоп-символы») и их позиции в проверочном массиве;
2) последовательность данных в файле
Давайте восстановим структуру искомого файла по алгоритму работы функции _construct_key(). Итак, последовательность данных в файле будет такова:
Структура файла
4 байта == key_array[1][0...3] == «VOID»
2 байта == key_array[1][4] == 5
2 байта == key_array[1][6] == 4
1 байт == key_array[0][0] == 'b' (маркер)
1 байт == (длина следующей строки) == n_strlen1
n_strlen1 байт == (любая строка) == n_strlen1
1 байт == (+7 == следующий маркер) == n_pos
1 байт == key_array[0][0] == 'l' (маркер)
1 байт == (длина следующей строки) == n_strlen1
n_strlen1 байт == (любая строка) == n_strlen1
1 байт == (+7 == следующий маркер) == n_pos
1 байт == key_array[0][0] == 'W' (маркер)
1 байт == (длина следующей строки) == n_strlen1
n_strlen1 байт == (любая строка) == n_strlen1
1 байт == (+7 == следующий маркер) == n_pos
1 байт == key_array[0][0] == 'p' (маркер)
1 байт == (длина следующей строки) == n_strlen1
n_strlen1 байт == (любая строка) == n_strlen1
1 байт == (+7 == следующий маркер) == n_pos
1 байт == key_array[0][0] == 152 (маркер)
1 байт == (длина следующей строки) == n_strlen1
n_strlen1 байт == (любая строка) == n_strlen1
1 байт == (+7 == следующий маркер) == n_pos
4 байта == (key_array[1340]) == 1122
Для наглядности я сделал в Excel такую табличку с данными искомого файла:
Здесь в 7-й строке — сами данные в виде символов и чисел, в 6-й строке — их шестнадцатеричные представления, в 8-й строке — размер каждого элемента (в байтах), в 9-й строке — смещение относительно начала файла. Это представление очень удобно, т.к. позволяет вписывать любые строки в будущий файл (отмечены желтой заливкой), при этом значения длин этих строк, а также смещения позиции следующего стоп-символа вычисляются формулами автоматически, как это требует алгоритм программы. Выше (в строках 1-4) приведена структура проверочного массива key_array.
Генерация двоичного файла и проверка#
Осталось дело за малым — сгенерировать искомый файл в двоичном формате и скормить его нашей крякми. Для генерации файла я написал простенький скрипт на Python:
import sys, os
import struct
import subprocess
out_str = ['!', 'I', ' solved', ' this', ' crackme!']
def write_file(file_path):
try:
with open(file_path, 'wb') as outfile:
outfile.write('VOID'.encode('ascii'))
outfile.write(struct.pack('2h', 5, 4))
outfile.write('b'.encode('ascii'))
outfile.write(struct.pack('B', len(out_str[0])))
outfile.write(out_str[0].encode('ascii'))
pos = 10 + len(out_str[0])
outfile.write(struct.pack('B', pos - 6))
outfile.write('l'.encode('ascii'))
outfile.write(struct.pack('B', len(out_str[1])))
outfile.write(out_str[1].encode('ascii'))
pos += 3 + len(out_str[1])
outfile.write(struct.pack('B', pos - 6))
outfile.write('W'.encode('ascii'))
outfile.write(struct.pack('B', len(out_str[2])))
outfile.write(out_str[2].encode('ascii'))
pos += 3 + len(out_str[2])
outfile.write(struct.pack('B', pos - 6))
outfile.write('p'.encode('ascii'))
outfile.write(struct.pack('B', len(out_str[3])))
outfile.write(out_str[3].encode('ascii'))
pos += 3 + len(out_str[3])
outfile.write(struct.pack('B', pos - 6))
outfile.write(struct.pack('B', 152))
outfile.write(struct.pack('B', len(out_str[4])))
outfile.write(out_str[4].encode('ascii'))
pos += 3 + len(out_str[4])
outfile.write(struct.pack('B', pos - 6))
outfile.write(struct.pack('i', 1122))
except Exception as err:
print(err)
raise
def main():
if len(sys.argv) != 2:
print('USAGE: {this_script.py} path_to_crackme[.exe]')
return
if not os.path.isfile(sys.argv[1]):
print('File "{}" unavailable!'.format(sys.argv[1]))
return
file_path = os.path.splitext(sys.argv[1])[0] + '.dat'
try:
write_file(file_path)
except:
return
try:
outputstr = subprocess.check_output('"{}" -f "{}"'.format(sys.argv[1], file_path), stderr=subprocess.STDOUT)
print(outputstr.decode('utf-8'))
except Exception as err:
print(err)
if __name__ == '__main__':
main()
Скрипт принимает единственным параметром путь к крякми, затем генерирует в этой же директории двоичный файл с ключом и вызывает крякми с соответствующим параметром, транслируя в консоль вывод программы.
Для конвертации текстовых данных в двоичные используется пакет struct. Метод pack() позволяет записывать двоичные данные по формату, в котором указывается тип данных («B» = «byte», «i» = int и т.д.), а также можно указать порядок следования (">" = «Big-endian», "<" = «Little-endian»). По умолчанию применяется порядок Little-endian. Т.к. мы уже определили в первой статье, что это именно наш случай, то указываем только тип.
Весь код в целом воспроизводит найденный нами алгоритм программы. В качестве строки, выводимой в случае успеха, я указал «I solved this crackme!» (можно модифицировать этот скрипт, чтобы возможно было указывать любую строку).
Проверяем вывод: