“A Engenharia Reversa é o processo de extrair conhecimento ou design de algo feito pelo homem (…) O Software é uma das mais complexas e intrigantes tecnologias dentre nós nos dias de hoje. Sua engenharia reversa se trata de abrir o programa e olhá-lo por dentro. Claro, nós não precisamos de nenhuma chave de fenda nesta jornada. Da mesma forma que a engenharia de software, seu reverso é puramente um processo virtual envolvendo apenas um processador e a mente humana.” Reversing – Secrets of Reverse Engineering – Eldad Eilam.
Antes de nos debruçarmos na reversão em si, é importante sabermos como se dá o processo de engendramento do software binário.
Para a sua construção, faz-se necessária (a não ser que vocês sejam masoquistas e queiram construir em código de máquina) uma linguagem de programação. Essa se traduz como um método padronizado para informar instruções a um processador. Cada linguagem detém sua própria padronização assim como seu nível de abstração (baixo, médio e alto).
Na sequência, são destacados os procedimentos demandados para a transformação do código escrito em um bloco de instruções “entendível” aos olhos do computador.
Construção de Software
A construção ou compilação é um processo de vários estágios que envolve várias ferramentas. No nosso caso, as ferramentas usadas serão o front-end gcc (GNU Compiler), o cc1 (C Compiler), o as (GNU Assembler), o collect2 (ld wrapper), e o ld (GNU Linker). O conjunto completo de ferramentas utilizadas no processo de compilação é chamado toolchain, que, quase que por padrão, já vem instalado nas distribuições GNU/Linux. A criação de toolchains específicas são primordiais para o cross-compiling, mas esse assunto fica para um post futuro.
Durante uma invocação do GCC a sequência de comandos executadas percorre os seguintes estágios:
- pré-processamento (expansão de macros)
- compilação (do código fonte para linguagem assembly)
- assembler (de linguagem assembly para código de máquina)
- linking (criação do binário final)
Criemos como exemplo um programa minimalista em C com o famoso Hello World.
$ mkdir construindo; cd construindo
$ echo -e "#include <stdio.h>\nint main() { printf(\"Hello World\\\n\"); return 0;}" > programa1.c
Para esta compilação, usemos o gcc.
$ gcc programa1.c -o programa1
Ao rodar o gcc, as etapas de pré-processamento, compilação, assembly e linking são efetuadas uma após a outra de forma transparente, até que o binário ELF programa1 seja criado.
$ ./programa1
Hello World
Em seguida, para melhor entendimento, percorreremos cada fase manualmente.
Pré-processamento e Compilação
Em versões passadas do gcc, o pré-processamento era feito como um estágio em separado através do cpp (C Preprocessor).
$ cpp programa1.c -o programa1.i
A saída era um arquivo com as macros expandidas e declarações de arquivos header incluídas. O pré-processador, em verdade, traduzia a abstração média da linguagem C (na qual programamos) para uma reconhecível à próxima etapa da compilação.
# 1 "programa1.c"
# 1 "<command-line>"
# 1 "programa1.c"
# 1 "/usr/include/stdio.h" 1 3 4
# 28 "/usr/include/stdio.h" 3 4
# 1 "/usr/include/features.h" 1 3 4
...
extern void funlockfile (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__));
# 940 "/usr/include/stdio.h" 3 4
# 2 "programa1.c" 2
int main(void) { printf("Hello World\n"); return 0;}
Esse primeiro estágio, atualmente, é incorporado pelo compilador cc1 quando usado com opções default; ou seja, geralmente é omitido.
$ gcc programa1.c -o programa1 -v
...
/usr/lib/gcc/x86_64-linux-gnu/4.7/cc1 -quiet -v -imultiarch x86_64-linux-gnu programa1.c -quiet -dumpbase programa1.c -mtune=generic -march=x86-64 -auxbase programa1 -version -fstack-protector -o /tmp/ccZvPRPS.s
...
Como pode ser visto no modo verboso (detalhado), o cc1 efetua o pré-processamento e compilação, gerando diretamente o arquivo assembly. De toda forma podemos fazer com que o cc1 seja invocado duas vezes, com o uso da opção -no-integrated-cpp; primeiramente para o pré-processamento, gerando o arquivo expandido (*.i) e posteriormente para a compilação, gerando o arquivo para montagem (*.s). Tal procedimento é útil quando se pretende utilizar um pré-processador alternativo, por exemplo.
# gcc programa1.c -o programa1 -v -no-integrated-cpp
...
/usr/lib/gcc/x86_64-linux-gnu/4.7/cc1 -E -quiet -v -imultiarch x86_64-linux-gnu programa1.c -mtune=generic -march=x86-64 -fstack-protector -o /tmp/ccjNwBsL.i
...
/usr/lib/gcc/x86_64-linux-gnu/4.7/cc1 -fpreprocessed /tmp/ccjNwBsL.i -quiet -dumpbase programa1.c -mtune=generic -march=x86-64 -auxbase programa1 -version -fstack-protector -o /tmp/cc0B4fmf.s
...
Podemos, também, interromper o processo do gcc, invocando o cc1 apenas uma vez com o intuito de gerarmos o arquivo (*.i) pré-processado.
# gcc programa1.c -o programa1.i -v -E
...
/usr/lib/gcc/x86_64-linux-gnu/4.7/cc1 -E -quiet -v -imultiarch x86_64-linux-gnu programa1.c -o programa1.i -mtune=generic -march=x86-64 -fstack-protector
...
Para isso utilizamos a opção -E.
O objetivo principal do uso do cc1, através ou não do arquivo expandido (programa1.i), é gerar o respectivo código assembly, linguagem de baixo nível.
$ gcc programa1.i -o programa1.s -v -S
...
/usr/lib/gcc/x86_64-linux-gnu/4.7/cc1 -fpreprocessed programa1.i -quiet -dumpbase programa1.i -mtune=generic -march=x86-64 -auxbase-strip programa1.s -version -o programa1.s -fstack-protector
...
$ gcc programa1.c -o programa1.s -v -S
...
/usr/lib/gcc/x86_64-linux-gnu/4.7/cc1 -quiet -v -imultiarch x86_64-linux-gnu programa1.c -quiet -dumpbase programa1.c -mtune=generic -march=x86-64 -auxbase-strip programa1.s -version -o programa1.s -fstack-protector
...
A opção -S, instrui o gcc a apenas converter o código C, pré-processado ou não, para a linguagem de montagem (programa1.s).
.file "programa1.c"
.section .rodata
.LC0:
.string "Hello World"
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movl $.LC0, %edi
call puts
movl $0, %eax
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (Ubuntu/Linaro 4.7.2-2ubuntu1) 4.7.2"
.section .note.GNU-stack,"",@progbits
Atenção! O código assembly pode estar diferente do gerado em seu computador. Essa divergência pode ter sido ocasionada por vários motivos: versão do GCC; arquitetura utilizada; flags de compilação.
Para gerar o respectivo código em 32 bits, basta utilizar a opção -m32.
$ gcc programa1.i -S -m32
ou
$ gcc programa1.c -S -m32
Vejam a diferença:
.LFB0:
.cfi_startproc
pushl %ebp # <-
.cfi_def_cfa_offset 8 # <-
.cfi_offset 5, -8 # <-
movl %esp, %ebp # <-
.cfi_def_cfa_register 5 # <-
andl $-16, %esp # <-
subl $16, %esp # <-
movl $.LC0, (%esp)call puts # <-
movl $0, %eax
leave # <-
.cfi_restore 5 # <-
cfi_def_cfa 4, 4ret # <-
.cfi_endproc
Assembler (Montador)
Na montagem, o código assembly é convertido nas suas instruções correlatas em código de máquina. O resultado é um arquivo-objeto (object file).
$ gcc programa1.s -o programa1.o -v -c
...
as -v --64 -o programa1.o programa1.s
...
$ gcc programa1.s -o programa1.o -v -m32 -c
...
as -v --32 -o programa1.o programa1.s
...
A opção -c faz com o gcc apenas compile ou monte o arquivo fonte, mas não o linque.
Como visto acima, o as faz a montagem do binário usando o arquivo assembly (programa1.s). Ao rodarmos o file contra o arquivo-objeto construído, podemos verificar a estrutura ELF.
$ file programa1.o
programa1.o: ELF 32-bit LSB relocatable, Intel 80386, version 1 (SYSV), not stripped
programa1.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
Entretanto, como o processo de compilação ainda não foi finalizado, ao tentarmos executá-lo recebemos a mensagem de que é impossível executar o arquivo binário. Ao usarmos o ltrace (ferramenta de análise de chamadas à bibliotecas) recebemos a mensagem ./programa1.o is not an ELF executable nor shared library. O assembler construiu o bloco de instruções binárias para a arquitetura, contudo não definiu os endereços referentes às funções externas, assim como não relocou o binário para a correta execução.
Linking
Até então o arquivo-objeto montado não sabe para onde olhar (endereço das funções necessárias ao correto funcionamento). O Linking ou lincagem é, neste caso, o procedimento de aglutinação de arquivos-objeto, resolução de símbolos e relocação das seções e respectivos endereços do binário outrora gerado.
Vejamos.
$ gcc programa1.o -o programa1 -v
...
/usr/lib/gcc/x86_64-linux-gnu/4.7/collect2 --sysroot=/ --build-id --no-add-needed --as-needed --eh-frame-hdr -m elf_x86_64 --hash-style=gnu -dynamic-linker /lib64/ld-linux-x86-64.so.2 -z relro -o programa1 /usr/lib/gcc/x86_64-linux-gnu/4.7/../../../x86_64-linux-gnu/crt1.o /usr/lib/gcc/x86_64-linux-gnu/4.7/../../../x86_64-linux-gnu/crti.o /usr/lib/gcc/x86_64-linux-gnu/4.7/crtbegin.o -L/usr/lib/gcc/x86_64-linux-gnu/4.7 -L/usr/lib/gcc/x86_64-linux-gnu/4.7/../../../x86_64-linux-gnu -L/usr/lib/gcc/x86_64-linux-gnu/4.7/../../../../lib -L/lib/x86_64-linux-gnu -L/lib/../lib -L/usr/lib/x86_64-linux-gnu -L/usr/lib/../lib -L/usr/lib/gcc/x86_64-linux-gnu/4.7/../../.. programa1.o -lgcc --as-needed -lgcc_s --no-as-needed -lc -lgcc --as-needed -lgcc_s --no-as-needed /usr/lib/gcc/x86_64-linux-gnu/4.7/crtend.o /usr/lib/gcc/x86_64-linux-gnu/4.7/../../../x86_64-linux-gnu/crtn.o
...
ou
$ gcc programa1.o -o programa1 -v -m32
...
/usr/lib/gcc/x86_64-linux-gnu/4.7/collect2 --sysroot=/ --build-id --no-add-needed --as-needed --eh-frame-hdr -m elf_i386 --hash-style=gnu -dynamic-linker /lib/ld-linux.so.2 -z relro -o programa1 /usr/lib/gcc/x86_64-linux-gnu/4.7/../../../../lib32/crt1.o /usr/lib/gcc/x86_64-linux-gnu/4.7/../../../../lib32/crti.o /usr/lib/gcc/x86_64-linux-gnu/4.7/32/crtbegin.o -L/usr/lib/gcc/x86_64-linux-gnu/4.7/32 -L/usr/lib/gcc/x86_64-linux-gnu/4.7/../../../i386-linux-gnu -L/usr/lib/gcc/x86_64-linux-gnu/4.7/../../../../lib32 -L/lib/i386-linux-gnu -L/lib/../lib32 -L/usr/lib/i386-linux-gnu -L/usr/lib/../lib32 -L/usr/lib/gcc/x86_64-linux-gnu/4.7 -L/usr/lib/gcc/x86_64-linux-gnu/4.7/../../../i386-linux-gnu -L/usr/lib/gcc/x86_64-linux-gnu/4.7/../../.. -L/lib/i386-linux-gnu -L/usr/lib/i386-linux-gnu programa1.o -lgcc --as-needed -lgcc_s --no-as-needed -lc -lgcc --as-needed -lgcc_s --no-as-needed /usr/lib/gcc/x86_64-linux-gnu/4.7/32/crtend.o /usr/lib/gcc/x86_64-linux-gnu/4.7/../../../../lib32/crtn.o
...
O que o gcc realmente faz é invocar o collect2 (wrapper para o ld (binutils)) informando todos os arquivos-objeto que devem ser lincados.
É verdade que o ld pode ser utilizado diretamente; de toda sorte, o gcc/collect2 já informa todas as libs necessárias para o linking, o que facilita bastante.
Após terminado o processo, é possível verificar que o executável está devidamente lincado.
$ file programa1
programa1: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked (uses shared libs), for GNU/Linux 2.6.24, BuildID[sha1]=0xa0c...bf73, not stripped
programa1: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked (uses shared libs), for GNU/Linux 2.6.24, BuildID[sha1]=0xe07...8df3, not stripped
E, finalmente, executá-lo.
$ ./programa1
Hello World
Conclusão
Pudemos, neste post, acompanhar de forma breve o processo de construção de um executável ELF, exemplificando os respectivos procedimentos no ambiente GNU/Linux através do GCC. No próximo, iniciaremos uma abordagem prática da engenharia reversa que possibilitará um entendimento objetivo da estrutura ELF, assim como do método e das ferramentas utilizadas na reversão.
Até lá!
Mais Informações
Reversing – Secrets of Reverse Engineering – Eldad Eilam
Reverse Engineering
A Introduction to GCC – Brian Gough
Compiler, Assembler, Linker and Loader: A Brief Story
Basics of GCC compilation process
GNU C Compiler Internals/GNU C Compiler Architecture
GCC front-end Whitepaper - Andi Hellmund
Programming language
List of programming languages by type
C (programming language)
Low-level programming language
Assembly Language
Machine code