Всем привет!
Я бы хотел создать цикл статей, который будет посвящен созданию слоя совместимости для запуска Windows приложений на ОС семейства Linux. При этом хочу сделать акцент на реализации собственного формата исполняемого файла и использования метода дистилляции для перевода программного кода из формата в формат. Это очень обширная тема, чтобы уместить её в одну статью, а также достаточно сложная для понимания «извне», поэтому я буду стараться излагать всё на доступном языке и подходить к этой тематике, так скажем, издалека.
В этой статье мы посмотрим, как написать код и запустить его не самым тривиальным и "велосипедным" способом.
Всю работу мы будем делать на Linux с архитектурой процессора x86-64 (AMD64), а также использовать инструменты: gcc, NASM и IDA.
Наш план:
Напишем самую простую программу, которую только можно вообразить.
Запустим её "ручками".
Придумаем и опишем свой формат исполняемого файла.
Упакуем программу и запустим её через импровизированный загрузчик.
Profit!
1. По настоящему самая простая программа
В качестве тестовой программы почти всегда на ум приходит "Hello, world!", но, на данный момент, работа с ней будет очень сложной, поэтому мы начнём с более простого примера и напишем программу, которая просто завершится не сделав ничего полезного :-)
При этом, мы еще больше упростим задачу и сделаем это на языке ассемблера используя ассемблер NASM.
section .text global _start _start: mov eax, 74 ret
Итак, мы имеем код для ассемблера NASM, в первых двух строках которого мы говорим, что в нашей секции (название для блока данных внутри исполняемого файла) .text (там стандартно содержится исполняемый машинный код) будет доступна функция _start. Она имеет такое название, потому как именно по этому названию NASM будет понимать, где находится точка входа (entry point, место, откуда начнётся исполнение) в нашей программе. Конечно, можно было бы написать иное название, например привычное main, но тогда это явно потребуется указать NASM'у. Далее, в 4-й строке, мы объявляем нашу точку входа для _start. В 5-й строке мы помещаем значение 74 в регистр EAX (регистры это такие, так скажем, переменные в памяти процессора, своеобразные кармашки для данных, с которыми происходит работа на данный момент). И, наконец, в 6-й строке мы делаем return, то есть выходим из функции.
Стандартом принято, что регистр EAX используется для передачи возвращаемого значения, поэтому функция _start просто возвращает значение 74, а так как это самая главная функция в нашей программе и возвращаться некуда — вся программа завершается с этим кодом.
Теперь преобразуем наш код в объектный файл (промежуточный тип файла между файлом с кодом и исполняемым файлом).
> nasm -f elf64 test.asm -o test.o
Предлагаю оставить нашу программу в виде объектного файла и посмотреть, что стало с кодом. Для этого воспользуемся графическим дизассемблером под названием IDA.

Здесь мы видим тот же самый код, но самое интересное для нас появится, когда мы посмотрим на код в режиме Hex View.

