- Mesmo um pequeno programa C
hello se torna um executável ELF no Linux, e é possível inspecionar diretamente sua estrutura interna com readelf, nm e objdump
- Os eixos centrais para entender um executável são símbolos, seções e segmentos; eles cuidam, respectivamente, da ligação de funções, da separação entre código/dados e do layout em memória durante a execução
- Com
objdump e readelf, é possível verificar os bytes e atributos de seções como .text, .rodata, .data, .bss e .interp
- Um programa não começa diretamente em
main; ele entra por _start e, depois de várias tarefas de inicialização, chama main
- Um executável não é um “bloco ilegível”, mas um arquivo em um formato definido; usando ferramentas, é possível rastrear passo a passo código, strings e informações de linking
Executáveis são formatos de arquivo legíveis
- Um executável compilado à primeira vista parece um “binário mágico” impossível de ler, mas na prática é um formato de arquivo compreensível
- O exemplo trata de binários ELF no Linux; como binários dependem da plataforma, a explicação também fica vinculada a ela
- O exemplo usado é o seguinte programa em C
#include <stdio.h>
int main() {
printf("Penguin!\n");
}
- Depois de compilar com
gcc -o hello hello.c para criar o executável hello, inspecionamos seu interior
- O fluxo geral gira em torno de três conceitos
- Símbolos (symbols): usados para localizar funções definidas em outro lugar ao chamar algo como
printf
- Seções (sections): unidades que separam código e dados, como
.text, .data, .rodata etc.
- Segmentos (segments): agrupam seções como unidades de layout em memória no momento da execução
Mesmo abrindo como texto, algumas pistas aparecem
- Se você abrir o executável diretamente, como em
cat hello, a maior parte será exibida como caracteres corrompidos
- Ainda assim, é possível encontrar strings como
Penguin! e ELF na saída
- ELF é o nome do formato de arquivo desse binário
- A maior parte da saída é difícil para humanos lerem porque o executável é composto de dados binários
A tabela de símbolos mostra nomes de funções e ligações
readelf --symbols hello imprime a tabela de símbolos do executável
- Na saída do exemplo aparecem símbolos importantes
main: o endereço da função main() escrita no programa
puts@@GLIBC_2.2.5: parece ser uma referência relacionada ao printf chamado no código; presume-se que o compilador a tenha trocado por puts como otimização
_start: um símbolo importante relacionado ao início do programa
- O programa não começa diretamente em
main; na verdade, ele entra por _start
_start realiza várias tarefas importantes, incluindo a chamada de main
Símbolos tornam a ligação possível
- Se você escreve uma função chamada
hello em um programa, o código dessa função no binário compilado recebe um símbolo chamado hello
- Para chamar uma função de biblioteca como
printf, é necessário um modo de encontrar a localização do código dessa função
- O processo de encontrar a localização de uma função é o linking
- Quando acontece logo após a compilação, é linking estático
- Quando acontece no momento da execução do programa, é linking dinâmico
- A
libc contém as funções da biblioteca padrão C
- Mesmo que
nm imprima “no symbols” para a libc, é possível ver os símbolos com objdump -tT /lib/x86_64-linux-gnu/libc-2.15.so
- Na tabela de símbolos da libc, é possível verificar funções como
sprintf, strlen, fork e exec
- Dá para imaginar o funcionamento do linking dinâmico pelo fluxo em que
hello chama puts e encontra a localização de puts na tabela de símbolos da libc
Seções separam código e dados
objdump -s hello imprime os bytes contidos em cada seção do executável em hexadecimal e ASCII
- As principais seções são as seguintes
.text: contém o código real do programa, ou seja, assembly, incluindo _start e main
.rodata: contém dados somente leitura; no exemplo, há a string "Penguin!"
.interp: contém o nome do arquivo do linker dinâmico
- Seções e segmentos são usados em momentos diferentes
- Seções são usadas pelo
ld no momento do linking
- Segmentos são usados no momento da execução
- Com
readelf --sections hello, é possível ver os metadados das seções com mais detalhes
- As flags do exemplo revelam a natureza de cada seção
.text: executável e somente leitura
.rodata: somente leitura
.data: leitura/escrita
.bss: área de dados gravável
Vendo código de máquina como assembly por disassembly
- A seção
.text contém bytes que a CPU interpreta e executa como código
- Como é difícil para humanos entenderem de imediato bytes iniciais da
.text como 31 ed, é necessário um disassembler
objdump -d ./hello faz o disassembly da seção .text e a mostra como instruções assembly
- Na saída do exemplo,
31 ed aparece como xor %ebp,%ebp
- Dessa forma, é possível verificar a quais instruções assembly correspondem os bytes de código dentro do binário
Segmentos definem o layout em memória durante a execução
- Um executável também é composto por segmentos, ou program headers
readelf --segments hello mostra os segmentos do programa e o mapeamento entre seções e segmentos
- Segmentos são usados para decidir como dividir e posicionar cada parte do programa na memória
- O exemplo tem dois segmentos
LOAD principais
- Primeiro
LOAD: marcado como R E, com permissão de leitura/execução
- Segundo
LOAD: marcado como RW, com permissão de leitura/escrita
.text precisa ser lida e executada, mas não deve ser escrita, então fica no primeiro segmento
.data e .bss precisam poder ser escritas, mas não precisam ser executadas, então ficam no segundo segmento
Ferramentas e materiais para explorar mais
- Executáveis ELF não são uma magia especial, mas um formato de arquivo comum, e binários Linux podem ser investigados com
readelf, nm e objdump
- Materiais relacionados
1 comentários
Opiniões do Hacker News
Como já foi dito em outra thread, https://news.ycombinator.com/item?id=38847750#38862450, recomendo fortemente escrever um ELF à mão pelo menos uma vez
É um bom exercício para entender os componentes básicos de um executável, e também ajuda quando você quer abordar o tema de baixo para cima, em vez de cima para baixo como neste texto
Há muitas discussões boas nas várias threads daquele outro post do HN
Também criei uma visualização interativa que mostra os bytes do arquivo para explicar o formato a mim mesmo e a outras pessoas
Ao clicar nos bytes, aparece uma explicação, e os bytes relacionados dentro do arquivo são destacados para ajudar na compreensão: https://scratchpad.avikdas.com/elf-explanation/elf-explanati...
Linkedição dinâmica tem uma complexidade de implementação considerável, mas, se você der suporte apenas a ELFs estáticos, é bem direto
No começo é frustrante porque, quando não funciona, depurar é praticamente impossível, mas, quando finalmente começa a funcionar, é muito legal
ELFs podem ser corrigidos de formas interessantes, e o vetor auxiliar permite até auto-observação em tempo de execução
Como o Linux fornece o endereço da tabela de cabeçalhos de programa, a partir dali dá para chegar a qualquer lugar, e basta expandir o segmento LOAD para cobrir o binário inteiro
Por exemplo, criei uma ferramenta que insere módulos e código Lisp diretamente dentro do executável do meu interpretador Lisp
O segmento inserido é carregado automaticamente pelo ELF, e o interpretador o encontra e executa
Gostei tanto desse pequeno recurso que até escrevi um texto sobre ele: https://www.matheusmoreira.com/articles/self-contained-lone-...
Seria bom se linguagens mainstream adotassem esse método
Você lê a documentação, escolhe os bytes necessários na folha de dados do processador, coloca-os em ordem nas várias seções, preenche os campos do ELF e, no fim, tudo se resume a digitar tudo
Em ambientes da era pré-ELF, como o Apple II de 8 bits, o monitor de linguagem de máquina permitia inserir diretamente os bytes do programa, e esses bytes eram executados
Salvar em disco era apenas um pouco mais complicado, e aí havia mais uma oportunidade
Dá para criar arquivos com um editor de setores de disco, e assim por diante
Pelo que entendo, o símbolo
mainé específico de CO símbolo
_starté o ponto de entrada binário independente de linguagem e, neste caso, chamamainSe tivesse existido uma convenção de chamar o ponto de entrada de
_starte passar para ele oargc/argvdemain, o formato teria sido muito menos flexível_starttambém não é especialO binário registra no cabeçalho o endereço do ponto de entrada, e o sistema operacional começa a execução nesse endereço
Chamar esse símbolo de
_starté apenas uma convenção de C e de outras linguagens, e o linker o usa para definir o ponto de entrada ao escrever o cabeçalho ELFSe você escrever seu próprio script de linkedição, pode dar ao ponto de entrada o nome que quiser
main, corretamente, é fornecido apenas em hosted CEm freestanding C, você pode ter o ponto de entrada que quiser
_starttambém é apenas o valor padrão do linker, e você pode especificar um símbolo melhor com-Wl,--entry="${symbol}"; o GCC também permite configurá-lo diretamente, sem o feio-WlAlém disso, o ponto de entrada, na realidade, não é um símbolo, mas um ponteiro
O linker apenas pega o endereço do símbolo especificado e o define como o ponto de entrada do ELF
Além da contagem de argumentos e do vetor de argumentos, a pilha também contém o vetor de ambiente e o vetor auxiliar
O código de inicialização do processo pode ser tão simples quanto retirar esses valores da pilha, colocá-los nos registradores apropriados e chamar a função C desejada
O ponto de entrada em si não é uma função, portanto não há para onde retornar
O código do ponto de entrada deve terminar com a chamada de sistema
exitpara que o processo seja encerrado corretamente quandomainretornar um código de statusPelo menos no Linux, é assim que funciona
Em linguagens como Rust/C/C++, também é possível injetar variáveis a serem inicializadas por meio de flags do linker
Se o programa tiver linkedição dinâmica, pelo que sei o runtime do linker é executado antes de
_start, resolve os links e então passa o controle para_startNo fim das contas, são hacks em cima de hacks que foram adicionados organicamente para oferecer extensibilidade, e continuam sendo usados porque foram socialmente bem aceitos e funcionam bem o suficiente
Em 2012, quando mudei meu caminho acadêmico de matemática para ciência da computação, comecei um blog, e esse assunto foi literalmente a primeira coisa que estudei: https://heinrichhartmann.com/archive/Dissecting-Hello-World....
Nunca me arrependi de ter entrado nessa toca de coelho tão profunda
Se minha memória não falha, Julia também tem formação em matemática
Talvez o motivo de pessoas da matemática serem atraídas por esse tipo de experimento seja o desejo de raciocinar a partir do zero
Fico feliz que ela tenha tornado esse conteúdo acessível a tanta gente
Os textos da Julia são sempre excelentes
Ao ensinar que código compilado não consegue esconder segredos, demonstrar
stringssempre funcionava muito bemUm pobre coitado foi multado porque encontrou uma senha da mesma forma que alguém executaria
stringsem um binárioOs juízes entenderam que ele “contornou” as medidas de segurança do software: https://www.theregister.com/2024/01/19/germany_fine_security...
Não é uma crítica nem implicância, só um pensamento que me veio à cabeça
A frase “como binários são praticamente a definição de algo específico de plataforma, tudo aqui também é específico de plataforma” me fez lembrar de quando o Actually Portable Executable mostrou que o mesmo binário podia rodar em várias plataformas
Ainda não me recuperei totalmente, mentalmente, daquele momento surreal
Durante décadas tentamos resolver o problema de multiplataforma de todo tipo de maneira fractal, com Java, bibliotecas cross-platform etc., e no fim a solução esteve bem diante do nosso nariz o tempo todo
Na era dos computadores rápidos, acho que distribuição de código-fonte e compilação local são melhores do que distribuição de binários
Infelizmente, boa parte do software de que dependemos é grande demais, e os compiladores são relativamente lentos, então a distribuição de binários vira uma espécie de mal necessário
Eu gostaria que mais esforço fosse direcionado a componentes de software mais simples, que compilem naturalmente rápido, e a compiladores mais rápidos, em vez de binários portáveis
Ele é um script que pode ser executado em qualquer sistema, e esse script consegue carregar um binário
Se me lembro bem, a versão original precisava decodificar de base64 antes de carregar
Então está mais para um loader de binários executável
No começo dos anos 1990, fiquei fascinado por formatos de executáveis e passei algumas semanas criando, em Modula 2, um visualizador de executáveis DOS e Windows, ao qual dei o nome VEXE, e o distribuí como shareware em 1991
A ferramenta ganhou uma popularidade de nicho entre crackers, a ponto de ser mencionada nos tutoriais do +ORC: https://gist.github.com/callowaysutton/48bdf0245e17e72d41a15...
Provavelmente porque conseguia detectar vários métodos de criptografia e compressão usados para impedir a engenharia reversa de programas
Se você tem curiosidade sobre o quão pequeno um arquivo binário ELF pode ficar, talvez goste deste texto divertido: https://www.muppetlabs.com/~breadbox/software/tiny/teensy.ht...
Vale também conferir minha ferramenta que permite explorar ELF com SQL: https://github.com/fzakaria/sqlelf
Alguém poderia recomendar materiais ou livros práticos de introdução à programação de baixo nível para alguém com forte background em Python?
Comecei recentemente a aprender Rust e percebi que há muita coisa para colocar em dia
Nunca fiz uma disciplina de compiladores, então talvez esteja deixando passar muita informação
Por exemplo, eu nem sabia que existiam símbolos em binários, nem conhecia a diferença entre ELF e Mach-O
Fazer
catde um binário no terminal é um atalho para a tristezaEu gosto de
| hd, que na prática éhexdump -C, embora seja igualmente enigmático a olho nu