Всем привет!

Я бы хотел создать цикл статей, который будет посвящен созданию слоя совместимости для запуска 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

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


  1. punzik
    23.09.2025 13:29

    Не хватает последнего шага - зарегистрировать новый тип исполняемых файлов через binfmt_misc.


    1. TizZy Автор
      23.09.2025 13:29

      Согласен, очень хорошая идея, в следующих статьях обязательно сделаю. Спасибо!


  1. NightBlade74
    23.09.2025 13:29

    Нафига все эти пляски с ручным вводом байтов кода в консоль? NASM умеет формировать bin-файлы (безо всяких заголовков), которые можно использовать напрямую, как, к примеру, COM-файлы MS-DOS.

    Если уж очень хочется, то можно и заголовок прямо в NASM'е вставить инструкцией DB или DWORD, но надо понимать, что может поехать абсолютная адресация. Так что файл в этом случае в память надо загружать полностью с заголовком, чтобы адреса не сдвинулись.

    А еще binfmt_misc может исполнимые файлы не только по сигнатуре регистрировать, но и по расширению (в DOS/Windows стиле), так что можно прям те же COM-файлы без заголовка в Linux возродить.


    1. TizZy Автор
      23.09.2025 13:29

      Понимаю, но, в рамках этой статьи, мне хотелось сделать все максимально просто и доступно. А усложнять и углубляться можно долго:)
      Спасибо за комментарий!


      1. NightBlade74
        23.09.2025 13:29

        Ну так тут как раз проще и получается: NASM генерирует конечный исполнимый файл, а приложение должно уметь только загружать его и исполнять.


        1. TizZy Автор
          23.09.2025 13:29

          Мне просто кажется, что это чуть больше будет похоже на черный ящик. Хотелось чуть больше "ручками" повзаимодействовать и меньше использовать готовые утилиты, или, по крайней мере, не сильно погружаясь в их особенности и функционал.

          Но я согласен абсолютно — писать байты в консоль это жесть как муторно, зато наглядно! :D


    1. TemArtem
      23.09.2025 13:29

      Тоже об этом подумал, но с другой стороны, когда сам ручками вводишь байты, лучше чувствуешь что происходит
      Это как учиться водить на механике, а не на автомате, для понимания основ - бесценно


  1. Rigidus
    23.09.2025 13:29

    Если интересно как сделать теперь следующий шаг и добавить кроссплатформенности для SEF-файлов, могу подсказать - заодно выйдет еще одна понятная и интересная статья


    1. Vilos
      23.09.2025 13:29

      Вот это была бы "пушка"! Представляем себе исполняемый файл для любой ОС!...Я балдею.


      1. unreal_undead2
        23.09.2025 13:29

        Чтобы "для любой ОС", надо ещё системные вызовы абстрагировать, это отдельная задача.


        1. pvvv
          23.09.2025 13:29

          1. Rigidus
            23.09.2025 13:29

            Всем, кому интересна эта тема предлагается связаться со мной в телеграмме @RigidusRigidus - сделаем группу и обсудим как мы можем совместить эти подходы (включая и https://justine.lol/ape.html)


      1. TizZy Автор
        23.09.2025 13:29

        Туда и двигаемся, можно сказать)


    1. TizZy Автор
      23.09.2025 13:29

      Конечно интересно, жду)

      Вообще, этот SEF формат — быстрая импровизация на тему исполняемых файлов, в которой нет ничего особенно полезного. Цель была (и будет) в плавном введении в эту тематику, чтобы ребята, которые достаточно далеки от этого, смогли по-тихоньку собирать контекст из тучи непонятных слов:D

      А сам формат мы постепенно будем развивать как минимум до введения экспортов, импортов и релокаций. Ещё и научимся PE переводить в SEF. Напишем более правильный и сложный загрузчик. Без исполнения массива никуда не денемся и будем использовать похожий сценарий для формирования функций-заглушек при отсутствующем импорте.

      Короче, ещё много работы предстоит, поэтому я с удовольствием вбираю новые мысли и идеи!


  1. punhin
    23.09.2025 13:29

    Прочитав заголовок, подумал, что увижу информацию о том, как написать для линукса программу на Си/Си++, но внезапно увидел, что здесь ассемблер! Вау! Я такого ещё нигде не видел, спасибо огромное! Опять же, интересный кейс про регистрацию формата исполняемого файла, обязательно буду показывать своим студентам!


    1. TizZy Автор
      23.09.2025 13:29

      Спасибо большое!)


    1. TemArtem
      23.09.2025 13:29

      Плюсую, студентам такое точно зайдет, гораздо лучше сухой теории из учебников
      Когда сам видишь как из массива байт получается исполняемая программа, это производит впечатление


  1. pvvv
    23.09.2025 13:29

    Теперь мы добавили выделение памяти с помощью mmap с модификаторами доступа RWX (Read, Write, Execute), после в эту память мы скопировали наш байт-код

    mprotect(2) вроде может поменять RWX для существующей страницы, без дополнительного выделения и копирования. Про выравнивание и размер страниц только надо не забывать.


    1. TizZy Автор
      23.09.2025 13:29

      Если я не ошибаюсь, то mprotect не подойдет для нашего сценария, так как массив попадет в .data секцию и будет лишь её частью. А mprotect работает именно с целыми страницами, поэтому, как вы и сказали, потребуется выравнивание и учет размера страницы. Это приведет к изменению прав для гораздо большего диапазона адресов нежели нам нужно, что, я считаю, слишком излишним и более вредным, хоть это и требует меньше ресурсов.


      1. pvvv
        23.09.2025 13:29

        Ну mmap ведь тоже отнюдь не 6 байт выделит, а ту же выровененную ~4кБ страницу целиком.

        Единственная разница что mprotect также разрешит исполнение тому что уже рядом с program_data[] (вроде вообще на стэке в данном конкретном примере, что делает это ещё более "безопасным") в пределах страницы оказалось.

        Но при загрузке исполняемого кода с диска руками под него всё равно надо выделять память, так что да, нет смысла выделять её как попало и потом менять доступ на исполнение.

        вот тут https://habr.com/ru/articles/746658/ тоже самое через mprotect только потому что память под сгенерированный исполняемый код выделялась снаружи и лезть туда менять аллокатор на свой было лень.


        1. TizZy Автор
          23.09.2025 13:29

          Согласен с вами, бесспорно использовать mprotect в данном контексте было бы неплохо (хоть и пришлось бы углубиться в управление оперативной памятью), но тут важно будет сказать, что с помощью mmap мы добиваемся отделения исполняемой области, что, на мой взгляд, более правильно.

          Ну и если добавить контекст, то далее в статье идёт речь про отображение файла с помощью mmap, поэтому мне принципиально было задействовать этот системный вызов ранее, чтобы не добавлять новой информации и, тем самым, упростить статью:)


  1. unreal_undead2
    23.09.2025 13:29

    Перед исполнением динамически зачитанного/сгенерированного кода непохо ещё кеш инструкций сбросить (скажем, через __builtin___clear_cache). На x86 необязательно, а вот ARM может начать исполнять мусор.


    1. TizZy Автор
      23.09.2025 13:29

      Спасибо! Это действительно важное замечание, и я его обязательно учту в дальнейшем)


  1. space2pacman
    23.09.2025 13:29

    Это круто!


    1. TizZy Автор
      23.09.2025 13:29

      Спасибо! <3


  1. TemArtem
    23.09.2025 13:29

    Хорошая ламповая статья
    Трюк с mmap и исполнением массива это прям обряд посвящения в системщину, cразу начинаешь понимать что код и данные по сути одно и то же. Спасибо что напомнили


    1. TizZy Автор
      23.09.2025 13:29

      Спасибо за прочтение!:)


  1. douqu1ieNgu1In5ohv1v
    23.09.2025 13:29

    < segmentation fault (core dumped)

    Примечание: ELF файлу можно добавить exec-флаг для стэка и тогда такая программа должна заработать со стэка.
    Варианты как это можно сделать:

    • во время линковки с помощью флага -Wl,-zexecstack (gcc);

    • пропатчить готовый бинарник: execstack --set-execstack main.out

    Пример как посмотреть модификаторы доступа для стэка у ELF файла:

    readelf --program-headers 3_exec_stack/test | grep -A1 GNU_STACK
    #   GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000
    #                  0x0000000000000000 0x0000000000000000  RWE    0x10
    #                                                         ↑↑↑