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

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

NightBlade74
23.09.2025 13:29Нафига все эти пляски с ручным вводом байтов кода в консоль? NASM умеет формировать bin-файлы (безо всяких заголовков), которые можно использовать напрямую, как, к примеру, COM-файлы MS-DOS.
Если уж очень хочется, то можно и заголовок прямо в NASM'е вставить инструкцией DB или DWORD, но надо понимать, что может поехать абсолютная адресация. Так что файл в этом случае в память надо загружать полностью с заголовком, чтобы адреса не сдвинулись.
А еще binfmt_misc может исполнимые файлы не только по сигнатуре регистрировать, но и по расширению (в DOS/Windows стиле), так что можно прям те же COM-файлы без заголовка в Linux возродить.

TizZy Автор
23.09.2025 13:29Понимаю, но, в рамках этой статьи, мне хотелось сделать все максимально просто и доступно. А усложнять и углубляться можно долго:)
Спасибо за комментарий!
NightBlade74
23.09.2025 13:29Ну так тут как раз проще и получается: NASM генерирует конечный исполнимый файл, а приложение должно уметь только загружать его и исполнять.

TizZy Автор
23.09.2025 13:29Мне просто кажется, что это чуть больше будет похоже на черный ящик. Хотелось чуть больше "ручками" повзаимодействовать и меньше использовать готовые утилиты, или, по крайней мере, не сильно погружаясь в их особенности и функционал.
Но я согласен абсолютно — писать байты в консоль это жесть как муторно, зато наглядно! :D

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

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

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

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

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

TizZy Автор
23.09.2025 13:29Конечно интересно, жду)
Вообще, этот SEF формат — быстрая импровизация на тему исполняемых файлов, в которой нет ничего особенно полезного. Цель была (и будет) в плавном введении в эту тематику, чтобы ребята, которые достаточно далеки от этого, смогли по-тихоньку собирать контекст из тучи непонятных слов:D
А сам формат мы постепенно будем развивать как минимум до введения экспортов, импортов и релокаций. Ещё и научимся PE переводить в SEF. Напишем более правильный и сложный загрузчик. Без исполнения массива никуда не денемся и будем использовать похожий сценарий для формирования функций-заглушек при отсутствующем импорте.
Короче, ещё много работы предстоит, поэтому я с удовольствием вбираю новые мысли и идеи!

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

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

pvvv
23.09.2025 13:29Теперь мы добавили выделение памяти с помощью mmap с модификаторами доступа RWX (Read, Write, Execute), после в эту память мы скопировали наш байт-код
mprotect(2) вроде может поменять RWX для существующей страницы, без дополнительного выделения и копирования. Про выравнивание и размер страниц только надо не забывать.

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

pvvv
23.09.2025 13:29Ну mmap ведь тоже отнюдь не 6 байт выделит, а ту же выровененную ~4кБ страницу целиком.
Единственная разница что mprotect также разрешит исполнение тому что уже рядом с
program_data[](вроде вообще на стэке в данном конкретном примере, что делает это ещё более "безопасным") в пределах страницы оказалось.Но при загрузке исполняемого кода с диска руками под него всё равно надо выделять память, так что да, нет смысла выделять её как попало и потом менять доступ на исполнение.
вот тут https://habr.com/ru/articles/746658/ тоже самое через mprotect только потому что память под сгенерированный исполняемый код выделялась снаружи и лезть туда менять аллокатор на свой было лень.

TizZy Автор
23.09.2025 13:29Согласен с вами, бесспорно использовать mprotect в данном контексте было бы неплохо (хоть и пришлось бы углубиться в управление оперативной памятью), но тут важно будет сказать, что с помощью mmap мы добиваемся отделения исполняемой области, что, на мой взгляд, более правильно.
Ну и если добавить контекст, то далее в статье идёт речь про отображение файла с помощью mmap, поэтому мне принципиально было задействовать этот системный вызов ранее, чтобы не добавлять новой информации и, тем самым, упростить статью:)

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

TizZy Автор
23.09.2025 13:29Спасибо! Это действительно важное замечание, и я его обязательно учту в дальнейшем)

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 # ↑↑↑
Ivan174z
Круто! Как я сам не додумался до этого! Все гениальное просто!