6 pontos por GN⁺ 2024-02-03 | 1 comentários | Compartilhar no WhatsApp
  • 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

1 comentários

 
GN⁺ 2024-02-03
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

    • Recentemente, escrevi um arquivo ELF diretamente: https://github.com/avik-das/garlic/blob/master/recursive/elf...
      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...
    • De modo parecido, também recomendo escrever um loader ELF simples
      Linkedição dinâmica tem uma complexidade de implementação considerável, mas, se você der suporte apenas a ELFs estáticos, é bem direto
    • Modificar ELFs existentes também é muito educativo e divertido
      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
    • Quando você faz isso por conta própria, na prática é quase assembly manual
      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
    • A Magnetized Needle and a Steady Hand, de Chris Wellons, é um artigo sobre criar um executável ELF do zero: https://nullprogram.com/blog/2016/11/17/
  • Pelo que entendo, o símbolo main é específico de C
    O símbolo _start é o ponto de entrada binário independente de linguagem e, neste caso, chama main
    Se tivesse existido uma convenção de chamar o ponto de entrada de _start e passar para ele o argc/argv de main, o formato teria sido muito menos flexível

    • A rigor, o nome _start também não é especial
      O 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 ELF
      Se você escrever seu próprio script de linkedição, pode dar ao ponto de entrada o nome que quiser
    • O símbolo main, corretamente, é fornecido apenas em hosted C
      Em freestanding C, você pode ter o ponto de entrada que quiser
      _start també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 -Wl
      Alé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 exit para que o processo seja encerrado corretamente quando main retornar um código de status
      Pelo menos no Linux, é assim que funciona
    • Depende do runtime da linguagem, mas uma das tarefas comuns é inicializar valores estáticos globais diferentes de zero
      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 _start
      No 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 strings sempre funcionava muito bem

    • É preciso explicar isso também aos juízes alemães
      Um pobre coitado foi multado porque encontrou uma senha da mesma forma que alguém executaria strings em um binário
      Os 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

    • Pessoalmente, não tenho certeza de que binários portáveis sejam, no saldo, algo bom
      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
    • Posso ter entendido errado, mas acho que o APE não é, por si só, um formato binário
      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 cat de um binário no terminal é um atalho para a tristeza
    Eu gosto de | hd, que na prática é hexdump -C, embora seja igualmente enigmático a olho nu