Метод Reverse-engineering на практике: как расшифровать исходный код

Всем привет!
Сегодня в нашем эфире новый автор — Никита Синкевич, руководитель группы анализа и реагирования Инженерного центра Angara Security. Итак, начинаем!

Иногда в ходе расследования инцидента информационной безопасности необходимо понять, имеет ли та или иная программа вредоносное воздействие на систему. Если для данной программы у вас нет исходного кода, то приходится применять метод исследования «обратная разработка», или Reverse Engineering. Я покажу, что такое «обратная разработка» на примере задания «RE-101» с ресурса cyberdefenders.

CyberDefenders — это платформа для развития навыков blue team по обнаружению и противодействию компьютерным угрозам. В этой статье будет разбор последней, шестой подзадачи из задания «RE-101». На мой взгляд, она лучше раскрывает процесс реверс-инжиниринга, чем остальные: нам предстоит восстановить из исполняемого файла кастомный алгоритм шифрования, написать скрипт-дешифратор и расшифровать флаг.

Используемые инструменты:

1.       Detect it Easy

2.       IDA

3.       Python3

4.       Notepad++

5.       CyberChef

Задание

Описание намекает, что в предложенном файле malware201 кто-то реализовал свой собственный криптографический алгоритм.

1e107d7fc8b7819088ed96a369dbfec6.png

Запустим DiE и посмотрим информацию о файле из задания:

67e1eab020d98ab61d771ba80b502a7f.png

Видим, что это 64-разрядный исполняемый файл для Linux никакого протектора и упаковщика DiE не нашел, — это радует.

Теперь запустим программу:

a03553762192fe4189f42395334b79b6.png

Программа ничего не запрашивает, просто выводит две строчки: зашифрованный флаг и строку-подсказку, скорее всего, это строчка из исходного кода данного ПО.

Загрузим файл в IDAx64. Перед нами функция main (), разобьем ее на блоки следующим образом:

bc6f8c8e3cafa859e84399e383abc506.png

Разберем каждый блок по отдельности.

Блок 1

3a8ba2e932fa6aa4924b718cf9970366.png

В этом блоке определяются смещения в памяти, потом они будут использоваться для определения места хранения данных в процессе работы программы.

Блок 2 Пролог функции

4ed16bb442e04fb7a4b3ba8a54fda729.png

Это пролог, он отвечает за настройку среды функции. Первая инструкция сохраняет rbp (также называемый указателем кадра) в стеке.
Это сделано для того, чтобы его можно было восстановить после возврата из функции. В следующей инструкции значение rsp копируется в rbp, в результате и rsp, и rbp указывают на вершину стека.

rbpтеперь будет хранить фиксированный адрес, и программа будет использовать rbp для ссылки на аргументы функции и локальные переменные. sub rsp, 40h уменьшает значение регистра rsp, таким образом, выделяется место (64 байта) для локальных переменных.

Блок 3 Вывод строки

0b35279c243db05fbaae0e5c9f3d3df0.png

Команда "mov rdi offset aTheEncryptedFl” помещает указатель на строку в регистр rdi. Регистр rdi используется для передачи первого аргумента в функцию. Аналогично работает следующая команда — в регистр rax помещается адрес некоторой метки unk_40082B, после чего значение rax сохраняется в памяти по адресу rbp+var_8, а в регистр al помещается 0 «mov al, 0», чтобы последующий вызов функции printf отработал корректно. Последняя команда в данном блоке вызывает функцию printf передавая ей из rdi адрес строки aTheenc.

