Criando o empacotador de arquivo ELF x86_64 para linux

1. Introdução


Este post descreverá a criação de um empacotador de arquivos executável simples para linux x86_64. Supõe-se que o leitor esteja familiarizado com a linguagem de programação C, a linguagem assembly para a arquitetura x86_64 e os arquivos ELF do dispositivo. Para garantir a clareza, o tratamento de erros foi removido do código no artigo e as implementações de algumas funções não foram mostradas; o código completo pode ser encontrado clicando nos links para o github ( carregador , empacotador ).

A idéia é a seguinte: transferimos o arquivo ELF para o empacotador e obtemos um novo com a seguinte estrutura na saída:
Cabeçalho ELF
Título do programa
Segmento de códigoDownloader de arquivo ELF empacotado
Arquivo ELF empacotado
256 bytes de dados aleatórios

Para compactação, decidiu-se usar o algoritmo Huffman, para criptografia - AES-CTR com uma chave de 256 bits, ou seja, a implementação do kokke tiny-AES-c . 256 bytes de dados aleatórios são usados ​​para inicializar a chave AES e o vetor de inicialização usando um gerador de números pseudo-aleatórios, conforme mostrado abaixo:

for(int i = 0; i < 32; i++) { seed = (1103515245*seed + 12345) % 256; key[i] = buf[seed]; } 

Essa decisão foi causada pelo desejo de complicar a engenharia reversa. Até o momento, percebi que a complicação é insignificante, mas não comecei a removê-la, pois não queria gastar tempo e energia nela.

Carregador de inicialização


Primeiro, o trabalho do gerenciador de inicialização será considerado. O carregador não deve ter nenhuma dependência; portanto, todas as funções necessárias da biblioteca C padrão terão que ser escritas independentemente (a implementação dessas funções está disponível por referência ). Também deve ser posicionalmente independente.

Função _Start


O carregador de inicialização inicia a partir da função _start, que simplesmente passa argc e argv para main:

 .extern main .globl _start .text _start: movq (%rsp), %rdi movq %rsp, %rsi addq $8, %rsi call main 

Função principal


O arquivo main.c começa definindo várias variáveis ​​externas:

 extern void* loader_end; //    , .   //  ELF . extern size_t payload_size; //   ELF  extern size_t key_seed; //     // -   . extern size_t iv_seed; //     // -     

Todos eles são declarados como externos para encontrar a posição dos caracteres correspondentes às variáveis ​​(Elf64_Sym) no empacotador e alterar seus valores.

A função principal em si é bastante simples. A primeira etapa é inicializar os ponteiros para um arquivo ELF compactado, um buffer de 256 bytes e para o topo da pilha. Em seguida, o arquivo ELF é descriptografado e expandido, depois é colocado no lugar certo na memória usando a função load_elf e, finalmente, o valor do registro rsp retorna ao seu estado original e ocorre um salto no ponto de entrada do programa:

 #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); //       } 

A redefinição do estado do AES e do arquivo ELF descompactado é feita por motivos de segurança - para que a chave e os dados descriptografados sejam armazenados na memória apenas durante o uso.

A seguir, consideraremos a implementação de algumas funções.

load_elf


Peguei essa função do usuário do github com o apelido bediger de seu repositório userlandexec e a finalizei, pois a função original travava em arquivos como ET_DYN. A falha ocorreu devido ao fato de o valor do primeiro argumento da chamada do sistema mmap ter sido definido como NULL, e o endereço ter sido retornado bem próximo ao programa principal, durante chamadas subsequentes ao mmap e segmentos de cópia para os endereços retornados por eles, o código do programa principal foi substituído e ocorreu um segfault. Portanto, decidiu-se adicionar o endereço inicial como parâmetro à função load_elf. A própria função percorre todos os cabeçalhos do programa, aloca memória (seu número deve ser múltiplo do tamanho da página) para os segmentos PT_LOAD do arquivo ELF, copia seu conteúdo para as áreas de memória alocadas e define os direitos correspondentes de leitura, gravação e execução para essas áreas:

 //      #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


