Всем привет!

Я бы хотел создать цикл статей, который будет посвящен созданию слоя совместимости для запуска Windows приложений на ОС семейства Linux. При этом хочу сделать акцент на реализации собственного формата исполняемого файла и использования метода дистилляции для перевода программного кода из формата в формат. Это очень обширная тема, чтобы уместить её в одну статью, а также достаточно сложная для понимания «извне», поэтому я буду стараться излагать всё на доступном языке и подходить к этой тематике, так скажем, издалека.

В этой статье мы посмотрим, как написать код и запустить его не самым тривиальным и "велосипедным" способом.

Всю работу мы будем делать на Linux с архитектурой процессора x86-64 (AMD64), а также использовать инструменты: gcc, NASM и IDA.

Наш план:

  1. Напишем самую простую программу, которую только можно вообразить.

  2. Запустим её "ручками".

  3. Придумаем и опишем свой формат исполняемого файла.

  4. Упакуем программу и запустим её через импровизированный загрузчик.

  5. 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.

Просмотр в режиме IDA View
Просмотр в режиме IDA View

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

Просмотр в режиме Hex View
Просмотр в режиме Hex View

Это, опять же, наш код, но представленный в виде машинного байт-кода (низкоуровневое представление кода, которое понятно процессору). Здесь присутствуют 2 команды:

  1. B8 4A 00 00 00 — B8, это команда переместить последующее 4-х байтовое значение в регистр EAX, а 4A 00 00 00, это наше 4-х байтовое значение в формате LE (представление многобайтовых данных Little-Endian, это когда байты идут в обратном порядке, то есть наше значение на самом деле равно 0x0000004A), которое представляет из себя число 74.

  2. 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

Спасибо за внимание!

Комментарии (0)


  1. Ivan174z
    23.09.2025 13:29

    Круто! Как я сам не додумался до этого! Все гениальное просто!


  1. strok_ova
    23.09.2025 13:29

    Очень подробно написано. Не мой профиль, но даже я все поняла. Спасибо автору