Это, опять же, наш код, но представленный в виде машинного байт-кода (низкоуровневое представление кода, которое понятно процессору). Здесь присутствуют 2 команды:
B8 4A 00 00 00 — B8, это команда переместить последующее 4-х байтовое значение в регистр EAX, а 4A 00 00 00, это наше 4-х байтовое значение в формате LE (представление многобайтовых данных Little-Endian, это когда байты идут в обратном порядке, то есть наше значение на самом деле равно 0x0000004A), которое представляет из себя число 74.
C3 — return:)
2. Не самый простой запуск самой простой программы
Теперь мы возьмем этот байт-код и попробуем запустить его "самостоятельно". Для этого мы напишем небольшую программу на языке C.
int main() { char program_data[] = { 0xB8, 0x4A, 0x00, 0x00, 0x00, 0xC3 }; int (*program)() = (int (*) ())program_data; int result = program(); return result; }
Что мы тут делаем?
Мы создали 6-ти байтовый массив и заполнили эти 6 байт машинным байт-кодом нашей программы, потом привели этот массив к типу функции, а потом вызвали эту функцию и завершили всю программу с кодом, равным её возвращаемому значению.
Скомпилируем и запустим.
> gcc main.c -o main.out > ./main.out < segmentation fault (core dumped) ./main.out
Упс, мы получаем любимую "сегу" (segmentation fault, ошибка сегментации, это когда программа нарушает что-то при работе с памятью), что означает, что мы явно что-то сделали не так. Но что именно?
Всё дело в том, что ОС реализует механизм модификаторов доступа (права на выполнение каких-то операций) к памяти, он, можно сказать, такой же как у файлов: чтение, запись и исполнение. Иными словами, не все операции можно делать со всеми данными, что, на самом-то деле, вообще прекрасно, иначе мы бы достаточно часто и быстро убивали систему своими кривыми ручками :D
Так вот, компилятор при создании исполняемого файла, записывает информацию, которая при запуске задает для нашего массива модификаторы доступа на чтение и запись, но не на исполнение, что, думаю, логично, кто в здравом уме будет запускать массив?
Для решения данной проблемы мы будем использовать один из главным механизмов в ОС, без которого не обходится ни один запуск программы, так как именно он отображает (копирует) файл в память перед исполнением. Прошу любить и жаловать — mmap!
Эта функция, а точнее системный вызов (syscall, специальная функция-прослойка для работы в режиме ядра ОС), похожа на привычные функции выделения динамической памяти, такие как: malloc, calloc и realloc. Но эта функция имеет гораздо больше возможностей, хотя, всё же, самое главное для нас сейчас — она позволяет задать модификаторы доступа к памяти самостоятельно.
Теперь изменим нашу программу и превратим её в это:
#include <sys/mman.h> #include <string.h> int main() { char program_data[] = { 0xB8, 0x4A, 0x00, 0x00, 0x00, 0xC3 }; char* program_mmaped = mmap((void*)0, sizeof(program_data), PROT_READ | PROT_WRITE | PROT_EXEC, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0); memcpy(program_mmaped, program_data, sizeof(program_data)); int (*program)() = (int (*) ())program_mmaped; int result = program(); return result; }
Теперь мы добавили выделение памяти с помощью mmap с модификаторами доступа RWX (Read, Write, Execute), после в эту память мы скопировали наш байт-код и проделали те же манипуляции, что и в прошлый раз. Кстати, MAP_ANONYMOUS это флаг, который указывает, что выделенная память не привязана к дескриптору файла, то есть, другими словами, что это просто память, а не отображенный файл. А MAP_PRIVATE означает, что выделенная область памяти будет существовать только для нашего процесса.
Проверим, что будет теперь...
> gcc main.c -o main.out > ./main.out > echo $? < 74
Шалость удалась!
Скажу честно, когда этот трюк я сделал в первый раз, то сначала не поверил, а потом посмотрел на всё программирование другими глазами. Всё-таки, когда человек исполняет массив, его жизнь делится на до и после :D
3. Придумываем свой формат исполняемого файла
Назовем наш супер-крутой формат SEF — Simple Executable File.