Данный блок можно описать одной строкой на более высокоуровневом языке программирования. Уверен, что здесь очень хорошо подошел бы C++, но навыков работы с ним у меня не хватает, поэтому я буду писать C-подобный псевдокод, который дальше будет магическим образом с помощью спецсредств преобразован в нормальную программу на C++.
Итак: псевдокод printf("The encypted flag is: \"”);

Блок 4 Вызов функции sub_4005B0

1096400dabbe9ab5409f3a61118ba24e.png

Первые две команды помещают в регистры ecx и esi значение 2016=3210. Регистр rsi используется для передачи второго аргумента в функцию. Затем в rdi помещают значение из памяти по адресу rbp+var_8. Вспомним, что там находится адрес метки unk_40082B, который был туда помещен командой из предыдущего блока. Предпоследняя команда сохраняет значение eax в памяти по адресу rbp+var_24, это нужно, потому что последующий возврат функции sub_4005B0 перезапишет eax и предыдущие данные будут потеряны.

Представим данный блок в виде еще одной строки псевдокода: sub_4005B0(&unk_40082B, 32);Посмотрим, что хранится в &unk_40082B.

a9de93153dd4a04c167f8dfe8123d2f8.png

Видим массив из 32 байт, завершаемый нулем, который выводится при запуске программы, это флаг, который нужно будет расшифровать. Дадим этому массиву имя flag.

5882c33c74a5900c81cef1dda51d4f0c.png

Чтобы преобразовать этот массив в символы, скопируем первую строку вывода программы в notepad++ и удалим символы »\x».

993974f20add93399671769d41ccadbe.png

Получится такая строка.

7f788caca6bc7acc7b6a5e9f74532afb.png

Теперь с помощью ресурса gchq.github.io/CyberChef/ и операции «From Hex» преобразуем ее в символы.

ec5927c4e217f8eae1b8ebb4f11f5af2.png

Полученная символьная строка будет нужна для восстановления исходного кода программы.

На данном этапе можно предположить, что функция sub_4005B0 нужна для вывода строки в определенном формате. Дадим ей название printing. Для этого выделяем функцию sub_4005B0, нажимаем N и вводим название.

83ca6153d8c820dbac2218c56019d5d8.png

Псевдокод: printing (flag, 32);

Блок 5 Вывод строки

2e3325076403ffd3145429d92695af9c.png

Так же как и блок 3, данный блок выводит строку <”\n\n>
Псевдокод: printf (»\»\n\n»);

Блок 6 Вызов функции strlen

c7e673793313a84442d16e68da04bf7d.png

Первые две инструкции записывают адреса строк и  в регистры rdi и rsi соответственно.

Следующие три инструкции сохраняют адрес строки в память по адресу rbp+s и адрес строки в память по адресу rbp+format. Затем в регистр rdi записывается адрес строки , после чего значение eax сохраняется в память по адресу rbp+var_34, и вызывается функция strlen, которая на вход приняла строку с rdi и результат записала в rax. Заключающая инструкция в этом блоке сохраняет значение rax в память по адресу rbp+var_18.

Псевдокод: var_18 = strlen("my message”);
Переименуем var_18 в len_message

a9475777305959e74fbdf525c1402948.png

Блок 7 Вывод строки в определенном формате

a07375e854d6270ec79cae592bedc738.png

Первые три инструкции помещают в регистры rsi, rdx и rdi адрес строки , длину строки и адрес строки соответственно, после чего вызывается функция printf. Напомню, что регистр rdi используется для передачи в функцию первого аргумента, rsi — второго, а rdx — третьего.

Псевдокод: printf("encrypt(\"%s\", %ld) == \"", "my message", len_message);
Данный код выводит следующий участок строки:

1d03c51220895f528d3f6b6124ef1e09.png

Блок 8 Вызов функции sub_400620

29eb4c5e8bab9a8a28ad5373c0cd94a9.png

Первые две инструкции передают в rdi и rsi адрес строки и ее длину соответственно, после чего происходит вызов sub_400620 с сохранением результата в память по адресу rbp+ptr. Назовем эту функцию encrypt, так же, как и в выводе программы.

ac4e33409173622758f98fab42535f1c.png

Псевдокод: ptr = encrypt ("my message", len_message);

Блок 9 Вызов функции printing (sub_4005B0)

dd17fb23cf4aadf48f0809d65d28def5.png

В этом блоке снова вызывается функция sub_4005B0, но уже с другими аргументами.

Первый аргумент — результат работы предыдущей функции, хранящийся по адресу rbp+ptr. Второй аргумент — длина строки .
Псевдокод: printing (ptr, len_message);

Блок 10 Вывод строки

88beef2519959992c381785613e9c81a.png

Так же, как и блоки 3,5, данный блок выводит строку <\”\n>
Псевдокод: printf("\"\n");

Блок 11 Освобождение выделенной памяти

c44291290886d156f52bc8b9e73b3852.png

Здесь в rdi сохраняется результат работы (скорее всего это будет указатель) функции encrypt и передается как аргумент функции free. Псевдокод: free(ptr);

Блок 12 Эпилог функции

5a090b96cb4f10b5cbb77446a7bd1b00.png

Эпилог предназначен для действий, обратных прологу. xor eax, eax устанавливает значение eax равным 0.

Это возвращаемое значение (return 0). add rsp, 40h возвращает rsp в положение, которое было до пролога. Это очистка стека.

pop rbp восстанавливает старый rbp из стека.

Псевдокод: return 0;

Объединим все ранее написанные строки в единую программу:
int main() {
int len_message;

int *ptr; // скорее всего это указатель, тип данных пусть будет int

char flag[32] = mxalÝ~e~GjOÌ÷ÊshUBSÜ×ÔkìÛÒámÞÑÂ;
printf("The encypted flag is: \"”);
printing (flag, 32);
printf("\"\n\n");
len_message = strlen("my message”);
printf("encrypt(\"%s\", %ld) == \"", "my message", len_message);
ptr = encrypt ("my message", len_message);
printing (ptr, len_message);
printf("\"\n");
free(ptr);
return 0;
}

Из листинга программы можно увидеть, что из main вызываются две пока неизвестные функции: printing и encrypt.

633efe95f87fcd20d7d8034acd595b7a.png

IDA разбила код на блоки, потому что инструкции в данной функции могут выполняться нелинейно. Если присмотреться, то можно увидеть цикл. Я дал названия переменным, чтобы было проще ориентироваться в коде.

Блок 1. Пролог, выделение памяти для переменных, сохранение аргументов

f52f11fe9a944e37d7ca536592f55b60.png

В первом блоке выделяется память под переменную цикла и переменных, которые предназначены для аргументов, переданных в данную функцию. Первый аргумент передан в регистр rdi — это указатель на строку message, второй аргумент передан в регистр rdi — это число, которое соответствует длине строки.

Блок 2. Предусловие

125fb68ecb91e7f4cad57c5b80bc6687.png

Второй блок представляет собой предусловие цикла: здесь сравнивается (cmp) переменная цикла i и переменная len_message, которая содержит в себе длину переданной строки.

Инструкция JNB (Jump if Not Below) выполняет переход, если первый операнд НЕ меньше второго (то есть больше или равен второму) при выполнении команды cmp. Таким образом, если i больше либо равно len_message, условие будет удовлетворено и выполнится выход из цикла (переход по зеленой стрелочке). В противном случае переход не случится и выполнение продолжится в теле цикла.

Блок 3. Тело цикла
Разобьем его на две части следующим образом:

5e7fd6002675b9be85f4fd7c1479b6cd.png

В первой части вызывается функция _printf с аргументами, список ниже.

1.       rdi — указатель на строку < \\x%02x >;

2.       esi — указатель на i-тый байт строки message. Он получается в результате:

2.1.   перемещения в rax указателя на строку message: mov rax, [rbp+message]

2.2.   перемещения в rcx значения переменной i: mov rcx, [rbp+i]

2.3.   увеличения адреса указателя на значение переменной цикла: rax+rcx

2.4.   перемещения в rdx значения одного байта, расположенного по адресу rax+rcx: movsx edx, byte ptr [rax+rcx]

2.5.   перемещения в esi значения edx

Таким образом, данная часть выводит i-тый байт строки message в формате \х00.
Вторая часть этого блока увеличивает переменную i на 1 и осуществляет переход в предыдущий блок с условием, запуская следующую итерацию цикла.

Блок 4. Эпилог функции

534e65731e011d73963abb91589f42d7.png

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

Псевдокод:

void printing (message, len_message){
for (int i = 0; i< len_message; i++){
		printf("\\x%02”, *(message + i));
}
}

Анализ функции encrypt

d4544fb152b75d60beadfdaf23786dcb.png

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

Блок 1

31e9e0e7349730e72c17adbb996b3f82.png

В данном блоке вызывается calloc с аргументами rdi = len_message и rsi = 1. Данная функция выделяет пространство для хранения массива из len_message элементов, каждый из которых имеет размер 1 байт и равен нулю. Указатель на данный массив сохраняется в rax, а затем помещается в переменную encrypted_message. Крайняя инструкция в данном блоке инициализирует переменную цикла i=0.

Блок 2. Предусловие

cc09f4ad964e347aa8d72cafb1574b91.png

Данный блок, так же, как и аналогичный блок предыдущей функции, выполняет переход, если первый операнд больше или равен второму. Т.е. если i меньше len_message будет выполнено тело цикла (переход по красной стрелочке).

Блок 3. Тело цикла

6d9bcf8e6ba5693ecd090f27aa4ae03a.png

И вот мы подобрались к самому интересному: в этом блоке реализован алгоритм шифрования.
Разобравшись в логике работы данного блока, мы сможем написать программу-дешифратор.

Разберем данный блок подробнее:

1.       В ecx записывается FF;

2.       В eax помещается значение i-го элемента массива message;

3.       Для данного элемента выполняется побитовый сдвиг влево на 1 (инструкция shl);

4.       Для этого же элемента выполняется операция логического ИЛИ с единицей;

5.       Результат из eax помещается в переменную part;

6.       В rax помещается переменная цикла i и делится на rcx=FF
(значение было присвоено в п.1), результат деления записывается в rax, в rdx записывается остаток от деления;

7.       Для rdx выполняется операция логического ИЛИ с 0A0;

8.       Для переменной part выполняется операция «исключающее ИЛИ» с rdx, результат записывается в rcx;

9.       Результат из предыдущего пункта записывается в i-ый байт массива encrypted_message;

10.   К переменной цикла прибавляется единица;

11.   Переход в Блок 2.

Блок 4. Эпилог функции

3c1fb27df9516d52a3b90886a4e22752.png

В данном блоке в rax помещается указатель на массив encrypted_message, что позволяет вернуть это значение при выходе из функции, затем следуют стандартные инструкции очистки стека и восстановления старого rbp из стека.

Псевдокод:

int encrypt(message, len_message){
	char *encrypted_message;
	for (int i = 0; i < len_message; i++){
	encrypted_message[i] = ( ( ( i % 0xFF ) | 0xA0) ^ ( ( message[i] << 1) | 1 ) )
}
	return encrypted_message;
}

Написание скрипта-дешифратора

В данном случае успешная расшифровка сообщения произойдет, если мы сможем узнать, чему равно message[i] из 3-го блока функции encrypt для любого i. Для этого нужно понять, какая информация у нас есть изначально. Итак, что мы знаем:

1.       Переменную цикла — i. Изначально она равна нулю и увеличивается при каждой следующей итерации цикла на 1;

2.       Каждый элемент зашифрованного сообщения — encrypted_message[i] и длину этого сообщения — len_message, так как у нас это сообщение есть;

Теперь, если взглянуть на данную строчку, выделив известные переменные и искомую message[i]:

encrypted_message[i] = (((i % 0xFF) | 0xA0) ^ ((message[i]) << 1) | 1 ) )

У нас получается своеобразное уравнение с одной неизвестной — X, где a = encrypted_message[i], b = (i % 0xFF) | 0xA0

a = b ^ X, ввиду особенности XOR: A XOR (A XOR B) = B, его можно привести к следующему виду:

X = a ^ b, что соответствует (message[i] << 1 ) | 1 = encrypted_message[i] ^ ( ( i % 0xFF ) | 0xA0 )

314476301edf687bb2048e9493771e98.png

Осталось разобраться с (message[i] << 1 ) | 1. Здесь для message[i] применяется:

1.       Сдвиг влево

2.       Изменение первого бита на 1

Возьмем для примера символ J. В таблице ascii он имеет код 4A16, что соответствует значению 10010102

1c1fa7b14764720c21aed5117df2b8c6.png

Получается, для того, чтобы получить исходное значение, достаточно произвести сдвиг вправо или произвести деление на 102. Оба этих действия будут иметь одинаковый эффект — удаление младшего бита. Осталось реализовать данную логику в python. Код представлен ниже:

Python3:
# Зашифрованный флаг
encrypted_flag = "\x6d\x78\x61\x6c\xdd\x7e\x65\x7e\x47\x6a\x4f\xcc\xf7\xca\x73\x68\x55\x42\x53\xdc\xd7\xd4\x6b\xec\xdb\xd2\xe1\x1c\x6d\xde\xd1\xc2"

 # Преобразование каждого символа в шестнадцатеричное число, а затем в десятичное
decimal_array = [int(hex(ord(char)), 16) for char in encrypted_flag]

 # Создание переменной для расшифрованного флага
flag = ""

 # Главный цикл
for i in range (len(decimal_array)):

# Выполнение XOR для выделения нужной части
part = ((i % 0xFF) | 0xA0) ^ int(decimal_array[i])

# Удаление последнего бита, преобразование числа в символ
flag += (chr (part // 2) )

# Вывод расшифрованного файла
print(flag)

После выполнения данного скрипта, будет выведен флаг:
flag

# Создание переменной для расшифрованного флага
flag = ""

# Главный цикл
for i in range (len(decimal_array)):

# Выполнение XOR для выделения нужной части
part = ((i % 0xFF) | 0xA0) ^ int(decimal_array[i])

# Удаление последнего бита, преобразование числа в символ
flag += (chr (part // 2) )

# Вывод расшифрованного файла
print(flag)

После выполнения данного скрипта, будет выведен флаг:
flag

Восстановление исходного кода программы:

Еще помните про псевдокод? Так вот, его можно легко переписать на C++ с помощью ChatGPT. Я использовал следующий запрос:

#include 
#include 
using namespace std;
void printing (message, len_message){
  for (int i = 0; i< len_message; i++){
    printf("\\x%02", *(message + i));
  }
}
int encrypt(message, len_message){
  char *encrypted_message;
  for (int i = 0; i < len_message; i++){
    encrypted_message[i] = ( ( ( i % 0xFF ) | 0xA0) ^ ( (2 * * message[i]) | 1 ) )
  }
  return encrypted_message;
}
int main() {
int len_message;
int *ptr;
string flag[32] = "mxalÝ~e~GjOÌ÷ÊshUBSÜ×ÔkìÛÒá mÞÑÂ";
printf("The encypted flag is: \"");
printing (flag, 32);
printf("\"\n\n");
len_message = strlen("my message");
printf("encrypt(\"%s\", %ld) == \"", "my message", len_message);
ptr = encrypt ("my message", len_message);
printing (ptr, len_message);
printf("\"\n");
free(ptr);
return 0;
}
Преобразуй в нормальный код

После ответа ChatGPT и незначительных поправок получился вот такой код

C++:

#include 
#include 
using namespace std;
void printing(const char* message, int len_message) {
    for (int i = 0; i < len_message; i++) {
        printf("\\x%02x", static_cast(message[i]));
    }
}
char* encrypt(const char* message, int len_message) {
    char* encrypted_message = new char[len_message];
    for (int i = 0; i < len_message; i++) {
        encrypted_message[i] = (((i % 0xFF) | 0xA0) ^ ((message[i] << 1) | 1));
    }
    return encrypted_message;
}
int main() {
    const char flag[] = "mxalÝ~e~GjOÌ÷ÊshUBSÜ×ÔkìÛÒá mÞÑÂ";
    printf("The encypted flag is: \"");
    printing(flag, 32);
    printf("\"\n\n");
    const char* message = "my message";
    int len_message = strlen(message);
    printf("encrypt(\"%s\", %d) == \"", message, len_message);
    char* encrypted_message = encrypt(message, len_message);
    printing(encrypted_message, len_message);
    printf("\"\n");
    delete[] encrypted_message;
    return 0;
}

Он нормально скомпилировался и запустился. Вывод получившейся программы идентичен выводу первоначальной программы, так что можно считать, что мы успешно восстановили исходный код.

e5c6ce4fbd539f16374a0cff6aa674a0.png

Заключение:

Мы успешно выполнили реверс-инжиниринг программы, восстановили ее исходный код, и на основе полученных данных смогли написать скрипт-дешифратор.
Также хочу отметить, что в задачах с восстановлением исходного кода может помочь искусственный интеллект, что поможет реверсеру сохранить немного (а, может, и много) драгоценного времени.

Спасибо за интерес, проявленный к данной статье!
Если у вас возникли какие-либо замечания или вопросы, буду рад ответить в комментариях. До новых встреч!

© Habrahabr.ru