Essa função para arquivos ET_EXEC ELF retorna NULL, pois os arquivos desse tipo devem estar localizados nos endereços especificados neles. Para arquivos ET_DYN, o endereço igual à diferença entre o endereço base do programa principal (ou seja, o carregador de inicialização), a quantidade de memória necessária para colocar o ELF na memória e 4096, 4096 - a lacuna necessária para não colocar o arquivo ELF ao lado do programa principal é calculada primeiro. Após o cálculo desse endereço, é verificado se a área de memória se cruza, do endereço fornecido ao endereço base do programa principal, com a área desde o início do arquivo ELF descompactado até o final. Em caso de interseção, o endereço é retornado igual à diferença entre o endereço inicial do ELF descompactado e a quantidade de memória necessária para colocá-lo; caso contrário, o endereço calculado anteriormente é retornado.

O endereço base do programa é encontrado extraindo o endereço dos cabeçalhos do vetor auxiliar (ELF), localizado após os ponteiros para as variáveis ​​de ambiente na pilha e subtraindo o tamanho do cabeçalho 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 --------------------------------------------------------------------------- 

A estrutura pela qual cada elemento do vetor auxiliar é descrito tem a forma:

 typedef struct { uint64_t a_type; //   union { uint64_t a_val; //  } a_un; } Elf64_auxv_t; 

Um dos valores válidos de a_type é AT_PHDR, a_val apontará para os cabeçalhos do programa. A seguir está o código para a função 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; } 

Descrição do script do vinculador


É necessário definir os caracteres para as variáveis ​​externas descritas acima e também garantir que os dados do código e do carregador após a compilação estejam na mesma seção .text. Isso é necessário para extrair convenientemente o código da máquina do carregador simplesmente cortando o conteúdo desta seção do arquivo. Para atingir esses objetivos, o seguinte script vinculador foi escrito:

 ENTRY(_start) SECTIONS { . = 0; .text :{ *(.text) *(.text.startup) *(.data) *(.rodata) payload_size = .; QUAD(0) key_seed = .; QUAD(0) iv_seed = .; QUAD(0) loader_end = .; } } 

Vale a pena explicar que QUAD (0) coloca 8 bytes de zeros, em vez dos quais o empacotador substituirá valores específicos. Para cortar o código da máquina, foi criado um pequeno utilitário que também grava no início do código da máquina a mudança do ponto de entrada no carregador de inicialização desde o início do carregador, o deslocamento dos valores dos caracteres payload_size, key_seed e iv_seed desde o início do carregador. O código para este utilitário está disponível aqui . Isso termina a descrição do carregador de inicialização.

Empacotador diretamente


Considere a principal função do empacotador. Ele usa dois argumentos de linha de comando: o nome do arquivo de entrada é argv [1] e o nome do arquivo de saída é argv [2]. Primeiro, o arquivo de entrada é exibido na memória e verificado quanto à compatibilidade com o empacotador. O empacotador trabalha com apenas dois tipos de arquivos ELF: ET_EXEC e ET_DYN, e apenas com arquivos compilados estaticamente. A razão para introduzir essa restrição foi o fato de que diferentes sistemas Linux têm versões diferentes de bibliotecas compartilhadas, ou seja, a probabilidade de um programa compilado dinamicamente não iniciar em um sistema diferente do sistema pai é bastante alta. O código correspondente na função principal:

 size_t mapped_size; void *mapped = map_file(argv[1], &mapped_size); if(check_elf(mapped) < 0) return 1; 

Depois disso, se o arquivo de entrada passar na verificação de compatibilidade, ele será compactado:

 size_t comp_size; uint8_t *comp_buf = huffman_encode(mapped, &comp_size); 

Em seguida, o estado AES é gerado e o arquivo ELF compactado é criptografado. O estado da AES é determinado pela seguinte estrutura:

 #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; 

Código correspondente no principal:

 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); 

Depois disso, a estrutura que armazena informações sobre o carregador de inicialização é inicializada, os valores payload_size, key_seed e iv_seed no carregador de inicialização são alterados para os gerados na etapa anterior, após a qual o estado AES é redefinido. As informações sobre o carregador de inicialização são armazenadas na seguinte estrutura:

 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; 

Código correspondente no principal:

 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)); 

Na parte final, criamos um arquivo de saída, escrevemos um cabeçalho ELF, um cabeçalho de programa, um código do carregador, um arquivo ELF compactado e criptografado e um buffer de 256 bytes:

 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-  

