[Из песочницы] Создание упаковщика x86_64 ELF файлов под linux
Введение
В данном посте будет описано создание простого упаковщика исполняемых файлов под linux x86_64. Предполагается, что читатель знаком с языком программирования си, языком ассемблера для архитектуры x86_64 и с устройством ELF файлов. В целях обеспечения ясности из приведённого в статье кода была убрана обработка ошибок и не были показаны реализации некоторых функций, с полным кодом можно ознакомится перейдя по ссылкам на github (загрузчик, упаковщик).
Идея состоит в следующем — мы передаём упаковщику ELF файл, на выходе получаем новый со следующей структурой:
Для сжатия было решено использовать алгоритм Хаффмана, для шифрования — AES-CTR с 256-битным ключём, а именно реализацию от kokke tiny-AES-c. 256 байт случайных данных используются для инициализации AES ключа и вектора инициализации при помощи генератора псевдо-случайных чисел, как показано ниже:
for(int i = 0; i < 32; i++) {
seed = (1103515245*seed + 12345) % 256;
key[i] = buf[seed];
}
Данное решение было вызвано желанием усложнить реверс-инжиниринг. К настоящему моменту я понял, что усложнение это незначительно, но убирать его не стал, так как мне не хотелось тратить на это силы и время.
Загрузчик
Сначала будет рассмотрена работа загрузчика. Загрузчик не должен иметь никаких зависимостей, поэтому все нужные функции из стандартной библиотеки си придётся писать самостоятельно (реализация данных функций доступна по ссылке). Также он должен быть позиционно-независимым.
Функция _start
Загрузчик стартует из функции _start, которая просто передаёт argc и argv в main:
.extern main
.globl _start
.text
_start:
movq (%rsp), %rdi
movq %rsp, %rsi
addq $8, %rsi
call main
Функция main
Файл main.c начинается с определения нескольких extern переменных:
extern void* loader_end; // Указатель на конец загрузчика, т.е на начало
// упакованного ELF файла.
extern size_t payload_size; // Размер упакованного ELF файла
extern size_t key_seed; // Начальное значение для генератора
// псевдо-случайных чисел для ключа.
extern size_t iv_seed; // Начальное значение для генератора
// псевдо-случайных чисел для вектора инициализации
Все они объявлены, как extern для того, чтобы в упаковщике находить положение соответствующих переменным символов (Elf64_Sym) и изменять их значения.
Сама функция main достаточно проста. Первым делом инициализируются указатели на упакованный ELF файл, 256-байтный буффер и на вершину стека. Затем расшифровывается и разжимается ELF файл, далее он помещается в нужное место в памяти с помощью функциии load_elf, и, наконец, значение регистра rsp возвращается к первоначальному состоянию, и происходит прыжок на точку входа в программу:
#define SET_STACK(sp) __asm__ __volatile__ ("movq %0, %%rsp"::"r"(sp))
#define JMP(addr) __asm__ __volatile__ ("jmp *%0"::"r"(addr))
int main(int argc, char **argv) {
uint8_t *payload = (uint8_t*)&loader_end; // указатель на упакованный
// ELF файл
uint8_t *entropy_buf = payload + payload_size; // указатель на 256-байтный
// буффер
void *rsp = argv-1; // указатель на вершину стека
struct AES_ctx ctx;
AES_init_ctx_iv(&ctx, entropy_buf, key_seed, iv_seed); // инициализация AES
AES_CTR_xcrypt_buffer(&ctx, payload, payload_size); // расшифровка ELF
memset(&ctx, 0, sizeof(ctx)); // обнуление состояния AES
size_t decoded_payload_size;
// расжатие ELF
char *decoded_payload =
huffman_decode((char*)payload, payload_size, &decoded_payload_size);
// Получение адреса для загрузки ELF в пямять,
// в случае ET_EXEC возвращает NULL.
void *load_addr = elf_load_addr(rsp, decoded_payload, decoded_payload_size);
load_addr = load_elf(load_addr, decoded_payload); // Загружает ELF в память,
// возвращает точку входа
// в программу.
memset(decoded_payload, 0, decoded_payload_size); // Обнуление расжатого ELF
munmap(decoded_payload, decoded_payload_size); // Освобождение пямяти
// из под него
// Шифрование ELF обратно и обнуление состояния AES
AES_init_ctx_iv(&ctx, entropy_buf, key_seed, iv_seed);
AES_CTR_xcrypt_buffer(&ctx, payload, payload_size);
memset(&ctx, 0, sizeof(ctx));
SET_STACK(rsp); // Восстанавливаем значение стека
JMP(load_addr); // Переходим к точке входа в программу
}
Обнуление состояния AES и расжатого ELF файла, производится в целях безопасности — чтобы ключ и расшифрованные данные содержались в памяти лишь на время использования.
Далее будут рассмотрены реализации некоторых функций.
load_elf
Данную функцию я взял у пользователя github с ником bediger из его репозитория userlandexec и доработал её, так как оригинальная функция давала сбой на файлах типа ET_DYN. Сбой происходил из-за того, что значение первого аргумента системного вызова mmap устанавливалось равным NULL, и возвращался адрес достаточно близкий к основной программе, при последующих вызовах mmap и копировании сегментов по возвращённым ими адресам, код основной программы затирался, и происходил segfault. Поэтому было решено добавить начальный адрес в качестве параметра функции load_elf. Сама функция проходится по всем заголовкам программы, выделяет память (её количество должно быть кратно размеру страницы) для PT_LOAD сегментов ELF файла, копирует их содержимое в выделенные области памяти и задаёт данным областям соответствующие права чтения, записи, исполнения:
// Округление вверх до размера страницы
#define PAGEUP(x) (((unsigned long)x + 4095)&(~4095))
// Округление вниз до размера страницы
#define PAGEDOWN(x) ((unsigned long)x&(~4095))
void* load_elf(void *load_addr, void *mapped) {
Elf64_Ehdr *ehdr = mapped;
Elf64_Phdr *phdr = mapped + ehdr->e_phoff;
void *text_segment = NULL;
unsigned long initial_vaddr = 0;
unsigned long brk_addr = 0;
for(size_t i = 0; i < ehdr->e_phnum; i++, phdr++) {
unsigned long rounded_len, k;
void *segment;
// Если не PT_LOAD, ничего не делаем
if(phdr->p_type != PT_LOAD)
continue;
if(text_segment != 0 && ehdr->e_type == ET_DYN) {
// Для ET_DYN phdr->p_vaddr содержит относительный виртуальный адрес,
// для получения абсолютного виртуального адреса нужно прибавить
// к нему базовый адрес, равный разности абсолютного и относительного
// виртуальных адресов первого сегмента
load_addr = text_segment + phdr->p_vaddr - initial_vaddr;
load_addr = (void*)PAGEDOWN(load_addr);
} else if(ehdr->e_type == ET_EXEC) {
// Для ET_EXEC phdr->p_vaddr содержит абсолютный виртуальный адрес
load_addr = (void*)PAGEDOWN(phdr->p_vaddr);
}
// Размер сегмента должен быть кратен размеру страницы
rounded_len = phdr->p_memsz + (phdr->p_vaddr % 4096);
rounded_len = PAGEUP(rounded_len);
// Выделение необходимого количества памяти по заданному адресу
segment = mmap(load_addr,
rounded_len,
PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED,
-1,
0);
if(ehdr->e_type == ET_EXEC)
load_addr = (void*)phdr->p_vaddr;
else
load_addr = segment + (phdr->p_vaddr % 4096);
// Копируем данные в только что выделенную область памяти
memcpy(load_addr, mapped + phdr->p_offset, phdr->p_filesz);
if(!text_segment) {
text_segment = segment;
initial_vaddr = phdr->p_vaddr;
}
unsigned int protflags = 0;
if(phdr->p_flags & PF_R)
protflags |= PROT_READ;
if(phdr->p_flags & PF_W)
protflags |= PROT_WRITE;
if(phdr->p_flags & PF_X)
protflags |= PROT_EXEC;
mprotect(segment, rounded_len, protflags); // Задание прав
// чтения, записи, исполнения
k = phdr->p_vaddr + phdr->p_memsz;
if(k > brk_addr) brk_addr = k;
}
if (ehdr->e_type == ET_EXEC) {
brk(PAGEUP(brk_addr));
// Для ET_EXEC ehdr->e_entry содержит абсолютный виртульный адрес
load_addr = (void*)ehdr->e_entry;
} else {
// Для ET_DYN ehdr->e_entry содержит относительный виртуальный адрес,
// для получения абсолютного адреса нужно прибавить к нему базовый адрес
load_addr = (void*)ehdr + ehdr->e_entry;
}
return load_addr; // Возвращаем адрес точки входа в программу
}
elf_load_addr
Данная функция для ET_EXEC ELF файлов возвращает NULL, так как файлы данного типа должны располагаться по определённым в них адресах. Для ET_DYN файлов сначала вычисляется адрес равный разности базового адреса основной программы (т.е. загрузчика), количества памяти, необходимого для размещения ELF в памяти, и 4096, 4096 — зазор нужный для того, чтобы не располагать ELF файл впритык с основной программой. После вычисления данного адреса проверяется пересекается ли область памяти, от данного адреса до базового адреса основной программы, с областью, от начала распакованного ELF файла до его конца. В случае пересечения возвращается адрес равный разнице адреса начала распакованного ELF и количества памяти, необходимого для его размещения, иначе возвращается ранее вычисленный адрес.
Базовый адрес программы находится с помощью извлечения адреса программных заголовков из вспомогательного вектора (ELF auxiliary vector), который находится после указателей на переменные среды в стеке, и вычитании из него размера заголовка ELF:
адрес содержимое размер в байтах
---------------------------------------------------------------------------
Указатель на стек -> [ argc ] 8
[ argv[0] ] 8
[ argv[1] ] 8
[ argv[..] ] 8 * x
[ argv[n – 1] ] 8
[ argv[n] ] 8 (= NULL)
[ envp[0] ] 8
[ envp[1] ] 8
[ envp[..] ] 8
[ envp[term] ] 8 (= NULL)
[ auxv[0] (Elf64_auxv_t) ] 16
[ auxv[1] (Elf64_auxv_t) ] 16
[ auxv[..] (Elf64_auxv_t) ] 16
[ auxv[term] (Elf64_auxv_t) ] 16 (= AT_NULL)
[ выравнивание ] 0 - 16
[ аргументы командной строки ] >= 0
[ переменные среды ] >= 0
[ обозначение конца ] 8 (= NULL)
< нижняя часть стека > 0
---------------------------------------------------------------------------
Структура, которой описывается каждый элемент вспомогательного вектора имеет вид:
typedef struct {
uint64_t a_type; // Тип записи
union {
uint64_t a_val; // Значение
} a_un;
} Elf64_auxv_t;
Одним из допустимых значений a_type является AT_PHDR, a_val в таком случае будет указывать на заголовки программы. Далее приведён код функции elf_load_addr:
void* elf_base_addr(void *rsp) {
void *base_addr = NULL;
unsigned long argc = *(unsigned long*)rsp;
char **envp = rsp + (argc+2)*sizeof(unsigned long); // Указатель на первую
// переменную среды
while(*envp++); // Проходимся по всем указателям на переменные среды
Elf64_auxv_t *aux = (Elf64_auxv_t*)envp; // Первая запись вспомогательного
// вектора
for(; aux->a_type != AT_NULL; aux++) {
// Если текущая запись содержит адрес заголовков программы
if(aux->a_type == AT_PHDR) {
// Вычитаем размер ELF заголовка, так как обычно заголовки
// программы располагаются срузу после него
base_addr = (void*)(aux->a_un.a_val – sizeof(Elf64_Ehdr));
break;
}
}
return base_addr;
}
size_t elf_memory_size(void *mapped) {
Elf64_Ehdr *ehdr = mapped;
Elf64_Phdr *phdr = mapped + ehdr->e_phoff;
size_t mem_size = 0, segment_len;
for(size_t i = 0; i < ehdr->e_phnum; i++, phdr++) {
if(phdr->p_type != PT_LOAD)
continue;
segment_len = phdr->p_memsz + (phdr->p_vaddr % 4096);
mem_size += PAGEUP(segment_len);
}
return mem_size;
}
void* elf_load_addr(void *rsp, void *mapped, size_t mapped_size) {
Elf64_Ehdr *ehdr = mapped;
if(ehdr->e_type == ET_EXEC)
return NULL;
size_t mem_size = elf_memory_size(mapped) + 0x1000;
void *load_addr = elf_base_addr(rsp);
if(mapped < load_addr && mapped + mapped_size > load_addr - mem_size)
load_addr = mapped;
return load_addr - mem_size;
}
Описание скрипта компоновщика
Необходимо определить символы для описанных выше extern переменных, а также сделать так, что бы код и данные загрузчика после компиляции находились в одной секции .text. Это нужно для удобного извлечения машинного кода загрузчика, простым вырезанием содержимого данной секции из файла. Для достижения данных целей был написан следующий скрипт компоновщика:
ENTRY(_start)
SECTIONS {
. = 0;
.text :{
*(.text) *(.text.startup) *(.data) *(.rodata)
payload_size = .;
QUAD(0)
key_seed = .;
QUAD(0)
iv_seed = .;
QUAD(0)
loader_end = .;
}
}
Стоит пояснить, что QUAD (0) размещает 8 байт нулей, вместо которых упаковщик будет подставлять конкретные значения. Для вырезания машинного кода была написана небольшая утилита, которая также записывает в начало машинного кода смещение точки входа в загрузчик от начала загрузчика, смещения значений символов payload_size, key_seed и iv_seed от начала загрузчика. Код данной утилиты доступен по ссылке. На этом описание загрузчика оканчивается.
Непосредственно упаковщик
Рассмотрим функцию main упаковщика. В ней используются два аргумента командной строки: имя входного файла — argv[1] и имя выходного файла — argv[2]. Сначала, в пямять отображается входной файл и проверяется на совместимость с упаковщиком. Упаковщик работает только с двумя типами ELF файлов: ET_EXEC и ET_DYN, причём только со статически скомпанованными. Причиной введения данного ограничения был тот факт, что на различных linux системах стоят различные версии разделяемых библиотек, т.е. вероятность того, что динамически скомпанованная программа не запустится на отличной от родительской системе, достаточно велика. Соответствующий код в функции main:
size_t mapped_size;
void *mapped = map_file(argv[1], &mapped_size);
if(check_elf(mapped) < 0)
return 1;
После этого, если входной файл прошёл проверку на совместимость, он сжимается:
size_t comp_size;
uint8_t *comp_buf = huffman_encode(mapped, &∁_size);
Далее генерируется состояние AES, происходит шифрование сжатого ELF файла. Состояние AES определяется следующей структурой:
#define AES_ENTROPY_BUFSIZE 256
typedef struct {
uint8_t entropy_buf[AES_ENTROPY_BUFSIZE]; // 256-байтный буффер
size_t key_seed; // начальное значение генератора для ключа
size_t iv_seed; // начальное значение генератора для ветора инициализации
struct AES_ctx ctx; // состояние AES-CTR
} AES_state_t;
Соответствующий код в main:
AES_state_t aes_st;
for(int i = 0; i < AES_ENTROPY_BUFSIZE; i++)
state.entropy_buf[i] = rand() % 256;
state.key_seed = rand();
state.iv_seed = rand();
AES_init_ctx_iv(&state.ctx, state.entropy_buf, state.key_seed, state.iv_seed);
AES_CTR_xcrypt_buffer(&aes_st.ctx, comp_buf, comp_size);
После этого инициализируется структура, хранящая информацию о загрузчике, в загрузчике изменяются значения payload_size, key_seed и iv_seed на сгенерированные в предыдущем шаге, после чего состояние AES обнуляется. Информация о загрузчике хранится в следующей структуре:
typedef struct {
char *loader_begin; // Указатель на машинный код загрузчика
size_t entry_offset; // Смещение точки входа от начала загрузчика
size_t *payload_size_patch_offset; // Смещение значения размера упакованного
// ELF от начала загрузчика
size_t *key_seed_pacth_offset; // Смещение значения начального значения
// генератора для ключа от начала загрузчика
size_t *iv_seed_patch_offset; // Смещение значения начального значения
// генератора для вектора инициализации
// от начала загрузчика
size_t loader_size; // Размер машинного кода загрузчика
} loader_t;
Соответствующий код в main:
loader_t loader;
init_loader(&loader);
*loader.payload_size_patch_offset = comp_size;
*loader.key_seed_pacth_offset = aes_st.key_seed;
*loader.iv_seed_patch_offset = aes_st.iv_seed;
memset(&aes_st.ctx, 0, sizeof(aes_st.ctx));
В заключительной части мы создаём выходной файл, записываем в него ELF заголовок, один заголовок программы, код загрузчика, сжатый и зашифрованный ELF файл и 256-байтный буффер:
int out_fd = open(argv[2], O_WRONLY | O_CREAT | O_TRUNC, 0755); // Создаём
// выходной файл
write_elf_ehdr(out_fd, &loader); // Записываем ELF заголовок
write_elf_phdr(out_fd, &loader, comp_size); // Записываем заголовок программы
write(out_fd, loader.loader_begin, loader.loader_size); // Записываем загрузчик
write(out_fd, comp_buf, comp_size); // Записываем сжатый и зашифрованный ELF
write(out_fd, aes_st.entropy_buf, AES_ENTROPY_BUFSIZE); // Записываем
// 256-байтный буффер
На этом основной код упаковщика заканчивается, далее будут рассмотрены следующие функции: функция инициализации информации о загрузчике, функция записи ELF заголовка и функции записи заголовка программы.
Инициализация информации о загрузчике
Машинный код загрузчика встраивается в исполняемый файл упаковщика с помощью приведённого ниже простого кода:
.data
.globl _loader_begin
.globl _loader_end
_loader_begin:
.incbin "loader"
_loader_end:
Для того чтобы определить его адрес в памяти, в файле main.c объявляются следующие переменные:
extern void* _loader_begin;
extern void* _loader_end;
Далее рассмотрим функцию init_loader. Сперва в ней последовательно считываются следующие значения: смещение точки входа от начала загрузчика (entry_offset), смещение значения размера упакованного ELF файла от начала загрузчика (payload_size_patch_offset), смещение начального значения генератора для ключа от начала загрузчика (key_seed_patch_offset), смещение начального значения генератора для вектора инициализации от начала загрузчика (iv_seed_patch_offset). Затем к трём последним значениям прибавляется адрес загрузчика, таким образом при разыменовывании указателей и присваивании им значений мы будем заменять нули присвоенные на этапе компоновки (QUAD (0)) на нужные нам значения.
void init_loader(loader_t *l) {
void *loader_begin = (void*)&_loader_begin;
l->entry_offset = *(size_t*)loader_begin;
loader_begin += sizeof(size_t);
l->payload_size_patch_offset = *(void**)loader_begin;
loader_begin += sizeof(void*);
l->key_seed_pacth_offset = *(void**)loader_begin;
loader_begin += sizeof(void*);
l->iv_seed_patch_offset = *(void**)loader_begin;
loader_begin += sizeof(void*);
l->payload_size_patch_offset = (size_t)l->payload_size_patch_offset +
loader_begin;
l->key_seed_pacth_offset = (size_t)l->key_seed_pacth_offset +
loader_begin;
l->iv_seed_patch_offset = (size_t)l->iv_seed_patch_offset +
loader_begin;
l->loader_begin = loader_begin;
l->loader_size = (void*)&_loader_end - loader_begin;
}
write_elf_ehdr
void write_elf_ehdr(int fd, loader_t *loader) {
// Инициализация ELF заголовка
Elf64_Ehdr ehdr;
memset(ehdr.e_ident, 0, sizeof(ehdr.e_ident));
memcpy(ehdr.e_ident, ELFMAG, SELFMAG);
ehdr.e_ident[EI_CLASS] = ELFCLASS64;
ehdr.e_ident[EI_DATA] = ELFDATA2LSB;
ehdr.e_ident[EI_VERSION] = EV_CURRENT;
ehdr.e_ident[EI_OSABI] = ELFOSABI_NONE;
ehdr.e_type = ET_DYN;
ehdr.e_machine = EM_X86_64;
ehdr.e_version = EV_CURRENT;
ehdr.e_entry = sizeof(Elf64_Ehdr) +
sizeof(Elf64_Phdr) +
loader->entry_offset;
ehdr.e_phoff = sizeof(Elf64_Ehdr);
ehdr.e_shoff = 0;
ehdr.e_flags = 0;
ehdr.e_ehsize = sizeof(Elf64_Ehdr);
ehdr.e_phentsize = sizeof(Elf64_Phdr);
ehdr.e_phnum = 1;
ehdr.e_shentsize = sizeof(Elf64_Shdr);
ehdr.e_shnum = 0;
ehdr.e_shstrndx = 0;
write(fd, &ehdr, sizeof(ehdr)); // Записываем заголовок в файл
return 0;
}
Здесь происходит стандартная инициализация ELF заголовка и последующая запись его в файл, единственное на что следует обратить внимание — тот факт, что в ET_DYN ELF файлах сегмент, описываемый первым заголовком программы, включает в себя не только исполняемый код, но и ELF заголовок и все заголовки программы. Поэтому его смещение относительно начала должно быть равно нулю, размер складываться из размера ELF заголовка, всех заголовков программы и исполняемого кода, а точка входа определяться, как сумма размера ELF заголовка, размера всех заголовков программы и смещения относительно начала исполняемого кода.
write_elf_phdr
void write_elf_phdr(int fd, loader_t *loader, size_t payload_size) {
// Инициализация заголовка программы
Elf64_Phdr phdr;
phdr.p_type = PT_LOAD;
phdr.p_offset = 0;
phdr.p_vaddr = 0;
phdr.p_paddr = 0;
phdr.p_filesz = sizeof(Elf64_Ehdr) +
sizeof(Elf64_Phdr) +
loader->loader_size +
payload_size +
AES_ENTROPY_BUFSIZE;
phdr.p_memsz = phdr.p_filesz;
phdr.p_flags = PF_R | PF_W | PF_X;
phdr.p_align = 0x1000;
write(fd, &phdr, sizeof(phdr)); // Записываем заголовок программы в файл
}
Здесь происходит инициализация заголовка программы и последующая запись его в файл. Следует обратить внимание на смещение относительно начала файла и размер сегмента, описываемого заголовком программы. Как было описано в предыдущем абзаце сегмент, описываемый данным заголовком, включает в себя не только исполняемый код, но и ELF заголовок и заголовок программы. Также мы делаем сегмент с исполняемым кодом дооступным для записи, это связано с тем, что реализации AES, используемая в загрузчике шифрует и дешифрует данные «на месте».
Некоторые факты о работе упаковщика
При тестировании было замечено, что программы статически скомпанованные с glibc, при запуске уходят в segfault, на данной инструкции:
movq %fs:0x28, %rax
Почему так происходит, мне выяснить не удалось, буду рад, если поделитесь информацией на этот счёт. Вместо glibc можно использовать musl-libc, с ним всё работает без сбоев. Также упаковщик тестировался со статически собранными golang программами, например, http сервером. Для полной статической сбоки golang программ, необходимо использовать следующие флаги:
CGO_ENABLED=0 go build -a -ldflags '-extldflags "-static"' .
Последнее, с чем тестировался упаковщик — ET_DYN ELF файлы без динамического компоновщика. Правда, при работе с данными файлами, может сбоить функция elf_load_addr. На практике, её можно вырезать из загрузчика и использовать фиксированный адрес, например 0×10000.
Заключение
Данный упаковщик, очевидно, использовать по назначению смысла не имеет, так как файлы защищённые им довольно легко расшифровываются. Задачей данного проекта было лучшее освоение работы с ELF файлами, практика их генерации, а также подготовка к созданию более полноценного упаковщика.