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:
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;
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;
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:
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;
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);
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];
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;
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);
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) {
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) {
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.