O código principal do empacotador termina aqui, e as seguintes funções serão consideradas: a função de inicializar informações sobre o carregador de inicialização, a função de escrever o cabeçalho ELF e a função de escrever o cabeçalho do programa.

Inicializando informações do carregador de inicialização


O código da máquina do carregador está incorporado no executável do empacotador usando o código simples abaixo:

 .data .globl _loader_begin .globl _loader_end _loader_begin: .incbin "loader" _loader_end: 

Para determinar seu endereço na memória, as seguintes variáveis ​​são declaradas no arquivo main.c:

 extern void* _loader_begin; extern void* _loader_end; 

Em seguida, considere a função init_loader. Primeiro, os seguintes valores são lidos sequencialmente: o deslocamento do ponto de entrada desde o início do gerenciador de inicialização (entry_offset), a mudança do tamanho do arquivo ELF compactado desde o início do gerenciador de inicialização (payload_size_patch_offset), a mudança do valor inicial do gerador para a chave desde o início do gerenciador de inicialização (key_seed_patch_official the inicialização a partir do início do carregador de inicialização (iv_seed_patch_offset). Em seguida, o endereço do carregador é adicionado aos três últimos valores; portanto, ao remover a referência de ponteiros e atribuir valores a eles, substituiremos os zeros atribuídos no estágio de layout (QUAD (0)) pelos valores que precisamos.

 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; } 

Aqui ocorre a inicialização padrão do cabeçalho ELF e sua subsequente gravação em um arquivo, a única coisa a se prestar atenção é o fato de que nos arquivos ET_DYN ELF o segmento descrito pelo primeiro cabeçalho do programa inclui não apenas o código executável, mas também o cabeçalho ELF e todos os cabeçalhos programas. Portanto, seu deslocamento desde o início deve ser igual a zero, o tamanho deve ser a soma do tamanho do cabeçalho ELF, todos os cabeçalhos de programa e código executável e o ponto de entrada é determinado como a soma do tamanho do cabeçalho ELF, o tamanho de todos os cabeçalhos de programa e o deslocamento desde o início do código executável.

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)); //      } 

Aqui, o cabeçalho do programa é inicializado e depois gravado em um arquivo. Você deve prestar atenção ao deslocamento relativo ao início do arquivo e ao tamanho do segmento descrito pelo cabeçalho do programa. Conforme descrito no parágrafo anterior, o segmento descrito por este cabeçalho inclui não apenas o código executável, mas também o cabeçalho ELF e o cabeçalho do programa. Também tornamos o segmento com código executável acessível para gravação, devido ao fato de a implementação do AES usada no gerenciador de inicialização criptografar e descriptografar os dados "no local".

Alguns fatos sobre o trabalho do empacotador


Durante o teste, percebeu-se que os programas compilados estaticamente com glibc passam para segfault ao iniciar, nesta instrução:

  movq% fs: 0x28,% rax 

Não consegui descobrir por que isso acontece. Ficarei feliz em compartilhar informações sobre esse assunto. Em vez do glibc, você pode usar o musl-libc, tudo funciona com ele sem falhas. Além disso, o empacotador foi testado com programas golang compilados estaticamente, por exemplo, um servidor http. Para falhas estáticas completas dos programas golang, os seguintes sinalizadores devem ser usados:

  CGO_ENABLED = 0 vai construir -a -ldflags '-extldflags "-static"'. 

A última coisa com a qual o empacotador foi testado foram os arquivos ET_DYN ELF sem um vinculador dinâmico. É verdade que, ao trabalhar com esses arquivos, a função elf_load_addr pode falhar. Na prática, ele pode ser cortado do gerenciador de inicialização e usar um endereço fixo, por exemplo, 0x10000.

Conclusão


Obviamente, esse empacotador não faz sentido para ser usado para a finalidade pretendida, pois os arquivos protegidos por ele são descriptografados com muita facilidade. O objetivo deste projeto era dominar melhor o trabalho com arquivos ELF, a prática de gerá-los e também a preparação para a criação de um empacotador mais completo.

Source: https://habr.com/ru/post/pt483368/


All Articles