В нём не будет ничего кроме магического значения (специальная последовательность первых байт файла, чтобы можно было определить его тип, например в Windows исполняемые файлы имеют магическое значение MZ, а в Linux — ELF), смещения в байтах от начала файла до точки входа и данных, то есть кода.
В целом, всё, что нам потребуется для работы с таким форматом, это такая структурка:
typedef struct _SEF_HEADER { char magic[4]; unsigned int entry; } SEF_HEADER;
4. Пишем утилиту для создания и запуска нашего формата
Здесь, думаю, все просто, поэтому быстренько накидываем код программы для запуска программы:)
И получаем что-то такое:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <fcntl.h> #include <sys/mman.h> #include <sys/stat.h> typedef struct _SEF_HEADER { char magic[4]; unsigned int entry; } SEF_HEADER; int create_new_sef_file() { char filename_buffer[512 + 1] = ""; printf("Enter new SEF filename (max 512 symbols): "); fgets(filename_buffer, sizeof(filename_buffer), stdin); filename_buffer[strcspn(filename_buffer, "\n")] = 0; FILE* new_file = fopen(filename_buffer, "w+b"); if (new_file == NULL) { fprintf(stderr, "Can't open file '%s'!\n", filename_buffer); return -1; } char data_buffer[512 * 2 + 1] = ""; printf("Enter byte data without spaces (max 512 bytes): "); fgets(data_buffer, sizeof(data_buffer), stdin); filename_buffer[strcspn(filename_buffer, "\n")] = 0; unsigned short entry = 0; printf("Enter integer value as offset to entry point in data (2 bytes, unsigned): "); scanf("%hu", &entry); SEF_HEADER sef_header = { .magic = { 'S', 'E', 'F', 0 }, .entry = entry + sizeof(SEF_HEADER) }; fwrite(&sef_header, sizeof(sef_header), 1, new_file); for (size_t i = 0; i < strlen(data_buffer) / 2; ++i) { char byte = 0; sscanf(data_buffer + i * 2, "%2hhx", &byte); fwrite(&byte, sizeof(char), 1, new_file); } fclose(new_file); fprintf(stderr, "New SEF '%s' created!\n", filename_buffer); return 0; } int execute_sef_file(char* filename) { int sef_file = open(filename, O_RDWR); if (sef_file == -1) { fprintf(stderr, "Can't open '%s'!\n", filename); return -1; } struct stat sef_file_info = {0}; if (fstat(sef_file, &sef_file_info) == -1) { fprintf(stderr, "Can't get stats of '%s'!\n", filename); close(sef_file); return -1; } char* mmaped = mmap(NULL, sef_file_info.st_size, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE, sef_file, 0); if (mmaped == (char*)-1) { fprintf(stderr, "Can't mmap '%s'!\n", filename); close(sef_file); return -1; } close(sef_file); SEF_HEADER* sef_header = (SEF_HEADER*)mmaped; int (*entry_point) () = (int (*) ())(mmaped + sef_header->entry); int result = entry_point(); munmap(mmaped, sef_file_info.st_size); return result; } void exit_with_help(char* program_name, int code) { fprintf((code ? stderr : stdout), "Usage %s:\n" "%s -- create SEF\n" "%s <path> -- execute SEF\n" "%s --help -- print this message\n", program_name, program_name, program_name, program_name ); exit(code); } int main(int argc, char** argv) { if (argc > 2) { exit_with_help(argv[0], -1); } else if (argc == 1) { return create_new_sef_file(); } if (!strcmp(argv[1], "--help")) { exit_with_help(argv[0], 0); } return execute_sef_file(argv[1]); }
Само собой, этот код можно улучшать бесконечно, делая его функциональнее, быстрее, безопаснее и красивее, но, надеюсь, никто не обидится, если мы оставим его таким))
У нашей утилиты будет всего две функции: создание исполняемого файла в формате SEF и исполнение такого файла.
Для создания будем запрашивать имя файла, данные в виде байт без пробелов и сдвиг в байтах относительно введенных данных. Открываем/создаём файл в режиме бинарной записи, вписываем туда заголовок нашего формата, в котором магическим значением будет SEF\0, а сдвиг равен указанному сдвигу + размеру заголовка, ну, чтобы указывать куда нам нужно, ведь к данным добавился заголовок. Ну, а потом, в цикле переводим байты в виде символов в байты в виде байтов и записываем в файл.
Для запуска всё тоже просто — открыли файл, правда тут уже немного другой функцией — open, вместо fopen, так как от файла нам нужен только дескриптор для отображения его в память с помощью mmap. Определили размер файла с помощью получения информации о файле используя fstat. А дальше всё, практически, как и в прошлый раз, только мы указали дескриптор открытого файла, чтоб его отобразить в оперативной памяти. Сразу после отображения можно смело закрывать открытый дескриптор открытого файла, так как оригинал с диска нам больше не понадобится. И всё, запускаем приводя к типу функции.
Пробуем.
> gcc sef.c -o sef.out > ./sef.out --help < Usage ./sef.out: < ./sef.out -- create SEF < ./sef.out <path> -- execute SEF < ./sef.out --help -- print this message > ./sef.out < Enter new SEF filename (max 512 symbols): > test.sef < Enter byte data without spaces (max 512 bytes): > B84A000000C3 < Enter integer value as offset to entry point in data (2 bytes, unsigned): > 0 < New SEF 'test.sef' created! > ./sef.out test.sef > echo $? < 74
И это именно то, чего мы и добивались! Поздравляю!
Заключение
В целом, это всё, чем я хотел поделиться в данной статье.
Надеюсь, что это было полезно и увлекательно. А если нет — обязательно напишите об этом, я постараюсь не обидеться и прислушаться к критике :D
Спасибо за внимание!
